Skip to content

Commit a60c913

Browse files
os-steveclaude
andauthored
fix(spec): refuse a { $field } reference as a $between endpoint at the runtime filter door (#19501)
Fixes #19377 Clause-②: yes The runtime filter door now refuses a `{ $field }` column reference as a `$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 verbatim since 2026-08-11 (#7596, ADR-0049 enforce-or-remove) that "A { $field } reference is NOT an endpoint shape: no backend resolves one inside a list". That ruling removed `FieldReferenceSchema` from both endpoint unions and shipped at the schema door alone. Re-measured on this branch's base `4045781faf` before any edit, ⛔ not adopted from the card: | probe | before this PR | after this PR | |:--|:--|:--| | `RangeOperatorSchema.safeParse({ $between: [{ $field: 'a' }, 'M'] })` | `success: false` — refused, names `$between.0` | unchanged | | `parseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } })` | returned unchanged — **same object reference** | throws `INVALID_FILTER` / 400, names the MIN bound | | `parseFilterAST({ f: { $between: ['A', { $field: 'b' }] } })` | returned unchanged — same object reference | throws, names the MAX bound | | `parseFilterAST([['f', 'between', [{ $field: 'a' }, 'M']]])` (lowered array form) | returned unchanged | throws, same envelope | | control `parseFilterAST({ f: { $between: ['A', 'M'] } })` | accepted | accepted, same object reference | | control `parseFilterAST({ f: { $between: [null, 1] } })` | threw "requires two non-null bounds" | unchanged | 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 nothing resolves the reference: the in-memory matchers compare the raw reference OBJECT so the range silently matches nothing, and both SQL faces refuse the position. The caller got a filter that named a window and answered no rows, instead of a refusal they could act on. ## What a reviewer is being asked to accept **Accepted before, refused now** — and nothing else: a two-element `$between` comparand, of correct arity, with neither bound `null` and neither bound blank, where at least one bound is a non-array object carrying a `$field` key. That is the whole delta. **Deliberately left alone:** - **A `{ $field }` reference as the WHOLE comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`** — #5222's shipped column-to-column capability, and the alternative this refusal prescribes. Pinned by a lit control in the same file, for all four ordering operators plus the two-bound spelling `{ $gte: { $field: 'a' }, $lte: { $field: 'b' } }` the message names. - **Every legal range** — numbers, Dates, ISO days, UTC instants, clock times, non-temporal text, `[0, 0]`, `['0', '9']`. Same object reference out. - **The three older endpoint carve-outs.** The new check is LAST inside the `$between` arm, after arity, `null` (2026-08-31) and blank (2026-09-17). A pair that already carried a refusal keeps the message and the prescription it had — `[{ $field: 'a' }, null]` still answers with the null predicate, `[{ $field: 'a' }, '']` still answers with the blank prescription, `[{ $field: 'a' }]` still answers with the arity sentence. Pinned. - **A plain object that is not a reference** — `{ nope: 1 }` — keeps the comparand-TYPE door's own sentence, one step further on. Pinned, so the new refusal cannot be a blanket rejection of object endpoints. - **`$in` / `$nin` MEMBERS carrying a reference.** Same 2026-08-11 decision, published by `SET_MEMBER_DESCRIPTION`, and this door still lowers them unchanged — measured, see Acceptance notes. It is a second split over a different published sentence, needing its own wording and its own ruling; absorbing it silently here is the exact move this card's family exists to refuse. ⛔ It is not pinned in either direction: pinning a measured defect green would read as a ruling nobody made. ## The refusal's own wording Its own builder rather than an arm of the null or blank one, because those prescribe a VALUE and the author who wrote a reference was reaching for a column-to-column comparison — a capability the platform has, one operator over. The remedy is a different filter, not a different literal. ``` Operator "$between" on field "close_date" does not accept a { "$field": … } reference as an endpoint (at where.close_date.$between[0], the MIN bound). No evaluation path resolves one inside a list. Write a literal bound, or range column-to-column as two bounds: {"$gte": {"$field": "a"}, "$lte": {"$field": "b"}}. Authoring spellings: between. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set. ``` `INVALID_FILTER` / 400 (ADR-0112 class 1). The longest assembled form measures 456 characters against the unrelaxed 500-character client bound, with the engine's `find('deal')` context prefix included, and the bound test grew the new cases. ⛔ It does NOT offer the in-memory evaluator as an escape — `matchesFilter` does not resolve a list member either, it fails silently rather than loudly, so naming it would send an author to the one path whose answer is a wrong row set; the schema door's twin holds the same line, and a pin asserts the absence. The recogniser is SHAPE — a non-array object carrying a `$field` key — spelled as the schema door's `isFieldReferenceShape` spells it, ⛔ not the comparand-TYPE door's stricter `typeof value.$field === 'string'`. It is spelled again in this file rather than imported because `filter.zod.ts` imports this module and the reverse edge would be a cycle; a pin reads BOTH doors on one input set so they cannot drift. ## The migration conclusion, and the rule behind it **This narrowing owes its own ADR-0087 ledger entry, and it carries one** — `filter-between-field-reference-endpoint-refused`, registered in this PR. The rule applied: ADR-0087's disposition vocabulary is closed and every exemption is re-verified, so a declared-breaking changeset either registers or claims exactly one named category. Checked in turn against what is on disk: - `already-registered` — **unavailable, and this is the load-bearing finding.** It requires ids that pre-date the merge base and cover this change. The 2026-08-11 schema-door removal registered nothing: no `semantic/` entry, no retired key, no retired def, no `spec-changes.json` row, no line in the protocol upgrade guide names `$field` or #7596. This is the route PR #19374 took for the blank spelling one endpoint over, and it does not transfer, because the blank spelling's schema-door half had already registered `filter-between-blank-endpoint-refused` under #18012. Here the ledger is empty, so this entry is the FIRST record of the transition and covers **both** doors rather than only the second. - `no-migration-prescription` — refused by a body that carries a migration prescription, and AGENTS.md requires a breaking changeset that removes something an author can write to state FROM → TO. The changeset carries that table. - `unpublished` — refused: `@objectstack/spec` publishes to npm. - `runtime-interface-only` / `type-surface-only` — neither describes a published Zod-declared authoring face whose accept/reject behaviour moved. ⇒ `registered`. **No D2 conversion, and that is a fact about the shape rather than a decision to decline one.** The literal a reference stood for is not recoverable from the reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar bounds), so producing it would be the platform rewriting one filter into another; dropping the operator would delete a constraint the author wrote and WIDEN the result set silently. The load path was **measured, not assumed**: `applyConversionsToStoredItem` — the one primitive every stored-row rehydration seam calls — returned the **same object reference** for a stored view carrying `{ close_date: { $between: [{ $field: 'contract.start' }, '2026-12-31'] } }`, so it neither drops the operator nor refuses the row. Stored views stay readable; what changes is that RE-SAVING one is refused, at the endpoint's own path, with the side named. Same ground as the two nearest narrowings of this surface set, `filter-between-blank-endpoint-refused` and `filter-preset-ordering-comparand-refused`. The changeset carries the disposition marker in the ADR-0087 comment form the gate reads, naming that entry id; `node scripts/check-adr-0087-registration.mjs` reports **1 declared-breaking changeset, carrying `registered filter-between-field-reference-endpoint-refused` (new here)**. ## File surface — one declared expansion The claim named `packages/spec/src/data/` as the file surface. The code and its pins are inside it. Three files fall outside and are reported rather than taken silently: the changeset (`.changeset/`, named in the deliverable), the ledger entry (`packages/spec/src/migrations/entries/semantic/`) and the generated `packages/spec/src/migrations/registry.ts` it concatenates into. The ADR-0087 gate reds a declared-breaking changeset with no valid disposition, and — per the section above — no `not-required` category was available, so the entry is what makes the declared narrowing landable at all. `gen:spec-changes` and `gen:upgrade-guide` were re-run and produced **no diff**; neither projection carries entries of this major, which matches the sibling precedent. ## Verification Every reading below was taken in this worktree. - `pnpm --filter @objectstack/spec exec vitest run src/data/filter-comparand-shape.test.ts` — 1 file / 65 tests passed. - `pnpm --filter @objectstack/spec build` — green, before and after the change. - Both doors re-read through the built `dist/data/index.mjs` after the change: the three reference rows now REFUSE at both doors, the two literal-range controls still ACCEPT at both, and the null / blank / non-reference-object rows are unchanged. - `pnpm --filter @objectstack/spec check:migration-registry` / `check:spec-changes` / `check:upgrade-guide` — all exit 0. - `node scripts/check-adr-0087-registration.mjs` — exit 0. - Full per-package and derived-gate readings are in the report on the card. ## Acceptance notes Noted while measuring, ⛔ not filed by this dispatch and ⛔ not fixed here: - **A `{ $field }` reference as an `$in` / `$nin` MEMBER is refused by the schema door and accepted by the runtime door.** The same 2026-08-11 ruling one position over, with its own published sentence: `SET_MEMBER_DESCRIPTION` states "A { $field } reference is NOT a member shape: no backend resolves one inside a list", and `listPositionFieldReferenceMessage`'s own doc names "every `$in` / `$nin` member, and both `$between` endpoints" as one ruling. Measured on this branch at `4045781faf` and again after this change: `FieldOperatorsSchema.safeParse({ $in: [{ $field: 'a' }, 'x'] })` answers `success: false`, while `parseFilterAST({ f: { $in: [{ $field: 'a' }, 'x'] } })` returns the filter unchanged, same object reference; `$nin` behaves identically. Left alone because the refusal needs its own authored wording and, being a narrowing of a second published face, its own ruling — the same reason this card exists rather than being absorbed into #19374. Dedupe words: `field reference list member` · `SET_MEMBER_DESCRIPTION` · `parseFilterAST runtime door` · `7596 member shape` · `two doors disagree`. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e671d2 commit a60c913

5 files changed

Lines changed: 504 additions & 1 deletion

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for callers** — `parseFilterAST` now refuses a `{ $field }` column reference as a `$between` endpoint, exactly as the authoring schema has since 2026-08-11. A reference at either bound is refused with `INVALID_FILTER` / 400, and the refusal names the side — MIN or MAX, plus the index (#19377).
6+
7+
Clause-②: yes
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 verbatim since 2026-08-11 that "A { $field } reference is NOT an endpoint shape: no backend resolves one inside a list". The ruling that wrote it (ADR-0049 enforce-or-remove) removed `FieldReferenceSchema` from both endpoint unions, and it shipped at the authoring schema alone. The runtime door disagreed with it: `parseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } })` 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 an embedder reaches by handing a lowered filter straight to a driver. There, nothing resolves the reference: the in-memory matchers compare the raw reference OBJECT and the range silently matches nothing, while both SQL faces refuse the position. A filter that names a window and answers no rows, or 400s one layer down, is what a caller got instead of a refusal they could act on.
14+
15+
```
16+
FROM parseFilterAST({ close_date: { $between: [{ $field: 'contract.start' }, '2026-12-31'] } })
17+
-> the same object, unchanged, straight on to the driver
18+
19+
TO throws INVALID_FILTER / 400:
20+
'Operator "$between" on field "close_date" does not accept a { "$field": … }
21+
reference as an endpoint (at where.close_date.$between[0], the MIN bound). …'
22+
```
23+
24+
## Migration — FROM → TO
25+
26+
| You wrote | Write instead |
27+
| --- | --- |
28+
| `{ $between: [{ $field: 'contract.start' }, '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the literal bound the range was meant to stop at |
29+
| a range that was genuinely meant to be column-to-column | `{ "$gte": { "$field": "a" }, "$lte": { "$field": "b" } }` — two scalar bounds, the position that compiles on every face |
30+
31+
**The one-line fix: write the literal bound, or — if the range really was column-to-column — drop `$between` and write the two bounds separately as `$gte` / `$lte`.** Nothing was evaluating the old filter, so treat the replacement as a new one and test it: at every backend the reference range either matched nothing or was refused.
32+
33+
## What does NOT change
34+
35+
- **A `{ $field }` reference as the WHOLE comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`.** That is #5222's shipped column-to-column capability, it is the alternative this refusal prescribes, and it lowers exactly as before — pinned by a lit control in the same file.
36+
- **Every legal range.** Numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference.
37+
- **The three older endpoint carve-outs.** Arity, `null` (2026-08-31) and blank (2026-09-17) are checked first, so a pair carrying one of those keeps the message and the prescription it already had — an author who wrote `null` is still sent to the null predicate, not to a scalar comparison.
38+
- **A plain object that is not a reference** keeps the comparand-TYPE door's own sentence, one step further on.
39+
- **`$in` / `$nin` members.** The same 2026-08-11 decision rules a reference out of those positions too and `SET_MEMBER_DESCRIPTION` publishes it, but that is a second split over a different published sentence; it is measured and filed separately, and this change deliberately does not move it.
40+
- **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.
41+
42+
<!-- adr-0087: registered filter-between-field-reference-endpoint-refused -->

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

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,18 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
245245
// is the empty string, `filter.test.ts` pins the schema side of this very
246246
// row, and a trim here would re-open the split in the other direction.
247247
["[' ', 'M']", [' ', 'M']],
248+
// The 2026-08-11 `{ $field }` carve-out (#7596), reaching this door
249+
// under #19377 — the same two-door question, one endpoint spelling over.
250+
["[{ $field }, 'M']", [{ $field: 'a' }, 'M']],
251+
["['A', { $field }]", ['A', { $field: 'b' }]],
252+
['[{ $field }, { $field }]', [{ $field: 'a' }, { $field: 'b' }]],
253+
// A non-string referent is still the SHAPE the author wrote, at both
254+
// doors: the schema door reads `'$field' in value` and so does this one.
255+
['[{ $field: 42 }, M]', [{ $field: 42 }, 'M']],
256+
// A plain object that is NOT a reference was already refused at both
257+
// doors, each with its own wording — the row is here so the loop cannot
258+
// be read as "every object endpoint is the #7596 refusal now".
259+
["[{ nope: 1 }, 'M']", [{ nope: 1 }, 'M']],
248260
];
249261
const refused: string[] = [];
250262
for (const [label, pair] of endpointPairs) {
@@ -259,9 +271,134 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
259271
if (schemaRefuses) refused.push(label);
260272
}
261273
// Guards the loop from passing vacuously in either direction.
262-
expect(refused).toHaveLength(7);
274+
expect(refused).toHaveLength(12);
263275
});
264276

