Skip to content

Commit 32b5831

Browse files
Samclaude
andauthored
fix(spec): refuse a blank $between endpoint at the runtime filter door (#19374)
Fixes #19071 Clause-②: no The runtime filter door now refuses a blank `$between` endpoint, 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 `2277d1fcd` at 2026-09-20T13:22Z, all three of the card's probes reproduce, control included: | probe | before this PR | |:--|:--| | `parseFilterAST({ at: { $between: ['', ''] } })` | returned unchanged — same object reference | | `parseFilterAST({ at: { $between: ['2026-01-01', ''] } })` | returned unchanged — same object reference | | control `parseFilterAST({ at: { $between: [null, 1] } })` | threw `Operator "$between" on field "at" requires two non-null bounds` | One 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`' `$between` arm refuses `''` and `undefined` at either bound with `INVALID_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 `$between` for `{"$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 and `null` on the other keeps the message it has had since 2026-08-31. ## ⚠️ One ruled word could not be implemented as written — please read this cell 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.** | endpoint pair | schema door on `2277d1fcd` | this PR's runtime door | |:--|:--|:--| | `[' ', 'M']` (whitespace-only) | `success: true` | passes | | `['\t\n', 'M']` | `success: true` | passes | | `['', 'M']` | refused, names MIN | refused, names MIN | `rangeEndpointSchema`'s own comment states the rule as `endpoint !== ''` 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.ts` then pins `RangeOperatorSchema.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.ts` pin on the other side. ## Scope held, deliberately - ⛔ `RANGE_ENDPOINT_DESCRIPTION` and `packages/spec/src/data/filter.zod.ts` are not touched — B was refused, and that file is held by open PR #19335. - ⛔ No ADR-0087 transition is registered — C was refused. The changeset's disposition is `already-registered` against #18012's existing entry, which covers this same surface set. - ⭐ Only the `$between` row of the `filter-comparand-shape.test.ts` pin is inverted. The `$in` / `$nin` falsy-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. - Pointer for #13357: the null-shaped carve-out recorded there is preserved unchanged — `null` bounds 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` (after `git 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. - Gate family re-derived in this worktree from the real changed paths: `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; `--ran` reconciles 81 derived / 80 run / 1 NOT MEASURED / 0 UNRUN. - The one NOT MEASURED is `node scripts/check-plugin-teardown-shape.mjs --self-test`, exit 3: its positive control is pinned to commit `621a487607881c66b2899b7e3477115229a156b4`, which this shallow checkout cannot reach; `git fetch --deepen 500` did 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. - Three gates first answered exit 3 PREREQUISITE NOT MET on an unbuilt tree (`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 after `pnpm build`, and `check:type-check-debt` re-measures 4 ledger entries / 53 raw errors, none above its recorded number. - `pnpm lint` — the whole repo, `eslint . --no-inline-config`, exit 0 at `7682aebc`. No narrowing was claimed and none was needed. ## Acceptance notes Noted while measuring, ⛔ not filed by this dispatch and ⛔ not fixed here: - **A `{ $field }` reference as a `$between` endpoint 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_DESCRIPTION` says "A { $field } reference is NOT an endpoint shape", ruled 2026-08-11 under #7596. Reproduction on `2277d1fcd`: `RangeOperatorSchema.safeParse({ $between: [{ $field: 'a' }, 'M'] })` answers `success: false`, while `parseFilterAST({ 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/hono` fails its dts build under `turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*'`**, with `TS7016: Could not find a declaration file for module '@objectstack/plugin-hono-server'`, and builds clean under `pnpm build` on 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 for `check:type-check-debt` names 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](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9977c1 commit 32b5831

3 files changed

Lines changed: 240 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for callers** — `parseFilterAST` now refuses a blank `$between` endpoint, exactly as the authoring schema already does. An empty-string or absent (`undefined`) bound, at either side, is refused with `INVALID_FILTER` / 400, and the refusal names the blank side — MIN or MAX, plus the index (#19071).
6+
7+
Clause-②: no
8+
9+
## What changed, and why it is the implementation catching up rather than a new rule
10+
11+
`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated since 2026-09-17 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 authoring schema only. The runtime door disagreed with it: `parseFilterAST({ at: { $between: ['', ''] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after.
12+
13+
One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one that matters most here. A caller that lowers a filter with `parseFilterAST` and hands it straight to a driver (an embedder; this repo's own driver conformance suites) never meets the schema. At every backend a blank bound stops bounding on that side while the range still reads as a complete two-element range, so the query runs with one meaningless boundary and returns rows outside the window its filter names, with no signal at any layer.
14+
15+
```
16+
FROM parseFilterAST({ at: { $between: ['2026-01-01', ''] } })
17+
-> { at: { $between: ['2026-01-01', ''] } } // unchanged, same reference,
18+
// straight on to the driver
19+
20+
TO parseFilterAST({ at: { $between: ['2026-01-01', ''] } })
21+
-> throws INVALID_FILTER / 400:
22+
'Operator "$between" on field "at" requires two non-blank bounds.
23+
Received an empty string at where.at.$between[1] (the MAX bound). …'
24+
```
25+
26+
## Migration — FROM → TO
27+
28+
| You wrote | Write instead |
29+
| --- | --- |
30+
| `{ $between: ['2026-01-01', ''] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the bound you meant, written out |
31+
| `{ $between: ['', 100] }` | `{ $between: [0, 100] }` — the lower bound you meant |
32+
| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range |
33+
34+
**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** The same prescription the authoring door already gives, now given at the door an embedder actually reaches.
35+
36+
## What does NOT change
37+
38+
- **`null` bounds** keep their own message, refused since 2026-08-31. It prescribes the null PREDICATE, because an author who wrote `null` was reaching for absence and an author who left a bound empty was reaching for a bound — two blank spellings, two intents, two remedies. `null` is also checked first, so a pair that is blank on one side and null on the other keeps the message it has always had.
39+
- **Whitespace-only endpoints** are still accepted, at BOTH doors. The 2026-09-17 ruling is the empty string; the authoring door pins `{ $between: [' ', 'M'] }` as parsing green on purpose, and trimming here would re-open the very split this change closes, in the opposite direction.
40+
- **Falsiness.** `{ $between: [0, 0] }` and `{ $between: ['0', '9'] }` lower exactly as before. The rule is blankness, not falsiness.
41+
- **`$in` / `$nin` members.** A falsy or empty-string MEMBER is a value, not an absence (2026-08-31, `filter-comparand-shape.test.ts`). Only a range ENDPOINT is judged here, and only the `$between` row of that pin moves.
42+
- **Arity**, which was already refused with its own message, and every legal range: numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference.
43+
- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged.
44+
45+
<!-- adr-0087: not-required (already-registered filter-between-blank-endpoint-refused) the transition from a blank $between endpoint to two present, non-empty bounds was registered by #18012 for this same surface set; this change adds no new transition, it brings the runtime lowering face under the one already on the ledger, and the entry's own prescription and acceptance criteria apply verbatim -->

‎packages/spec/src/data/filter-comparand-shape.test.ts‎

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
import { describe, it, expect } from 'vitest';
2323
import { StandardErrorCode } from '../api/errors.zod';
24-
import { parseFilterAST, VALID_AST_OPERATORS } from './filter.zod';
24+
import { parseFilterAST, RangeOperatorSchema, VALID_AST_OPERATORS } from './filter.zod';
2525
import { assertListComparandShapes } from './filter-comparand-shape';
2626

2727
type Refusal = Error & { code?: string; status?: number };
@@ -151,13 +151,115 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
151151
.toContain('where.$or[0].stage.$nin[0]');
152152
});
153153

154-
it('refuses ONLY null — falsy and empty-ish members are values, not absence', () => {
154+
it('refuses ONLY null among the MEMBERS — falsy and empty-ish members are values', () => {
155155
// The carve-out is null-shaped and nothing wider: #5041's and #5234's
156156
// member questions stand untouched, and every falsy VALUE keeps working.
157157
expect(parseFilterAST({ n: { $in: [0, false, ''] } })).toEqual({ n: { $in: [0, false, ''] } });
158158
expect(parseFilterAST({ n: { $nin: [0, false, ''] } })).toEqual({ n: { $nin: [0, false, ''] } });
159-
expect(parseFilterAST({ at: { $between: ['', ''] } })).toEqual({ at: { $between: ['', ''] } });
159+
// A falsy ENDPOINT is still an endpoint — `0` is a bound like any other.
160160
expect(parseFilterAST({ n: { $between: [0, 0] } })).toEqual({ n: { $between: [0, 0] } });
161+
// ⚠️ `{ at: { $between: ['', ''] } }` was pinned HERE as a value that
162+
// passes. That row — and only that row — is INVERTED by the 2026-09-20
163+
// ruling (#19071); it now lives in the blank-endpoint section below. The
164+
// `$in` / `$nin` rows above are untouched, because falsy VALUES are values
165+
// and a range ENDPOINT is a different question.
166+
});
167+
168+
// ── the BLANK endpoint carve-out, ruled 2026-09-20 (#19071) ────────────
169+
170+
it.each([
171+
['both sides blank', { at: { $between: ['', ''] } }],
172+
['a blank MAX', { at: { $between: ['2026-07-01', ''] } }],
173+
['a blank MIN', { at: { $between: ['', '2026-07-31'] } }],
174+
['an absent MAX', { at: { $between: ['2026-07-01', undefined] } }],
175+
['an absent MIN', { at: { $between: [undefined, '2026-07-31'] } }],
176+
['the lowered array form', [['at', 'between', ['', '2026-07-31']]]],
177+
])('refuses a BLANK $between ENDPOINT — %s', (_label, where) => {
178+
const err = refusalOf(() => parseFilterAST(where));
179+
expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER);
180+
expect(err.status).toBe(400);
181+
});
182+
183+
it('the blank-bound refusal names the SIDE and prescribes both remedies', () => {
184+
// 2026-09-20: the refusal 「naming the blank side and carrying the same
185+
// guidance as the schema door」 — with a padded pair both bounds are
186+
// present, and the author is the one person who cannot see which is empty.
187+
const err = refusalOf(() => parseFilterAST({ close_date: { $between: ['2026-07-01', ''] } }));
188+
expect(err.message)
189+
.toMatch(/^Operator "\$between" on field "close_date" requires two non-blank bounds/);
190+
expect(err.message).toContain('an empty string at where.close_date.$between[1] (the MAX bound)');
191+
expect(err.message).toContain('{"$gte": min} / {"$lte": max}');
192+
expect(err.message).toMatch(/Authoring spellings: between\./);
193+
expect(err.message).toMatch(/UNFILTERED result set/);
194+
expect(refusalOf(() => parseFilterAST({ close_date: { $between: ['', '2026-07-31'] } })).message)
195+
.toContain('where.close_date.$between[0] (the MIN bound)');
196+
// An ABSENT bound says so in words: `undefined` inside an array renders as
197+
// `null` through JSON.stringify, and null is the one blank spelling this
198+
// message is NOT about.
199+
expect(refusalOf(() => parseFilterAST({ close_date: { $between: [5, undefined] } })).message)
200+
.toContain('Received undefined at where.close_date.$between[1] (the MAX bound)');
201+
});
202+
203+
it('a blank bound is refused at its own path inside $and / $or / $not too', () => {
204+
expect(refusalOf(() => parseFilterAST({ $not: { at: { $between: ['', 'M'] } } })).message)
205+
.toContain('where.$not.at.$between[0]');
206+
expect(refusalOf(() => parseFilterAST({ $or: [{ at: { $between: ['A', ''] } }] })).message)
207+
.toContain('where.$or[0].at.$between[1]');
208+
});
209+
210+
it('the null bound keeps the 2026-08-31 ruling\'s own message — two spellings, two remedies', () => {
211+
// An author who wrote `null` was reaching for absence; an author who left
212+
// a bound empty was reaching for a bound. If this went red the null author
213+
// would be sent to a scalar comparison instead of the null predicate.
214+
const err = refusalOf(() => parseFilterAST({ at: { $between: ['2026-07-01', null] } }));
215+
expect(err.message).toContain('requires two non-null bounds');
216+
expect(err.message).toContain('{"at": {"$null": true}}');
217+
expect(err.message).not.toContain('non-blank');
218+
// null is checked FIRST, so a pair that is blank on one side and null on
219+
// the other keeps the message it has had since 2026-08-31.
220+
expect(refusalOf(() => parseFilterAST({ at: { $between: ['', null] } })).message)
221+
.toContain('requires two non-null bounds');
222+
});
223+
224+
it('answers every endpoint spelling exactly as the SCHEMA door does', () => {
225+
// The defect this ruling closes was one published sentence
226+
// (`RANGE_ENDPOINT_DESCRIPTION`) with two truth values, so the pin is the
227+
// AGREEMENT itself rather than a second hand-written list that can drift
228+
// from the door it is supposed to match.
229+
const endpointPairs: Array<[string, unknown[]]> = [
230+
["['', '']", ['', '']],
231+
["['2026-01-01', '']", ['2026-01-01', '']],
232+
["['', '2026-12-31']", ['', '2026-12-31']],
233+
['[5, undefined]', [5, undefined]],
234+
['[undefined, 5]', [undefined, 5]],
235+
['[null, 1]', [null, 1]],
236+
['[1, null]', [1, null]],
237+
// Controls that must stay LEGAL at BOTH doors — a red here would mean a
238+
// door started reading falsiness, or shortness, instead of blankness.
239+
['[0, 0]', [0, 0]],
240+
["['0', '9']", ['0', '9']],
241+
["['A', 'M']", ['A', 'M']],
242+
["['08:00:00', '18:00:00']", ['08:00:00', '18:00:00']],
243+
["['2026-01-01', '2026-12-31']", ['2026-01-01', '2026-12-31']],
244+
// ⛔ Whitespace-only is NOT judged, at EITHER door: the 2026-09-17 ruling
245+
// is the empty string, `filter.test.ts` pins the schema side of this very
246+
// row, and a trim here would re-open the split in the other direction.
247+
["[' ', 'M']", [' ', 'M']],
248+
];
249+
const refused: string[] = [];
250+
for (const [label, pair] of endpointPairs) {
251+
const schemaRefuses = !RangeOperatorSchema.safeParse({ $between: pair }).success;
252+
let runtimeRefuses = false;
253+
try {
254+
parseFilterAST({ close_date: { $between: pair } });
255+
} catch {
256+
runtimeRefuses = true;
257+
}
258+
expect(runtimeRefuses, `${label}: schema refuses=${schemaRefuses}`).toBe(schemaRefuses);
259+
if (schemaRefuses) refused.push(label);
260+
}
261+
// Guards the loop from passing vacuously in either direction.
262+
expect(refused).toHaveLength(7);
161263
});
162264

163265
// ── the ordering carve-out, ruled 2026-09-01 (#14080) ──────────────────
@@ -266,6 +368,11 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
266368
{ stage: { $nin: [null] } },
267369
{ close_date: { $between: [null, null] } },
268370
{ close_date: { $between: ['2026-07-01', null] } },
371+
// The 2026-09-20 blank carve-out (#19071): the named side and both
372+
// prescriptions ride the same unrelaxed bound.
373+
{ close_date: { $between: ['2026-07-01', ''] } },
374+
{ close_date: { $between: ['', '2026-07-31'] } },
375+
{ close_date: { $between: [5, undefined] } },
269376
// The 2026-09-01 ordering carve-out (#14080): `$gte` / `$lte` carry the
270377
// longest spelling lists, so they are the tallest of the four.
271378
{ close_date: { $gte: null } },

‎packages/spec/src/data/filter-comparand-shape.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,40 @@
132132
* gate does not descend into one: a comparand is data, and a stricter reading
133133
* here would invent a contract no backend agrees with.
134134
*
135+
* ## Refused BY RULING, 2026-09-20: a BLANK `$between` ENDPOINT (#19071)
136+
*
137+
* The runtime twin of the schema door's 2026-09-17 rule (#18012). That ruling
138+
* wrote "BOTH are required NON-BLANK: an empty string, null and undefined are
139+
* refused, and the refusal names the blank side" into the PUBLISHED endpoint
140+
* contract (`RANGE_ENDPOINT_DESCRIPTION`, `./filter.zod.ts`) and enforced it at
141+
* the schema door alone. This door went on lowering `{ $between: ['', ''] }`
142+
* unchanged, so one published sentence had two truth values depending on which
143+
* door a caller came through — and the door that passed it is the one an
144+
* embedder reaches by handing a lowered filter straight to a driver, where the
145+
* range stops bounding on the blank side while still reading as a complete
146+
* range. Ruled 2026-09-20 (#19071, option A): the implementation follows the
147+
* declaration.
148+
*
149+
* The scope is the SCHEMA door's notion of blank, ⛔ not a second one invented
150+
* here — two doors disagreeing IS the defect:
151+
*
152+
* - **`''` and `undefined`** are refused, naming the blank side (MIN / MAX and
153+
* the index) and carrying the schema door's own prescription.
154+
* - **`null`** keeps {@link nullRangeBoundError}, which prescribes the null
155+
* PREDICATE: an author who wrote `null` was reaching for absence, an author
156+
* who left a bound empty was reaching for a bound. Two blank spellings, two
157+
* intents, two remedies; ⛔ do not unify them. It is checked FIRST, so a pair
158+
* that is `null` on one side and blank on the other keeps the 2026-08-31
159+
* message it has always had.
160+
* - **A whitespace-only endpoint is NOT judged**, because the schema door does
161+
* not judge one: `RangeOperatorSchema.safeParse({ $between: [' ', 'M'] })`
162+
* answers `success: true`, pinned deliberately in `filter.test.ts`. ⛔ A trim
163+
* here would re-open the very split this ruling closes, in the opposite
164+
* direction, and narrow a published face further than any ruling has.
165+
* - **Falsiness is untouched**: `[0, 0]` and `['0', '9']` are ranges, and a
166+
* falsy `$in` / `$nin` MEMBER stays a value — #13357's rows stand, because
167+
* this ruling is about range ENDPOINTS and those are about VALUES.
168+
*
135169
* ## Refusal envelope
136170
*
137171
* Every refusal carries `code: 'INVALID_FILTER'` and `status: 400` (ADR-0112
@@ -371,6 +405,45 @@ function nullRangeBoundError(
371405
);
372406
}
373407

408+
/**
409+
* A `$between` bound that is BLANK — the empty string or `undefined`, at
410+
* either end. Refused BY RULING, 2026-09-20 (#19071); see the module note's
411+
* third "Refused BY RULING" section.
412+
*
413+
* Its own message rather than an arm of {@link nullRangeBoundError}: that one
414+
* prescribes the null PREDICATE, which is the wrong remedy for a bound the
415+
* author simply did not type. This one is the schema door's
416+
* `blankRangeBoundMessage` (`./filter.zod.ts`) carrying the same two
417+
* prescriptions — write the bound you meant, or, if only one side was ever
418+
* bounded, drop `$between` for the scalar comparison that says so — in this
419+
* door's own #5346/#5348 wording contract: operator, field, position, the
420+
* named side, authoring spellings, front-loaded and inside the 500-char client
421+
* bound. Reconciled by pin, as the null pair is.
422+
*
423+
* The received value is described in WORDS rather than previewed: `undefined`
424+
* inside an array renders as `null` through `JSON.stringify`, which would show
425+
* an author the one spelling this message is not about.
426+
*/
427+
function blankRangeBoundError(
428+
context: string | undefined,
429+
field: string,
430+
bound: unknown,
431+
index: number,
432+
path: string,
433+
): Error {
434+
const spellings = LIST_COMPARAND_OPERATORS.get('$between') ?? [];
435+
return invalidFilterComparandError(
436+
context,
437+
`Operator "$between" on field "${field}" requires two non-blank bounds. Received ` +
438+
`${bound === undefined ? 'undefined' : 'an empty string'} at ${path}[${index}] ` +
439+
`(the ${index === 0 ? 'MIN' : 'MAX'} bound). A blank endpoint is compared as a value, so ` +
440+
`the range stops bounding on that side. Write the bound you meant; for a genuinely ` +
441+
`one-sided range use {"$gte": min} / {"$lte": max}. Authoring spellings: ` +
442+
`${spellings.join(', ')}. The filter was NOT applied, and an unapplied filter would have ` +
443+
`returned the UNFILTERED result set.`,
444+
);
445+
}
446+
374447
/**
375448
* A `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` — refused BY RULING,
376449
* 2026-09-01 (#14080); see the module note's second "Refused BY RULING"
@@ -494,6 +567,18 @@ function assertFieldListComparands(
494567
if (nullBound !== -1) {
495568
throw nullRangeBoundError(context, field, comparand, nullBound, `${path}.${op}`);
496569
}
570+
// Then the BLANK carve-out (2026-09-20 ruling, #19071) — exactly the two
571+
// spellings the schema door refuses as blank, so the two doors answer one
572+
// question the same way. Strict equality against `''` and `undefined`:
573+
// ⛔ no trim, because a whitespace-only endpoint PASSES the schema door
574+
// and narrowing further here would re-open the split in the other
575+
// direction; and `0` / `'0'` / `false` are endpoints, not blanks.
576+
const blankBound = comparand.findIndex((bound) => bound === '' || bound === undefined);
577+
if (blankBound !== -1) {
578+
throw blankRangeBoundError(
579+
context, field, comparand[blankBound], blankBound, `${path}.${op}`,
580+
);
581+
}
497582
continue;
498583
}
499584
if (!Array.isArray(comparand)) {

0 commit comments

Comments
 (0)