Skip to content

fix(driver-memory): a no-value row satisfies $nin / $notContains in the reference matcher (#13166) - #13356

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13166-memory-matcher-no-value-negated-operators
Aug 30, 2026
Merged

fix(driver-memory): a no-value row satisfies $nin / $notContains in the reference matcher (#13166)#13356
os-zhuang merged 2 commits into
mainfrom
claude/issue-13166-memory-matcher-no-value-negated-operators

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Part of #13166

Implements the settled ruling (#5146#5298 option A, re-affirmed 2026-08-10 after the reversal was priced and withdrawn): a row with no value SATISFIES a negation-carrying operator. driver-memory's reference matcher diverged in 3 of the card's 6 measured cells. Both independent causes are fixed, and the four in-code statements that spelled the #5499 freeze as LIVE are settled together with the disposition rather than re-tensed.

The defect, reproduced first

The card's fixture, run before any source edit — 6 of 14 assertions red, and exactly the three cells the card named:

× $nin: an absent key satisfies it            AssertionError: expected [ '2' ] to deeply equal [ '2', '3' ]
× $notContains: a null value satisfies it     AssertionError: expected [ '2' ] to deeply equal [ '2', '3' ]
× $notContains: an absent key satisfies it    AssertionError: expected [ '2' ] to deeply equal [ '2', '3' ]
Tests  6 failed | 8 passed (14)

Two independent causes, one stated ruling

Both live in packages/drivers/driver-memory/src/memory-matcher.ts:

  1. checkCondition's pre-switch guard allowlisted $ne but not $nin / $notContains, so a missing key short-circuited to "no match" before those arms ran.
  2. The $notContains arm rejected a null on its typeof value !== 'string' type test rather than on the predicate — the name: null half, which guard 1 cannot reach (it only ever sees undefined).

Both now answer one named predicate, noValueSatisfiesNegation, whose docblock carries the ruling chain. Two spellings of one ruling is how they came apart in the first place.

A present, non-string value keeps the answer it had — only the no-value cells moved, and there is an assertion pinning that.

Fence 1 cleared by measurement, not by assumption

This matcher is the reference the SQL family was aligned to for #5146, so the stop condition was: does aligning it overturn already-aligned SQL-family behaviour? It does not, and no SQL-side file is touched. driver-sql and formula already answered the include direction, so this change moves driver-memory to them:

Suite Result Files changed here
@objectstack/driver-sql 2241 passed, 9 files skipped (pre-existing, engine-gated) none
@objectstack/formula 643 passed none
@objectstack/driver-memory 919 passed (32 files) 4
@objectstack/service-analytics 1805 passed 1 (comment)
@objectstack/objectql 4287 passed 1 (comment)

sql-driver-not-null-safe.test.ts still asserts ['1'] for both filters the memory pin asserts; the two files agree again because this one moved.

What does NOT change for users

InMemoryDriver.find() is unaffected. match() is not in this package's export surface, and the live mingo path users actually reach already answered the include direction — measured on the card's fixture through the public API, ['2','3'] for all three operators. The observable effect is that this package's two filter faces now agree where they used to disagree. The changeset is graded to exactly that.

The four prose sites, settled with the disposition

Not re-tensed — #13089's triage forbade that, and rightly: a tense-only rewrite would have carried a measured-false claim forward as settled-looking prose. Each site was wrong twice: (a) the freeze dissolved 2026-08-11; (b) the "driver-mongodb does this too" clause was never true for this operator family (translateFieldOperators passes $nin through and compiles $notContains to { $not: { $regex } }, both of which match a missing or null field).

  1. packages/objectql/src/having-filter.ts — the card's site 1.
  2. packages/services/service-analytics/src/read-scope-sql.ts — the card's site 2. ⚠️ See the merge note below.
  3. packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts — the card's site 3, including the load-bearing sentence that was the stated reason the assertions had not been flipped. Two assertions inverted in place, with the old values and the reason recorded beside them.
  4. packages/drivers/driver-memory/src/memory-driver-document-not.test.tsfound during execution, not named in the card. Its describe already read "disposition open (driver-memory's reference matcher still answers $notContains / $nin the pre-ruling way on a no-value row — the #5499 freeze that excused it dissolved 2026-08-11, so the divergence is now unexcused and untracked #13166/$exists still reads KEY-PRESENCE rather than has-value on driver-memory's live mingo path and driver-mongodb — the #5499 freeze that excused it dissolved, #13166 explicitly excludes it, so it is now unexcused AND untracked #13195)" and its note already named the LIVE column correct for these two operators, so it was built for exactly this edit. Its $nin and $notContains rows now assert live and reference agreeing; its $exists row is untouched and stays a pinned divergence.

⛔ No assertion was re-baselined onto whatever the matcher began printing. Each was inverted onto a target named as correct before the fix existed — the live path's answer, and formula's.

Merge note — PR #13321 landed mid-flight and owns half of site 2

#13321 merged as 881f8d8e88 while this branch was in flight and rewrote read-scope-sql.ts:179-180, one of the sites this card was assigned. Its text correctly records the thaw and that the debt is now DUE, but it kept the second error — it still reads as though driver-mongodb answers the cell differently.

Resolved by extending its landed text, not reverting it: the thaw/DUE framing is kept verbatim in substance, and the #13166 disposition is added on top (mongodb was never a holdout; driver-memory was, on its reference face only, and is aligned now). having-filter.ts was left byte-identical to main by #13321 as promised, so there was no conflict there.

⛔ Also unchanged: the other freeze-tense sites #13321 owns.

Non-vacuity — ablation, direction predicted in writing first

The prediction was recorded before either leg ran. The subject resolves through a relative import, so vitest reads src/, not dist/ — evidenced by the repro failing and the fix passing with no build between them, so no rebuild leg is owed here.

Leg Mutation Predicted Observed
A guard allowlist reverted to $exists/$ne/$null red on the missing-key column only 6 failed, exactly the predicted set
B $notContains arm reverted to the bare type test red on both $notContains cells; "both readings agree" stays GREEN 3 failed, and that cell did stay green

Leg B is the one worth reading. With the guard fixed, undefined reaches the arm and fails the same type test as null, so both columns are equally wrong and the agreement assertion cannot see the cause. That is the concrete reason the six-cell structure is load-bearing and must not be collapsed: a table asserting only "the two readings answer alike" would be blind to cause 2.

Each mutation was proven on disk by content grep plus a git hash-object differing from the HEAD blob, and each restore was proven byte-identical to the HEAD blob by hash comparison rather than by an exit code, with an absolute-path trap ... EXIT INT TERM on both legs. Final tree hash equals the HEAD blob.

Verification

All readings below are from the final commit, 5ed889a2bf.

  • Suites: the five packages in the table above. Commands were run through the shared verify lock; exit codes captured before any pipe.
  • Typecheck: driver-memory, objectql, service-analytics — all Done, exit 0. tsc --listFiles confirms all three edited/added test files are inside the program, so "typecheck clean" genuinely covers them rather than silently excluding *.test.ts.
  • Gates: 34 of 37 green, including the driver-change gate this card owes —
    check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
    and where-matcher conformance holds: 316 matcher(s) discovered … 0 silently-wrong and 0 unjudged … none new.
  • 3 gates are NOT MEASURED, not passes, each quoting its own refusal:
    • check-test-completeness.mjs"There is no local log to hand it, so the local reading for this gate is NOT MEASURED. ⛔ It is not a red."
    • check-half-states.mjs"Nothing was swept … it is no reading at all." (needs a GitHub credential; a board sweep, unrelated to this diff)
    • check:dual-build-cjs-loads"PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured." (needs a full pnpm build; its own self-test passed, 59 cases)
  • check-engine-split-ratio.mjs first refused on the shallow clone; deepened with --shallow-since and it then measured and exited 0. A refusal was never counted as a pass.

Declared narrowing: the repo-wide pnpm lint and the full-workspace check:type-check-debt ratchet were not run locally — the latter refuses without a built workspace closure, and a full build risks the container's foreground cap. The structural half, check:type-check-coverage, is green, and the added test file typechecks clean inside its package program. Lint & Repo Gates runs gates the derivation never names (#13333), so CI owns the farm here.

Deliberately out of scope

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 30, 2026 05:32
…he reference matcher

The reference matcher `driver-sql` was aligned TO for #5146 diverged from the
platform's settled INCLUDE direction in 3 of 6 measured cells. Two independent
causes, one per reading of "no value":

1. `checkCondition`'s pre-switch guard allowlisted `$ne` but not `$nin` /
   `$notContains`, so a MISSING key short-circuited to "no match" before those
   arms ran.
2. The `$notContains` arm rejected a `null` on its `typeof value !== 'string'`
   TYPE test rather than on the predicate — the half the guard cannot reach.

Both now answer one named predicate, `noValueSatisfiesNegation`.

The four in-code statements that spelled the #5499 freeze as LIVE and used it to
excuse the divergence are settled together with the disposition rather than
re-tensed: the freeze dissolved 2026-08-11, and the "driver-mongodb does this
too" clause was never true for this operator family.

`$exists` is untouched — it is the neighbouring cell with a different backend
list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…mory-matcher-no-value-negated-operators

# Conflicts:
#	packages/services/service-analytics/src/read-scope-sql.ts
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/objectql/src/having-filter.ts, packages/services/service-analytics/src/read-scope-sql.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/objectql/src/having-filter.ts, packages/services/service-analytics/src/read-scope-sql.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 22 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 3ec8646f1b71deaa95a6675ddbbbf6303c79ba05packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7cd1357b7ac4c10f87d36a951a5ece5fc4eae3bf — the merge of head 5ed889a2bf3481406a3c8e2e596be6a41d495b36 into base 3ec8646f1b71deaa95a6675ddbbbf6303c79ba05, 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 7cd1357b7ac4c10f87d36a951a5ece5fc4eae3bf && git checkout 7cd1357b7ac4c10f87d36a951a5ece5fc4eae3bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ec8646f1b71deaa95a6675ddbbbf6303c79ba05 5ed889a2bf3481406a3c8e2e596be6a41d495b36 && git checkout -B drift-repro 3ec8646f1b71deaa95a6675ddbbbf6303c79ba05 && git merge --no-ff 5ed889a2bf3481406a3c8e2e596be6a41d495b36

node scripts/docs-audit/affected-docs.mjs --json 3ec8646f1b71deaa95a6675ddbbbf6303c79ba05

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

PM review — accepted for landing (held in draft until CI is complete and green)

Reviewed at head 5ed889a2bf, three-dot against the branch's real merge base 881f8d8e8.

Scope

Seven files. Zero packages/spec/src/**, docs/adr/**, .claude/**, skills/**, content/docs/releases/** — control 3/3 on a synthetic list, so the zeros are real. Clause-② does not bind.

All three fences held, and fence 1 was cleared by measurement

Fence 1 was the real risk: this matcher is the reference the SQL family was aligned to in #5146, so the stop condition was whether aligning it overturns already-aligned SQL behaviour. It does not — driver-sql (2241 passed) and formula (643 passed) both green with zero files changed there, and sql-driver-not-null-safe.test.ts still asserts the same values. The change moves driver-memory to them, which is the direction the ruling requires. ⛔ No SQL-side file was touched to make this card green.

Fence 2 (enrolment, not adding a row) was respected in the way that costs the most and is therefore worth naming: the backend is now fixed, which is the precondition — but enrolment lives in packages/spec/src/**, which my dispatch fenced off. Rather than breaching the fence or pretending the card is finished, it says Part of and leaves the residual filter-logic-conformance.ts:179 cell documented. That is my fence creating a genuine partial, declared instead of hidden.

Fence 3 ($exists is #13195's cell) held — untouched, and still pinned as a divergence in both pin files.

⚠️ #13321 landed mid-flight — extended, not reverted. I checked.

#13321 merged as 881f8d8e88 while this branch was in flight and rewrote read-scope-sql.ts:179-180, one of this card's assigned sites. That is exactly the shape that produces a silent clobber, so I read both versions rather than trusting the claim:

⇒ Extended. ⛔ Nothing of #13321's reverted, and the other freeze-tense sites it owns are untouched. Given that I duplicated another seat's work earlier today by not checking a file surface, seeing this one handled correctly under the same collision is worth recording.

⭐ Ablation leg B is a finding, not just a red

The direction was predicted in writing before either leg ran, and leg B's prediction included a GREEN: reverting the $notContains arm should turn both $notContains cells red while "both readings agree" stays green. It did.

The reason is the valuable part: with the guard fixed, undefined reaches the arm and fails the same type test as null, so both columns are equally wrong and an agreement assertion cannot see cause 2.

That is a concrete demonstration of why I insisted the six-cell structure not be collapsed — a table asserting only "the two readings answer alike" would be blind to one of the two causes. The dispatch asked for the structure to be preserved; this proves why it has to be.

The prose sites — settled, not re-tensed

Four sites, each wrong twice: the freeze dissolved 2026-08-11, and the "driver-mongodb does this too" clause was never true for this operator family (translateFieldOperators passes $nin through and compiles $notContains to { $not: { $regex } }, both matching a missing or null field).

⛔ Not re-tensed — #13089's triage forbade that, and the report quotes the reason back correctly. Site 4 (memory-driver-document-not.test.ts) was found during execution and declared rather than swept in silently; its describe already read "disposition open (#13166/#13195)", so it was built for this edit.

⭐ And the inversions were done the right way round: each assertion was inverted onto a target named as correct before the fix existed — the live path's answer, and formula's — rather than re-baselined onto whatever the matcher began printing. That distinction is the whole difference between inverting a pin and laundering one.

Verification

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt — the gate this card specifically owed. check:where-matcher green with "none new". Typecheck coverage measured with --listFiles rather than assumed, confirming all three edited/added test files are inside the program.

Three gates NOT MEASURED, each quoting its own refusal; none counted as a pass. check-engine-split-ratio.mjs first refused on the shallow clone and was re-run with --shallow-since until it actually measured — ⭐ a refusal converted into a reading instead of into a checkmark.

Declared narrowing stated honestly: repo-wide pnpm lint and the full check:type-check-debt ratchet were not run locally, with the reason, and CI owns the farm — correctly citing #13333.

Landing posture

Not enqueued. CI started minutes ago. Bar is EVERY check completed and green; total_count grows as rollup rows appear. Held in draft.

Nothing to change.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Docs-drift advisory — resolved by measurement. Nothing to change.

The run lists nothing but flags that two changed files yielded no anchor (having-filter.ts, read-scope-sql.ts) — correctly warning that pages documenting those are invisible to it. Those are precisely this PR's two prose sites, so "nothing listed" would have been the weakest possible evidence. I searched for the claims this diff could actually falsify instead.

Read on origin/main:

claim searched hits
a page describing driver-memory's $nin / $notContains answer on a no-value row 0
a page asserting the #5499 freeze is LIVE 0
control — pages naming $nin or $notContains 10
control — pages naming driver-memory 21

The only $nin / $notContains occurrences in the corpus are two bare operator enumerationscontent/docs/api/wire-format.mdx:179 and content/docs/kernel/contracts/data-engine.mdx:186, both listing the supported operator set. Neither states no-value semantics, and this change moves only the no-value cells, so neither is falsified.

⇒ No hand-written page teaches the behaviour this PR corrects, and none carries the stale-freeze claim the four in-code sites did. Nothing filed, nothing edited.

Recorded so the next reviewer does not have to re-derive it, and because the advisory's own "not a clean bill of health" caveat applies squarely here — the empty list was not the answer; the controlled search was.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33297884013 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (5/6) — 失败步骤: Run this shard's tests

    @objectstack/rest:test:  FAIL  src/ui-view-route-tenancy.measurement.test.ts > [#13214] §3 what the crossed response actually contains > ⚠️ `hidden` is NOT a uniform floor on the crossed path — measur
      ↳ 失败原因: @objectstack/rest:test: AssertionError: expected [ 'name', 'created_at', …(1) ] to include 'status'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Copy link
Copy Markdown
Contributor Author

队列弹出 —— ⛔ 不是本 PR 的失败。已定位到根因,且不重排,理由如下。

失败的是什么

队列构建 33297884013

@objectstack/rest:  FAIL  src/ui-view-route-tenancy.measurement.test.ts
  AssertionError: expected [ 'name', 'created_at', …(1) ] to include 'status'

为什么不是本 PR 的

本 PR 的文件面是:changeset · driver-memory ×3 · objectql/having-filter.ts · service-analytics/read-scope-sql.ts零个 packages/rest/** 文件。失败测试所在的包,本 PR 一行都没碰。

⚠️ 而且这条失败不是 flake,也不是环境问题 —— 它有确切的、已知的成因:

ui-view-route-tenancy.measurement.test.ts 属于 PR #13258(卡 #13214),它把 getUiView 修复前的行为有意钉成了测量:一个声明了 hidden 且名字在优先名单里的字段仍被送出。PR #13329(卡 #13259)于 04:23Z 落地,正是修掉这个缺陷的 —— 于是那条断言按设计变成假的。

我在 #13258 落地前就把这一点写给了它的作者,指名了是哪一条断言、为什么会变红,并明确提出可以压住 #13329 让他们先落#13258 (comment) 。他们选择继续。

⇒ 合并队列把两者放进了同一条投机栈,所以 #13258 的红把排在它后面的本 PR 一起弹了出来。队列分诊 bot 自己也这么判:「已弹出 2 个 PR(#13258#13356),但它们同属 1 条投机栈 ⇒ 1 次独立命中

⛔ 为什么不重排

修复不在我手里:它是另一个席位 PR 里的一条断言,⛔ 我不会去改别人的 PR,也没有可以「移植进本 PR」的补丁 —— 本 PR 根本不包含那个文件。

而队列分诊清单的第 3 条正对上:「等前面的 PR 落地或失败出队后再重排一次即可,⛔ 不要连续重排」#13258 仍带着那条陈旧断言排在前面,现在重排只会再烧一轮全队列重建、然后以同样的原因再弹一次。

#13258 落地或出队,再重排一次。 我这一次重排额度未动用,留给那个时候。

本 PR 自身的状态未变

5ed889a2bf32/32 全绿(含 Lint & Repo Gates 与本卡专属的 check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt)。评审已通过。⛔ 没有任何东西需要在这里修。


Generated by Claude Code

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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants