Repository navigation
Commit a60c913
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
- .changeset
- packages/spec/src
- data
- migrations
- entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
248 | 260 | | |
249 | 261 | | |
250 | 262 | | |
| |||
259 | 271 | | |
260 | 272 | | |
261 | 273 | | |
262 | | - | |
| 274 | + | |
263 | 275 | | |
264 | 276 | | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
265 | 402 | | |
266 | 403 | | |
267 | 404 | | |
| |||
373 | 510 | | |
374 | 511 | | |
375 | 512 | | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
376 | 517 | | |
377 | 518 | | |
378 | 519 | | |
| |||
0 commit comments