fix(spec): refuse a blank $between endpoint at the runtime filter door - #19374
Conversation
`parseFilterAST` lowered `{ $between: ['', ''] }` unchanged while the
published endpoint contract (`RANGE_ENDPOINT_DESCRIPTION`) states that both
endpoints are required NON-BLANK — one sentence with two truth values,
depending on which door a caller came through. The door that passed it is
the one an embedder reaches by handing a lowered filter straight to a
driver, where the range stops bounding on the blank side while still
reading as a complete range.
The comparand-shape door now refuses `''` and `undefined` at either bound,
naming the blank side (MIN / MAX plus the index) and carrying the schema
door's own two prescriptions. Scope is the schema door's notion of blank
and nothing wider: `null` keeps its own 2026-08-31 message (it prescribes
the null predicate), whitespace-only endpoints stay legal at both doors,
and falsy endpoints and falsy `$in` / `$nin` members are untouched.
The `$between` row of the `filter-comparand-shape.test.ts` pin is inverted
and only that row; the parity pin that replaces it reads both doors rather
than restating either.
Claude-Session: https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 016a3b62fe1943f2b553e40c339d5c100222bd83 && git checkout 016a3b62fe1943f2b553e40c339d5c100222bd83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 32708262d787c4a151dbeba2991b287b175df958 7682aebcc52adf9954ce653397a9b22abbbf7536 && git checkout -B drift-repro 32708262d787c4a151dbeba2991b287b175df958 && git merge --no-ff 7682aebcc52adf9954ce653397a9b22abbbf7536
node scripts/docs-audit/affected-docs.mjs --json 32708262d787c4a151dbeba2991b287b175df958 |
Contract reviewServed-tier: 20/20 Isolated contract-review subagent for the ① Derived judgments⭐ The trim question — the ruled parenthetical the dev deliberately did not implement. Judged squarely: the operative clause governs, the parenthetical is a slip, and the PR as written is correct. Not a FAIL.
Zone 2 — claims tested against the tree.
Zone 3.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Pointer from the director seat ( Generated by Claude Code |
Fixes #19071
Clause-②: no
The runtime filter door now refuses a blank
$betweenendpoint, exactly as the authoring schema door already does.The split this closes
RANGE_ENDPOINT_DESCRIPTION— the published endpoint contract shared by both of$between's bounds — has stated since 2026-09-17 (#18012, batch #146 item 5 letter A) that "BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side". That rule shipped at the schema door only.Re-measured on this branch's base
2277d1fcdat 2026-09-20T13:22Z, all three of the card's probes reproduce, control included:parseFilterAST({ at: { $between: ['', ''] } })parseFilterAST({ at: { $between: ['2026-01-01', ''] } })parseFilterAST({ at: { $between: [null, 1] } })Operator "$between" on field "at" requires two non-null boundsOne published sentence, two truth values. The door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver, where the range stops bounding on the blank side while still reading as a complete two-element range.
What this changes
assertListComparandShapes'$betweenarm refuses''andundefinedat either bound withINVALID_FILTER/ 400, naming the blank side (MIN or MAX plus the index) and carrying the schema door's own two prescriptions — write the bound you meant, or drop$betweenfor{"$gte": min}/{"$lte": max}if only one side was ever bounded. The longest assembled form measures 466 characters against the unrelaxed 500-character client bound, and the bound test grew the new cases.Order inside the arm is arity, then
null, then blank, so a pair that is blank on one side andnullon the other keeps the message it has had since 2026-08-31.The ruling says the runtime door refuses a blank endpoint "exactly as the schema door does (empty-after-trim string,
null,undefined)". Those two halves disagree, and the parenthetical is the one that does not hold: the schema door does not trim.2277d1fcd[' ', 'M'](whitespace-only)success: true['\t\n', 'M']success: true['', 'M']rangeEndpointSchema's own comment states the rule asendpoint !== ''and says in as many words: "⛔ Not a trim and not a whitespace rule: the ruling is the empty string, and widening it here would narrow a published face further than ruled."filter.test.tsthen pinsRangeOperatorSchema.safeParse({ $between: [' ', 'M'] })green on purpose, with the comment "this assertion is what keeps a later reader from widening it without a ruling of their own".So an empty-after-trim predicate here would have re-opened the very split this card exists to close — in the opposite direction, with whitespace passing the authoring door and being refused one step later — and narrowed a published face further than any ruling has. This PR implements the operative clause ("exactly as the schema door does", "the same guidance as the schema door") and leaves whitespace-only endpoints legal at both doors. ⛔ Nothing is re-adjudicated: if the intent really was to trim, that is a second narrowing of a published face and wants its own ruling, and it is one line here plus the
filter.test.tspin on the other side.Scope held, deliberately
RANGE_ENDPOINT_DESCRIPTIONandpackages/spec/src/data/filter.zod.tsare not touched — B was refused, and that file is held by open PR feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it #19335.already-registeredagainst spec(ui):FieldOperatorsSchemaaccepts a$betweenrange with a blank bound —{$between: [1, '']}parses green, so a half-filled range is not refused at the authoring door #18012's existing entry, which covers this same surface set.$betweenrow of thefilter-comparand-shape.test.tspin is inverted. The$in/$ninfalsy-member rows stand, in place, with a note saying which row moved and why: falsy VALUES are values, and this ruling is about range ENDPOINTS. The replacement pin reads BOTH doors and asserts they agree, rather than restating either.$in:[null]/$nin:[null]disagree while$null/$ne:nullagree #13357: the null-shaped carve-out recorded there is preserved unchanged —nullbounds keep their own message and their own prescription (the null predicate), and are checked first. This PR narrows nothing that ruling settled; it adds a second, differently-spelled blank alongside it.Verification
Every reading below was taken in this worktree at HEAD
7682aebc(aftergit merge origin/main), unless the line says otherwise.pnpm --filter @objectstack/spec build— green.pnpm --filter @objectstack/spec test— 502 files / 14696 tests passed.pnpm --filter @objectstack/spec typecheck— green (tsc --noEmit, scripts project, and the test-layer debt ledger held at 54 files / 259 errors / 144 pinned signatures).pnpm --filter @objectstack/objectql exec vitest run src/engine-filter-array-lowering.test.ts src/engine-comparand-type-door.test.ts src/query-expression-conformance.test.ts src/protocol-explicit-filter-field-gate.test.ts— 4 files / 289 tests passed. First attempt was a PREREQUISITE failure, not a red: the closure was unbuilt and the run died on "Failed to resolve entry for package @objectstack/core". Built@objectstack/objectql^...and re-ran.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack— 81 families at this head (75 before the changeset existed). All 81 run with exit codes captured to disk before any pipe;--ranreconciles 81 derived / 80 run / 1 NOT MEASURED / 0 UNRUN.node scripts/check-plugin-teardown-shape.mjs --self-test, exit 3: its positive control is pinned to commit621a487607881c66b2899b7e3477115229a156b4, which this shallow checkout cannot reach;git fetch --deepen 500did not bring it. That command grades the checker's own fixtures, not this diff — the PR-relevant form,node scripts/check-plugin-teardown-shape.mjs, exits 0.check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) and one hit my runner's own 150-second cap (check:query-options-erasure). All four are green afterpnpm build, andcheck:type-check-debtre-measures 4 ledger entries / 53 raw errors, none above its recorded number.pnpm lint— the whole repo,eslint . --no-inline-config, exit 0 at7682aebc. No narrowing was claimed and none was needed.Acceptance notes
Noted while measuring, ⛔ not filed by this dispatch and ⛔ not fixed here:
{ $field }reference as a$betweenendpoint is refused by the schema door and accepted by the runtime door. The same two-door shape as this card, one endpoint spelling over, and already ruled on the schema side:RANGE_ENDPOINT_DESCRIPTIONsays "A { $field } reference is NOT an endpoint shape", ruled 2026-08-11 under [spec]FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596. Reproduction on2277d1fcd:RangeOperatorSchema.safeParse({ $between: [{ $field: 'a' }, 'M'] })answerssuccess: false, whileparseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } })returns the filter unchanged. It is left alone here because its refusal needs its own wording and, being a second narrowing of a published face, its own ruling — the same reason this card exists. Dedupe words:field reference between endpoint·parseFilterAST runtime door·filter-comparand-shape·7596 endpoint shape·two doors disagree.@objectstack/honofails its dts build underturbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*', withTS7016: Could not find a declaration file for module '@objectstack/plugin-hono-server', and builds clean underpnpm buildon the same tree minutes later. Reads as a build-ordering race in a shared warm cache rather than a defect in the tree; recorded because the gate prescription forcheck:type-check-debtnames that exact command. Carries no reproduction that does not depend on cache state, so it is an observation, not one of the three filing classes.Generated by Claude Code