Skip to content

fix(spec): the null ordering-comparand refusal names only evaluation faces that exist - #21408

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21397-null-ordering-message-faces
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21397-null-ordering-message-faces

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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

…ertion name only faces that exist

The schema-door refusal for a null $gt/$gte/$lt/$lte comparand gave, as its
example of the evaluation faces disagreeing, driver-memory's reference matcher,
which has been deleted. The parenthesis now names two faces that exist and were
measured to disagree: driver-memory's query path admits a stored-null row for
{"$gte": null}; driver-sql compares against SQL NULL and admits no row. The
first sentence, the prescription and the ruling sentence are unchanged.

The filter-operator vocabulary assertion message no longer lists the deleted
reference matcher among driver-memory's faces.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ceb4a939b42f7c91ac8e9d371df34b8663d3f7bc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d7e7faa8ea0152fd8da5a2d4e7b960a3d09b1c11 — the merge of head 703bb2816894a4a8809f1c02b8f43360246b1118 into base ceb4a939b42f7c91ac8e9d371df34b8663d3f7bc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d7e7faa8ea0152fd8da5a2d4e7b960a3d09b1c11 && git checkout d7e7faa8ea0152fd8da5a2d4e7b960a3d09b1c11
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ceb4a939b42f7c91ac8e9d371df34b8663d3f7bc 703bb2816894a4a8809f1c02b8f43360246b1118 && git checkout -B drift-repro ceb4a939b42f7c91ac8e9d371df34b8663d3f7bc && git merge --no-ff 703bb2816894a4a8809f1c02b8f43360246b1118

node scripts/docs-audit/affected-docs.mjs --json ceb4a939b42f7c91ac8e9d371df34b8663d3f7bc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…aces do not agree

Measured, driver-sql and formula agree on an ordering against null (both
admit no row), so "no two evaluation faces agree" was false read pairwise.
The schema-door message (nullOrderingComparandMessage) and its runtime twin
(nullOrderingComparandError) now both say "the evaluation faces do not
agree"; every other sentence of each is unchanged.

The vocabulary assertion's every-face list now also names driver-sql and
driver-turso's remote transport, which clearing $empty edited.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 703bb2816894a4a8809f1c02b8f43360246b1118
Local-runs: none

Inputs read: card #21397 (body and all five comments: triage 5950939866, claim 5951484481, dev report 5952679308, the seat's answer and claim amendment 5952717122, dev report 5955118186), PR #21408 (body as rewritten by the seat, file list, net diff against main at ceb4a939b4), and the check-runs on the head. Source at the head and at f1e921ab8e was read with git show / git grep only; nothing was built, run or re-run.

① Derived judgments

Accept set: nothing moves, at any door — RIGHT. The schema-door slot (orderingComparandSchema, filter.zod.ts:487) is still z.union([number, date, string, FieldReference]); null never passed it and still does not, so the diff changes the error text and nothing else. The save door prints the same function's output (filter-save-door-face-parity.test.ts:347 pins equality). The runtime door (parseFilterAST → nullOrderingComparandError, filter-comparand-shape.ts:708) still refuses with INVALID_FILTER; only one phrase of its string changes (+4 chars; the 500-char client bound is pinned in filter-comparand-shape.test.ts and Test Core is green on this head).

Public surface: two published runtime strings, text only — RIGHT. No export is added, removed or renamed (check:api-surface unaffected); no .describe() changes (check:docs unaffected; the TypeScript Type Check job that runs every spec generated-artifact gate is green on this head). The third edited file is a test assertion message that prints only when its assertion fails; no public door reaches it.