277+
// ── the `{ $field }` endpoint carve-out, ruled 2026-08-11 (#7596) ──────
278+
//
279+
// The ruling is the oldest of the four and the last to reach this door: it
280+
// removed `FieldReferenceSchema` from both endpoint unions and published the
281+
// sentence "A { $field } reference is NOT an endpoint shape", while this door
282+
// went on lowering such a range unchanged (#19377).
283+
284+
it.each([
285+
['a reference as the MIN bound', { at: { $between: [{ $field: 'a' }, 'M'] } }],
286+
['a reference as the MAX bound', { at: { $between: ['A', { $field: 'b' }] } }],
287+
['both bounds references', { at: { $between: [{ $field: 'a' }, { $field: 'b' }] } }],
288+
['a reference with a non-string referent', { at: { $between: [{ $field: 42 }, 'M'] } }],
289+
['the lowered array form', [['at', 'between', [{ $field: 'a' }, 'M']]]],
290+
])('refuses a { $field } $between ENDPOINT — %s', (_label, where) => {
291+
const err = refusalOf(() => parseFilterAST(where));
292+
expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER);
293+
expect(err.status).toBe(400);
294+
});
295+
296+
it('the reference refusal names the SIDE and prescribes the two-bound spelling', () => {
297+
// The author who wrote a reference was reaching for a column-to-column
298+
// comparison, which the platform HAS one operator over — so the remedy is
299+
// a different filter, not a different literal, and the message says so.
300+
const err = refusalOf(() => parseFilterAST({ close_date: { $between: [{ $field: 'a' }, 'M'] } }));
301+
expect(err.message).toMatch(
302+
/^Operator "\$between" on field "close_date" does not accept a \{ "\$field": … \} reference/,
303+
);
304+
expect(err.message).toContain('at where.close_date.$between[0], the MIN bound');
305+
expect(err.message).toContain('{"$gte": {"$field": "a"}, "$lte": {"$field": "b"}}');
306+
expect(err.message).toMatch(/Authoring spellings: between\./);
307+
expect(err.message).toMatch(/UNFILTERED result set/);
308+
expect(refusalOf(() => parseFilterAST({ close_date: { $between: ['A', { $field: 'b' }] } })).message)
309+
.toContain('at where.close_date.$between[1], the MAX bound');
310+
// ⛔ The in-memory evaluator is NOT offered as an escape: it does not
311+
// resolve a list member either, it fails silently instead of loudly, so
312+
// naming it would send an author to the one path that answers a wrong row
313+
// set rather than an error. The schema door's twin holds the same line.
314+
expect(err.message).not.toContain('matchesFilter');
315+
});
316+
317+
it('a reference bound is refused at its own path inside $and / $or / $not too', () => {
318+
expect(refusalOf(() => parseFilterAST({ $not: { at: { $between: [{ $field: 'a' }, 'M'] } } })).message)
319+
.toContain('where.$not.at.$between[0]');
320+
expect(refusalOf(() => parseFilterAST({ $or: [{ at: { $between: ['A', { $field: 'b' }] } }] })).message)
321+
.toContain('where.$or[0].at.$between[1]');
322+
});
323+
324+
it('the three older endpoint carve-outs keep their own messages', () => {
325+
// This check is LAST inside the arm, so every pair that already carried a
326+
// refusal keeps the one it had. A red here means a pair was re-routed and
327+
// an author who wrote `null` is now being sent to a scalar comparison.
328+
expect(refusalOf(() => parseFilterAST({ at: { $between: [{ $field: 'a' }, null] } })).message)
329+
.toContain('requires two non-null bounds');
330+
expect(refusalOf(() => parseFilterAST({ at: { $between: [{ $field: 'a' }, ''] } })).message)
331+
.toContain('requires two non-blank bounds');
332+
expect(refusalOf(() => parseFilterAST({ at: { $between: [{ $field: 'a' }] } })).message)
333+
.toContain('requires a [min, max] value array');
334+
// And a plain object that is not a reference keeps the comparand-TYPE
335+
// door's own sentence, one step further on.
336+
expect(refusalOf(() => parseFilterAST({ at: { $between: [{ nope: 1 }, 'M'] } })).message)
337+
.toContain('plain object');
338+
});
339+
340+
it('LIT CONTROL — legal ranges and the reference\'s own ORDERING slots still pass', () => {
341+
// Without these the refusal could be a blanket rejection of `$between`, or
342+
// of the reference itself, and every assertion above would still be green.
343+
expect(parseFilterAST({ at: { $between: ['A', 'M'] } }))
344+
.toEqual({ at: { $between: ['A', 'M'] } });
345+
expect(parseFilterAST({ n: { $between: [0, 100] } })).toEqual({ n: { $between: [0, 100] } });
346+
expect(parseFilterAST({ at: { $between: ['2026-01-01', '2026-12-31'] } }))
347+
.toEqual({ at: { $between: ['2026-01-01', '2026-12-31'] } });
348+
// #5222's shipped capability: the reference IS a whole comparand of the
349+
// four ordering operators, which is what this refusal prescribes. Refusing
350+
// it there would delete the escape the message names.
351+
for (const op of ['$gt', '$gte', '$lt', '$lte']) {
352+
expect(parseFilterAST({ a: { [op]: { $field: 'b' } } }))
353+
.toEqual({ a: { [op]: { $field: 'b' } } });
354+
}
355+
// The two-bound spelling the message prescribes must itself lower.
356+
expect(parseFilterAST({ a: { $gte: { $field: 'b' }, $lte: { $field: 'c' } } }))
357+
.toEqual({ a: { $gte: { $field: 'b' }, $lte: { $field: 'c' } } });
358+
});
359+
360+
it('re-routes the pairs the comparand-TYPE door used to answer — convergence, pinned', () => {
361+
// ⚠️ This is the one part of the delta that is NOT "was accepted, is now
362+
// refused". A pair whose OTHER element the comparand-TYPE door would have
363+
// refused now meets this door first, so it answers with the reference
364+
// sentence instead of the type one: same code, same status, and the schema
365+
// door already answered every one of these with the reference message, so
366+
// the two doors CONVERGE rather than diverge. Pinned rather than left to
367+
// the reader, because "nothing else changed" is not true of these rows.
368+
const mixed = refusalOf(() => parseFilterAST({ at: { $between: [{ $field: 'a' }, { nope: 1 }] } }));
369+
expect(mixed.code).toBe(StandardErrorCode.enum.INVALID_FILTER);
370+
expect(mixed.status).toBe(400);
371+
expect(mixed.message).toContain('does not accept a { "$field": … } reference');
372+
// …and it names the REFERENCE's index, not the other element's.
373+
expect(mixed.message).toContain('where.at.$between[0]');
374+
// A non-string referent, and a reference reached through the PROTOTYPE, are
375+
// the shape the author wrote — at both doors. The TYPE door would have
376+
// called each of them a plain object and prescribed a literal, which is the
377+
// wrong remedy for someone who was reaching for a column.
378+
for (const bound of [{ $field: 42 }, Object.create({ $field: 'a' }) as object]) {
379+
expect(refusalOf(() => parseFilterAST({ at: { $between: [bound, 'M'] } })).message)
380+
.toContain('does not accept a { "$field": … } reference');
381+
}
382+
// A pair carrying NO reference is untouched: it still reaches the TYPE door
383+
// and still answers with the TYPE door's own sentence.
384+
expect(refusalOf(() => parseFilterAST({ at: { $between: [{ nope: 1 }, 'M'] } })).message)
385+
.toContain('plain object');
386+
});
387+
388+
it.todo(
389+
'the $in / $nin MEMBER positions of the same 2026-08-11 ruling still DISAGREE across the two '
390+
+ 'doors — FieldOperatorsSchema refuses a { $field } member, parseFilterAST lowers it '
391+
+ 'unchanged (measured on this branch). Filed separately; ⛔ not pinned green here, because a '
392+
+ 'green pin would read as a ruling nobody made',
393+
);
394+
395+
// ⚠️ `$in` / `$nin` MEMBERS carrying a `{ $field }` reference are the SAME
396+
// #7596 ruling one position over, published by `SET_MEMBER_DESCRIPTION`, and
397+
// this door still lowers them unchanged — measured under #19377 and filed
398+
// separately. ⛔ Deliberately NOT pinned here in either direction: pinning a
399+
// measured defect green reads as a ruling nobody made, and refusing it would
400+
// be a narrowing of a published face this card was never given.
401+
265402
// ── the ordering carve-out, ruled 2026-09-01 (#14080) ──────────────────
266403

267404
it.each([
@@ -373,6 +510,10 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
373510
{ close_date: { $between: ['2026-07-01', ''] } },
374511
{ close_date: { $between: ['', '2026-07-31'] } },
375512
{ close_date: { $between: [5, undefined] } },
513+
// The 2026-08-11 reference carve-out (#7596): the two-bound prescription
514+
// is the longest thing this arm assembles, inside the same bound.
515+
{ close_date: { $between: [{ $field: 'a' }, 'M'] } },
516+
{ close_date: { $between: ['A', { $field: 'b' }] } },
376517
// The 2026-09-01 ordering carve-out (#14080): `$gte` / `$lte` carry the
377518
// longest spelling lists, so they are the tallest of the four.
378519
{ close_date: { $gte: null } },

0 commit comments

Comments
 (0)