Uh oh!
There was an error while loading. Please reload this page.
fix(driver-memory): decide null before comparing in the reference matcher - #13554
Conversation
…cher The record-at-a-time matcher disagreed with the mingo query path beside it on three cells that all turn on a null. `$eq: null` did not match a row whose key was ABSENT, though it matched one whose value was a stored null: the pre-switch guard short-circuited a missing key to "no match" before the `$eq` arm ran, so one operator answered the two readings of "no value" two ways. `$eq` joins the guard's allowlist for the same reason its complement `$ne` was already on it — its arm decides the case itself. The exemption is written over the OPERATOR, never over "the comparand is null", so the held `$in: [null]` / `$nin: [null]` arms keep byte-identical answers. The `$between` arm was an exclusion test spelled with `<` and `>`. A relational comparison against a null is false in both directions, so neither disjunct fired and a bounded range stopped bounding: `[null, null]` matched every valued row, and a null-valued row sat inside a well-formed range. The arm decides comparability before comparing now. A comparison-only repair would have left the numeric case broken — null coerces to 0, so a null-valued row stays inside `[-1, 1]` — and that cell is pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift Check2 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
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2f0f2b5cfc6c96f01ebd7796b6e92059aed66da2 && git checkout 2f0f2b5cfc6c96f01ebd7796b6e92059aed66da2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df18120502b449b0b89ca19631cb595c21d1d91b 1198c1876329a68a207ecc541b9b2d647014aa08 && git checkout -B drift-repro df18120502b449b0b89ca19631cb595c21d1d91b && git merge --no-ff 1198c1876329a68a207ecc541b9b2d647014aa08
node scripts/docs-audit/affected-docs.mjs --json df18120502b449b0b89ca19631cb595c21d1d91b |
zhuangjianguo
commented
Aug 30, 2026
PM review — ACCEPT. Release held only on CI reaching full green.
The two Zone 1 rulings hold STRUCTURALLY, which is stronger than holding by assertionR1 — the #13357 hold. Verified two ways, and the second is what makes it durable:
⭐ That is the difference between honouring a hold and being unable to violate it. The six before/after cells are welcome, but the operator-scoped exemption is why they cannot drift later. R2 / R3 — #13550's pins. The only change to The load-bearing claim, executedThe claim that a comparison-only repair silently leaves numeric columns broken: Confirmed. Both fixtures in all three cards are ISO date strings, where A1: refuted, and the refutation was the pointTwo roots, not one. #13495 + #13549 share the Clause ②: I CONCUR it does not fire, and I verified the load-bearing half rather than accepting it
One thing I want on the record, because a later reader could misread it as a regression#13553 discloses that after this lands, That is correct scope discipline, not an oversight, and it is a strict improvement: one more arm now agrees with the live face, and repairing the other four would have widened the PR past its three cards on the seat's own judgment. It was disclosed rather than buried, and filed. ⛔ Nothing here should be "tidied up" by widening this PR. Non-blocking, carried to the maintainer rather than settled here
If the maintainer wants a different reading of the degenerate range, it is one line in Release conditionsCI at review time: 16 success, 2 skipped, 11 in progress, 0 red. ⛔ The bar is EVERY check green, not the required subset. On full green this goes draft → ready with auto-merge armed, and the merge queue does the merging — ⛔ never a hand-merge, never a queue bypass, never by me. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13494
Fixes#13495
Fixes#13549
Three cards, one file, one claim. The reference matcher disagreed with the mingo query path beside it on three cells that all turn on a null.
The two roots — measured by executing the file, not inferred from the cards
The dispatch order's A1 hypothesis was that all three share ONE root. Measured: it is two roots, not one. The fold still holds — both roots are in one file, the fixes do not interact, and neither needs the other — but the PR's shape follows the measurement:
{$between: [null, null]}with EVERY valued row — the range arm's two comparisons are both false against a null bound, so a bounded range stops bounding #13495 and driver-memory's reference matcher matches a NULL-VALUED row against a well-formed bounded$between— the live mingo path excludes it, so one package answers one filter two ways #13549 ARE one root. Same arm, same line, same coercion. The$betweenarm was an EXCLUSION test,if (value < min || value > max) return false, and a relational comparison against a null is false in BOTH directions, so neither disjunct fired and the range stopped bounding. driver-memory's reference matcher answers{$between: [null, null]}with EVERY valued row — the range arm's two comparisons are both false against a null bound, so a bounded range stops bounding #13495 enters on the comparand axis (a null BOUND), driver-memory's reference matcher matches a NULL-VALUED row against a well-formed bounded$between— the live mingo path excludes it, so one package answers one filter two ways #13549 on the value axis (a null stored VALUE). One line repairs both.{$eq: null}with NO MATCH on a MISSING key — it is the one surface of five that does not read$eq: nullas the null predicate (#5332) #13494 is a DIFFERENT root in a different place — the pre-switch guard incheckCondition, which short-circuited a MISSING key to "no match" before the$eqarm ever ran. Nothing about it is a failed comparison: the arm's own loose!=had the right answer for both readings all along, and the proof is$ne, which was already on the guard's allowlist and answered both readings correctly throughout. A1's mechanism ("both directions of a compare come out false") does not describe this card at all.What changed
$eqjoins the guard's allowlist, for the same reason its complement$newas already on it: its arm decides the no-value case itself. The exemption is written over the operator, never over "the comparand is null" — see the hold below.The
$betweenarm decides comparability before it compares: a no-value row is not inside a range with a real bound, a valued row is not inside a range whose bound is absent, and the degenerate range whose both ends are absent selects the no-value rows. A range with one absent end selects nothing rather than everything.Every moved cell lands on the live path's existing answer
16 matcher cells moved. Zero live cells moved. After the repair the two faces agree on every cell in the sweep except the ones held for the maintainer.
{$eq: null}, key ABSENT[]['3']['3']{$eq: undefined}, key ABSENT[]['3']['3']{$between: [null,null]}, value null['1','3']['3']['3']{$between: [null,null]}, key ABSENT['1'][][]{$between: [null,'z']}, value null['1','3'][][]{$between: ['a',null]}, value null['1','3'][][]{$between: ['a','b']}, value null['1','3']['1']['1']{$between: ['2026-07-01','2026-07-15']}, value null['1','2','4']['1','2']['1','2']{$between: [-1,1]}numeric, value null['2','3']['2']['2']{$between: [null,null]}numeric['2','3']['3']['3']The half-null bound (
[null,'z']/['a',null]) was named by no card; it is the same arm and the same repair.Why a comparison-only repair is not enough — the load-bearing measurement
Rewriting the arm as
!(value >= min && value <= max)repairs every STRING cell in all three cards and silently leaves the numeric ones broken:nullcoerces to0, sonull >= -1 && null <= 1istrueand a null-valued row stays inside a numeric range. Every fixture in the three cards is ISO date strings, wherenullcompares againstNaNand the naive form looks correct.Ablated on the committed implementation: the naive form fails exactly 3 of the new suite's 16 cases, all numeric, and passes the other 13. The comparability decision is what those 3 hold to the code.
The #13357 hold (Zone 1, R1) — byte-identical, proved by measurement
$in: [null]/$nin: [null]areneeds-user-decisionand were not touched, aligned, or pinned. This is exactly why the guard exemption is spelled over the operator$eq: an exemption written over "the comparand is null" would have moved these arms with it, which is the decision this repair must not make.All six held cells, before and after, on the matcher:
{$in: [null]}, key ABSENT[][]{$nin: [null]}, key ABSENT['1','3']['1','3']{$in: [null,'a']}, key ABSENT['1']['1']{$nin: [null,'a']}, key ABSENT['3']['3']{$in: null}on the 5-row fixture['4']['4']{$nin: null}on the 5-row fixture['1','2','3','5']['1','2','3','5']Their standing disagreement with the live path is preserved unchanged, in both directions. No test in this PR asserts any of them — pinning them in either direction would prejudge the ruling.
PR #13550's 16 pins (Zone 1, R2 · Zone 2, A2) — zero moved
memory-operator-key-clobber.test.tswas confirmed present in the tree before the sweep (the discriminating check that #13550 landed), and its 16matcherIds(...)assertion sites across 13 test cases were run against BOTH matchers:df18120502): 13 passed (13)So A2 holds as measured: the pins sit in the agreeing set and a correct repair moves none of them. No pinned expectation was edited. The suite's header fence, which named #13549's defect as knowingly unrepaired, is updated to record that it is closed (R3) — prose only; the composition-law design that scores the live path against ITSELF is deliberately left as it is, because it is a statement about what a clobber test measures rather than a workaround for the divergence.
Consumers of
match()(Zone 2, A3)matchis not exported from the package entry, so it has no cross-package consumers. 13 in-repo consumers, all insidepackages/drivers/driver-memory/src/, all tests:memory-driver-document-not·memory-driver-filter-logic-conformance·memory-empty-field-constraint·memory-exists-has-value-faces·memory-filter-text-conformance·memory-filter-vocabulary-refusal·memory-icontains·memory-like-pattern·memory-matcher-no-value-negated-operators·memory-matcher-not-null-safe·memory-matcher-or-semantics·memory-null-comparand-refusal·memory-operator-key-clobbermemory-driver.tsimports the module too, but takesgetValueByPathonly — it is the one non-test consumer, and no arm this PR changes is reachable from it. Everything else in the repo namingmemory-matchernames it in prose.Tests
packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts, 16 cases. Every cell asserts the row set on BOTH faces in one call, against a literal expectation — comparing the two faces only to each other would be satisfied by both being wrong together.Ablation, on the committed implementation, mutation proved on disk by grep of both the injected and the deleted text, restore under a
trapwith an absolute repo root and proved by a whole-treegit status --porcelainplus a HEAD-blob hash match:df18120502>=/<=repairNo rebuild leg applies: the tests resolve
./memory-matcher.jsto package-local source rather than throughexportstodist, demonstrated by the before/after measurements differing with no build between them.Verified at
1198c18763, after mergingorigin/mainatdf18120502(R5):pnpm --filter @objectstack/driver-memory test— Test Files 36 passed (36), Tests 975 passed (975)pnpm --filter @objectstack/driver-memory typecheck— exit 0, andtsc --noEmit --listFilesconfirms both edited test files andmemory-matcher.tsare in the program (so the green covers the new tests rather than skipping them)pnpm lint— whole repo,eslint . --no-inline-config, exit 0node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real change set: 33 of 36 green, includingcheck:where-matcher,check:engine-double-contract,check:driver-conformance,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverageandcheck:nul-bytesThree gates returned exit 3, and each prints its own verdict saying nothing was measured — none is a finding:
check:dual-build-cjs-loads("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured"),check-test-completeness.mjs("the local reading for this gate is NOT MEASURED. It is not a red"), andscripts/pm/check-half-states.mjs("PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential"). CI measures the first two on a built tree.Out of scope, filed not fixed
#13553 — the same class in the four ORDERING arms: on a NUMERIC column
nullcoerces to0, so a null-valued row satisfies$gt/$gte/$lt/$ltewhile the live path excludes it. Found by widening this fold's fixtures from strings to numbers. It is a different arm family with a target the #5332 lane recorded as uncovered, so it is recorded rather than repaired here.Generated by Claude Code