The card's filing gate was class (a), a false present-tense fact, so the new text has to be true. Each clause, judged against the code at the head:

  • "driver-memory's query path reads a stored null as equal to it, so {"$gte": null} admits that row" — RIGHT. memory-driver.ts:1861-1863 hands $gt/$gte/$lt/$lte to mingo as written (put(op, store(val)), no null special-casing), and mingo compares two Null-typed values as equal, so $gte admits the stored-null row and $gt does not. This is the same reading the face's own 2026-09-01 ruling note records (filter-comparand-shape.ts:141-142). The message says "stored null", which is the precise half: the dev's missing-key measurement admits no row on any face.
  • "driver-sql compares against SQL NULL and admits no row" — RIGHT. The plain-column arm (sql-driver.ts:17153-17164) calls knex where(field, op, coerced) with the SQL greater-or-equal operator and the comparand as a bound parameter; only the $ne arm ahead of it (:17150) special-cases a null comparand, so the ordering arms compile the column against a NULL binding, which is UNKNOWN and admits no row.
  • "the evaluation faces do not agree" (both doors) — RIGHT, and the retired phrase "no two evaluation faces agree" was false: formula's evalOp (matches-filter.ts:685-704) guards actual != null && v != null, so formula and driver-sql both admit no row in every cell, while driver-memory admits the stored-null row on $gte/$lte. The amended phrase is true of the set; the old one was false pairwise.
  • Per-operator note: every operator's message cites {"$gte": null} as the example, including $gt's and $lt's, whose own cells agree across the three measured faces. The parenthesis is an example of the class ("an ordering against it"), not a claim about the operator in hand, so it is not a false statement. Noted, not a finding.
  • The vocabulary assertion's EVERY-face list, filter-operator-vocabulary.test.ts:86-88 — RIGHT and complete for the roster at the head. Drops the retired matcher (memory-matcher.ts is absent at the head; only memory-matcher-*.test.ts files remain). Adds driver-sql and driver-turso (remote transport): commit f1e921ab8e (clearing $empty) touched driver-sql/src/sql-driver.ts, driver-turso/src/remote-transport.ts, driver-memory/src/memory-driver.ts, objectql/src/having-filter.ts and formula/src/matches-filter.ts, and remote-transport.ts compiles its own WHERE (buildWhereSQL). The one driver not on the list, driver-sqlite-wasm, extends SqlDriver and compiles through its codepath, so it is not a face of its own and f1e921ab8e touched no source in it. driver-mongodb has its own compiler (mongodb-filter.ts:1257) and is listed; memory-analytics.ts exists and is listed as the analytics face.

Tree-wide residue: git grep at the head finds 0 hits for "no two evaluation faces" and 0 hits for the old parenthesis in any string. The retired matcher is still named in comment lines (filter.zod.ts:3087, filter-comparand-shape.ts:123, :143, the conformance ledgers) — all past-tense history ("#6520 gave every JS evaluation face ... reference matcher and analytics face"; "the reference matcher compared ... until commit 8fec76a2b retired it"), so none is a class (a) present-tense claim, none is a runtime string, and the card scoped itself to the two string literals with #21395 holding the comment lines. Nothing to file.

② Semver level

  • .changeset/21397-null-ordering-message-faces.md: '@objectstack/spec': patch — RIGHT. The diff publishes two changed runtime strings from a released package and nothing else; a fix in a released package takes patch, never none and never skip-changeset (the label is absent; the labeler's documentation / size/s / tests / tooling / protocol:data are not the dev's). No major, no breaking arm, so no ADR-0087 marker is owed; check-adr-0087-registration and check-changeset-no-major ride inside the green Check Changeset and Lint & Repo Gates runs.
  • Clause-②: no — RIGHT and well-formed. It appears on line 2 of the PR body and in the changeset body, with no (widening) / (narrowing) arm, which matches ①: no accept set moves at the schema door, the save door or the runtime door.
  • The changeset body states what stays byte-identical (first sentence, prescription, the schema door's ruling sentence, the runtime door's "NOT applied" tail) and the one consumer-visible effect (a client or log filter matched on the old wording). It carries no tracker number and no model identifier.
  • Check Changeset on this head: two runs (the second re-triggered by the body rewrite at 14:58Z), both success.

③ Boundary flags

