Skip to content

Commit 68c5ab7

Browse files
fix(spec): the null ordering-comparand refusal names only evaluation faces that exist (#21408)
Fixes #21397 Clause-②: no ## What changed Two texts named driver-memory's reference matcher (`memory-matcher.ts`), which has been deleted. Both now name only faces that exist. Both null ordering-comparand refusals also said "no two evaluation faces agree", which measures false read pairwise (driver-sql and formula agree), so both now say only what was measured. This is a text-only change: no door accepts or refuses anything new. 1. **`packages/spec/src/data/filter.zod.ts`, `nullOrderingComparandMessage`.** This is the schema-door refusal for a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte`. The first sentence (`null is not a valid $gt comparand.` and its siblings), the `{"$eq": null}` / `{"$ne": null}` prescription and the ruling sentence (`Ruled 2026-09-01: …`) are byte-identical. The second sentence changes in two places: - "no two evaluation faces agree" becomes "the evaluation faces do not agree". - The parenthesis changes. Before: `(driver-memory's live path reads two absences as equal; its reference matcher compares through JS coercion)`. After: `(driver-memory's query path reads a stored null as equal to it, so {"$gte": null} admits that row; driver-sql compares against SQL NULL and admits no row)`. - The longest message is 484 characters (`$gte`). 2. **`packages/spec/src/data/filter-comparand-shape.ts`, `nullOrderingComparandError`.** This is the runtime twin, the `parseFilterAST` refusal. Only its string changes, at `:708`: "null is not ordered; no two evaluation faces agree on what it matches." becomes "null is not ordered; the evaluation faces do not agree on what it matches." Every other sentence is unchanged. - It never carried the parenthesis. - It cannot import the schema-door message, because `filter.zod.ts:5` imports `./filter-comparand-shape`. So the two messages stay parallel rather than shared. - The longest refusal is 479 characters (`$gte`, context `find('deal')`), inside the 500-char client bound that `filter-comparand-shape.test.ts` pins. 3. **`packages/spec/src/data/filter-operator-vocabulary.test.ts`.** The assertion message's EVERY-face list: - drops the deleted face: `driver-memory (query path, reference matcher, analytics face)` becomes `driver-memory (query path, analytics face)`; - adds `driver-sql` and `driver-turso (remote transport)`. Clearing `$empty` (commit `f1e921ab8e`) edited both, in `sql-driver.ts` and `remote-transport.ts`, and `remote-transport.ts` compiles its own WHERE (`buildWhereSQL`). 4. **`.changeset/21397-null-ordering-message-faces.md`.** A `@objectstack/spec` `patch`, because both messages ship. No comment line in `filter.zod.ts` or `filter-comparand-shape.ts` is touched. #21395 landed meanwhile and holds those lines, and `origin/main` is merged in (`703bb28168`); against `main`, the branch's diff is exactly the four files above. ## Measurement: what each existing face answers The question is what an ordering against `null` matches on each evaluation face that exists today. The refusal is skipped by calling each face directly, without `parseFilterAST`. The run used built `dist/` at `ecb6ca0258` plus this diff, with the fixture from `memory-null-ordering-comparand-unreachable.test.ts`. In the **stored-null** reading the rows are `{id:1,n:5}`, `{id:2,n:0}` and `{id:3,n:null}`: | filter | driver-memory `InMemoryDriver.find` (query path) | driver-sql `SqlDriver.find`, better-sqlite3 | formula `matchesFilterCondition` | |---|---|---|---| | `{n: {$gt: null}}` | no row | no row | no row | | `{n: {$gte: null}}` | **row 3** | no row | no row | | `{n: {$lt: null}}` | no row | no row | no row | | `{n: {$lte: null}}` | **row 3** | no row | no row | - **The missing-key reading:** the rows are `{id:1,n:5}`, `{id:2,n:0}` and `{id:4}`. All three faces admit no row for all four operators. - **Positive control:** `{n: {$gte: 0}}` answers rows 1 and 2 on all three faces. - **The disagreeing pair the parenthesis names:** driver-memory's query path (`memory-driver.ts`, `convertToMongoQuery`, then `normalizeFilterCondition`, then mingo) and driver-sql. - driver-sql's `$gte` arm calls knex `where(field, '>=', null)`, which compiles to `col >= ?` with a NULL binding. That is UNKNOWN in SQL. - Raw mingo `new Query({n: {$gte: null}})` gives the same answer as driver-memory's `find`. - **Not measured:** - driver-mongodb: there is no server in this container. - driver-turso and the Postgres / MySQL dialects: these were read, not run. They compile the same `col OP ?` with a NULL binding. - driver-memory's analytics face, objectql `having` and service-analytics: these refuse the shape themselves (`assertListComparandShapes`), so they give no answer to compare. - **The published door:** over the built `dist/data/index.js`, `FieldOperatorsSchema.safeParse({ $gt: null })` answers `success: false` with the new message. The control `{ $gt: 5 }` answers `success: true`. ## Pins that read each message None of these pins reads either changed phrase. All are green and unloosened at `703bb28168`. - **Schema-door message (`nullOrderingComparandMessage`):** - `filter.test.ts:176`–`:179`: on `ComparisonOperatorSchema`, the first sentence, both prescription halves, and the absence of `Invalid input`. - `filter.test.ts:187`: the first sentence, on `FieldOperatorsSchema`. - `filter.test.ts:217`: the negative control. - `filter-save-door-face-parity.test.ts:347`: the save-door message equals `FieldOperatorsSchema`'s message for the same comparand. Both come from the one function. - `filter-save-door-face-parity.test.ts:351`–`:352`: the prescription. - **Runtime twin (`nullOrderingComparandError`):** - `filter-comparand-shape.test.ts:436`–`:446`: the first sentence, the path, both prescription halves, the authoring spellings and the UNFILTERED tail. - The 500-char bound in the same file. - service-analytics `filter-value-type-fidelity.test.ts:495` and `where-face-arms-refusal.test.ts:88`: the first sentence. - **The vocabulary assertion message:** no pin reads it. It prints only when its assertion fails. - **Elsewhere:** `git grep` over the whole tree (excluding `CHANGELOG.md` and the releases pages) finds no other test that reads either phrase. ## Tests and gates All of these ran at head `703bb28168`, the merge of `origin/main` `ceb4a939b4`. Every heavy run went through `os-verify-lock`. - **Build:** the `@objectstack/service-analytics^...` closure plus the `@objectstack/lint...` closure, spec included: exit 0. - **`pnpm --filter @objectstack/spec check:generated`:** all 15 generated artifacts are up to date. - **The pinning files** (`filter.test.ts`, `filter-operator-vocabulary.test.ts`, `filter-comparand-shape.test.ts`, `filter-save-door-face-parity.test.ts`): 4 files, 427 passed and 1 todo. - **service-analytics** `filter-value-type-fidelity.test.ts` and `where-face-arms-refusal.test.ts`: 2 files, 191 passed. - **`pnpm --filter @objectstack/spec typecheck`:** exit 0. `check:test-typecheck` reports OK, with 52 files, 246 errors and 135 pinned signatures held. - **The full `@objectstack/spec` suite:** 600 files passed, with 17606 tests passed and 1 todo. - **`dispatch-gates --commands`** derived 85 commands. Results: - 84 exited 0. - `check:type-check-debt` hit a 420 s local timeout, then exited 0 under the lock: "26 raw tsc error(s) total, none above its recorded number". - `dispatch-gates --ran` reports "85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED". - Named verdicts: - `check:doc-authoring`: 17232 customer-facing strings clean. - `check:nul-bytes`: OK, 9746 files. - `check-empty-changeset`: 1 declaring changeset. - `check-changeset-no-major`: no `major` bump. - `check-adr-0087-registration`: no declared-breaking changeset. - `check-issue-citations`: no issue citations added. - `check:where-matcher`: 0 silently-wrong. - `check:dual-build-cjs-loads`: 105 entry points across 66 packages load. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bdd3654 commit 68c5ab7

4 files changed

Lines changed: 33 additions & 6 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the null ordering-comparand refusals name only evaluation faces that exist, and say only what was measured
6+
7+
Clause-②: no
8+
9+
`FieldOperatorsSchema` and `ComparisonOperatorSchema` refuse a `null` comparand of `$gt` / `$gte` /
10+
`$lt` / `$lte` with a pointed message. Its example of the evaluation faces disagreeing named
11+
driver-memory's reference matcher, which has been deleted, so an author or agent reading the
12+
refusal went looking for a face that no longer exists. The example now names two faces that exist
13+
and were measured to disagree: driver-memory's query path reads a stored `null` as equal to the
14+
comparand, so `{"$gte": null}` admits that row, while driver-sql compares against SQL `NULL` and
15+
admits no row.
16+
17+
That refusal and its runtime twin, the `parseFilterAST` refusal for the same comparand
18+
(`Operator "$gt" on field "…" does not accept a null comparand …`), both said "no two evaluation
19+
faces agree" on what an ordering against `null` matches. Measured, two faces do agree (driver-sql
20+
and formula both admit no row), so both now say "the evaluation faces do not agree".
21+
22+
Text only: each message's first sentence, its prescription (`{"$eq": null}` / `{"$ne": null}`), the
23+
schema door's ruling sentence and the runtime door's "NOT applied" sentence are unchanged, and both
24+
doors accept and refuse exactly the same filters. A client or log filter that matches the old
25+
wording needs the new spelling.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ function nullOrderingComparandError(
705705
return invalidFilterComparandError(
706706
context,
707707
`Operator "${op}" on field "${field}" does not accept a null comparand (at ${path}). ` +
708-
`null is not ordered; no two evaluation faces agree on what it matches. State absence ` +
708+
`null is not ordered; the evaluation faces do not agree on what it matches. State absence ` +
709709
`with the null predicate: {"$eq": null} is "has no value", {"$ne": null} is "has a value". ` +
710710
`Authoring spellings: ${spellings.join(', ')}. The filter was NOT applied, and an ` +
711711
`unapplied filter would have returned the UNFILTERED result set.`,

‎packages/spec/src/data/filter-operator-vocabulary.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ describe('the declaration surface and the enforcement surface', () => {
8383
+ 'so declaring ahead of the arms is the correct staging. To stage one: declare it in '
8484
+ 'FieldOperatorsSchema, add it to the array THIS assertion compares against, and note on '
8585
+ 'FILTER_OPERATORS which issue implements it. To clear one: implement it on EVERY face '
86-
+ 'in ONE PR — spec word list, driver-memory (query path, reference matcher, analytics '
87-
+ 'face), driver-mongodb, service-analytics (3 compilers), objectql `having`, formula — '
86+
+ 'in ONE PR — spec word list, driver-memory (query path, analytics face), driver-sql, '
87+
+ 'driver-turso (remote transport), driver-mongodb, service-analytics (3 compilers), '
88+
+ 'objectql `having`, formula — '
8889
+ 'then empty this list. #6520 is the worked example of the clearing direction.',
8990
).toEqual(STAGED_AHEAD_OF_BACKENDS);
9091
});

‎packages/spec/src/data/filter.zod.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,10 @@ const ORDERING_COMPARAND_DESCRIPTION =
467467
*/
468468
function nullOrderingComparandMessage(op: string): string {
469469
return (
470-
`null is not a valid ${op} comparand. null is not ordered, and no two evaluation faces `
471-
+ 'agree on what an ordering against it matches (driver-memory\'s live path reads two '
472-
+ 'absences as equal; its reference matcher compares through JS coercion). State absence '
470+
`null is not a valid ${op} comparand. null is not ordered, and the evaluation faces do not `
471+
+ 'agree on what an ordering against it matches (driver-memory\'s query path reads a '
472+
+ 'stored null as equal to it, so {"$gte": null} admits that row; driver-sql compares '
473+
+ 'against SQL NULL and admits no row). State absence '
473474
+ 'with the null predicate instead: {"$eq": null} is "has no value", {"$ne": null} is '
474475
+ '"has a value". Ruled 2026-09-01: a null ordering comparand is refused at the validation '
475476
+ 'entrance.'

0 commit comments

Comments
 (0)