Dev report 5952679308 (round 1):

  • open_questions[0] — "no two evaluation faces agree" is false read pairwise; A (keep) or B (rephrase both)? ANSWERED by the seat in 5952717122: B, as a no-escalation wording class, with the claim widened to the twin's string. Implemented at 80b111c86e; both strings at the head read "the evaluation faces do not agree".
  • deviations[0] (no origin/main merge before the PR) — SUPERSEDED: 703bb28168 merges ceb4a939b4 after docs: comments outside driver-memory name what replaced the retired reference matcher (#20822 group 4) #21395 landed (the A5 trigger); git diff --stat base..head is exactly the four files.
  • deviations[1] (labels) — not the dev's writes; skip-changeset is absent, correctly.
  • deviations[2] (attribution) — VERIFIED: c28172bd66 and 80b111c86e end with the model-free pair (Claude-Session: URL, Co-authored-by: Claude); the merge commit carries no trailer and no model identifier.
  • deviations[3] (concurrent sweep, one timed-out gate rerun) — procedural; the head's required contexts are the verdict, and all seven are green.
  • out_of_scope_findings[0] (EVERY-face list omits driver-sql / driver-turso) — ABSORBED in the patch round and verified against f1e921ab8e's file list (see ①).

Seat ruling 5952717122: honoured — one phrase per string, twin's string only (:708), no comment line touched (the diff's hunks in both source files are string lines only). One observation for the seat, non-blocking: the ruling's premise "the twin cannot import the schema-door message, so one message serving both is unavailable" is true of a direct import and not of the mechanism this repo already uses for exactly this cycle — ./filter-comparand-refusal-text.ts ("one constant, two doors", #19889 record 5805248669: "neither door can be that place ... A module both import breaks the tie"), which filter.zod.ts:12 and the face both import today. Triage's "⛔ No second wording" was conditioned on the twin carrying the parenthesis, which it never did; the two doors already had two wordings before this PR; and this PR introduces no new divergence — it moves both strings the same way. So the ruling stands as the claim-holder's within a no-escalation class and the PR is judged on it. If the seat wants this slot on the shared module too, that is a follow-up text card, not a change to this head.

Dev report 5955118186 (patch round 1):

  • deviations[0] (PR body not rewritten by the dev, per os-dev.md) — the seat rewrote it; read as it stands, it matches the diff (four files, the corrected pin lines :347 / :351-352, gates at 703bb28168, Clause-②: no, Fixes #21397, no angle brackets). ESCALATED to the seat, non-blocking: the rewritten body carries no attribution footer (it ends at the gates list; no rule line, no "Generated by" line). AGENTS.md puts the session-URL footer on a PR body. One body edit; no gate reads it; no verdict weight.
  • deviations[1] (merge of origin/main) — the A5 trigger fired (docs: comments outside driver-memory name what replaced the retired reference matcher (#20822 group 4) #21395 landed); clean merge; accounted for above.
  • open_questions: [], out_of_scope_findings: [].

Triage direction 5950939866, item by item: both texts name only faces that exist — yes (①); the refusal's first sentence and ruling sentence stay — yes, byte-identical in the diff; the twin checked for the parenthesis — it carried none, and its one phrase moved under the seat's answer B; patch + Clause-②: no — yes (②); "the claim re-measures which pins read the message" — done, and report 2 corrects report 1's line numbers. Verified at the head: filter.test.ts:176, :187, :217, filter-comparand-shape.test.ts:441, service-analytics filter-value-type-fidelity.test.ts:495 and where-face-arms-refusal.test.ts:88 all read the first sentence only; no test at the head or at the base reads either changed phrase, and none was loosened.

Claim 5951484481: the file surface is honoured (plus the amendment's twin string); the dev ran mode:subagent, so Implemented-by: is the branch. Head repo equals base repo (not a fork). No governed surface in the file list (Governed Surface Queue Guard success); 39 changed lines (Check PR Size success).

Check-runs on the head, as read: the seven required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — all completed / success. Every other run is success or a paths/event skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the 14:58Z re-triggered Auto Label / Check PR Size). Nothing failure, nothing in_progress at the last read. The Docs Drift Check comment is advisory and lists nothing.

Implemented-by: claude/issue-21397-null-ordering-message-faces
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 15:15
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 15:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 68c5ab7 Oct 2, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21397-null-ordering-message-faces branch October 2, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: the null-ordering refusal message and a vocabulary assertion message still name driver-memory's reference matcher, retired by 8fec76a2b

2 participants