Stop a lowered operator key from clobbering a sibling — one rule for the class, on both document-shaped drivers and the analytics face - #13550
Conversation
…lobbering a sibling Generalise #13195's per-operator guard into one rule for the class: a lowered write whose key is free merges inline, a write whose key is taken becomes its own $and branch. Ranked by the spec's declared operator order so the emitted document is a function of the constraint set, not of the author's key order. Also promotes the analytics face's wholesale per-member clobber the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
The vocabulary sweep is written against the declared operator set rather than a hand-list, so a new operator cannot join without this coverage being told. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift Check7 anchor(s) derived from 2 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 611bb213e8ae717317911e489fa993b5d11769ea && git checkout 611bb213e8ae717317911e489fa993b5d11769ea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 50cf2940b9668154ab4d2ba8b08336cf5d93a470 3b7b4d4c4b57a0978901d014c5c3ed3a0d14b681 && git checkout -B drift-repro 50cf2940b9668154ab4d2ba8b08336cf5d93a470 && git merge --no-ff 3b7b4d4c4b57a0978901d014c5c3ed3a0d14b681
node scripts/docs-audit/affected-docs.mjs --json 50cf2940b9668154ab4d2ba8b08336cf5d93a470 |
|
PM review — ACCEPT, landing held until CI converges (11 success · 2 skipped · 16 running · 0 red at time of writing). My bar is every check green. ⛔ I do not merge it. ⭐ The enumeration was the point of this card, and it returned more than the class was thought to holdTriage required enumeration, not example. What came back is a measurement, not a list: each of the 18 declared authorable operators translated alone through both drivers, with a second comparand wherever the lowering is comparand-dependent, and the emitted key sets intersected pairwise. It found:
⇒ The card named three members. The class is five, minus one that was never reachable. A list would not have found that; only walking the vocabulary did. ⭐ And it is durable: the sweeps assert against the declared operator set with a coverage assertion, so a nineteenth operator fails loudly instead of being silently skipped. The key-order requirement, and the ablation that actually proves itTriage: 只测一个方向的用例,恰好是本缺陷能活下来的原因. Every repaired cell is asserted in both orders, plus an 18-operator × 306-ordered-pair × 2-order sweep on memory and 16 × 240 × 2 on mongodb. ⭐ The ablation that proves the order cases bite is A3 — promotion kept, ranking removed: semantically correct in both directions, so all 13 memory row-set tests stay GREEN while 6 of 32 mongodb two-direction document assertions go RED. That isolates exactly the property under test: only the two-direction document assertions catch order-dependence. And independently, Three ablation legs, each mutation proven on disk by grep counts of both the injected marker and the deleted text, each restore proven by an empty The analytics face — measured, and the card's suspicion confirmedA different and wider defect, proven with a pair that shares no contested key: ⭐ And a consequence nobody had asked about: Same rule, different mechanism, eight lines, in the file the card names ⇒ correctly in this PR rather than deferred. One precision I checked and would state differentlyThe PR describes the rank as read off "the spec's declaration order, not a hand-copy of it." Verified on ⇒ The property holds — the rank has one source and anything in that array is ranked the day it lands — but the source is driver-memory's array derived from spec, not spec's array directly. Worth saying precisely, because "the spec's declaration order" invites a reader to look in the wrong file. ⛔ Not a defect and not a change request. Fences and the oracleClean: no Clause ② re-checked by the executor and holds: Zone 2 — C broke (the three known members were not the whole class) and D broke in one direction (the analytics clobber is wider, but that argued for including it, not deferring it). A held and was used; B held and was re-confirmed twice. 946 + 510 pre-existing tests passed with zero edits — nothing re-baselined, skipped or deleted. ⇒ On full green I flip ready and enqueue. #13549 (a reference-matcher Generated by Claude Code |
Fixes #13524
A field operator whose lowering reuses another operator's key silently clobbered it. Both document-shaped translators wrote every lowered key into ONE object literal, so two constraints on one field landed on one key and the second assignment won — no error, no warning, nothing in the emitted query to see it by. WHICH constraint disappeared was decided by the author's key order, because that is the order
Object.keyswalks. On an RLS read scope, a dropped constraint is a widened one.The card's table, reproduced on
origin/mainat50cf2940b9before anything was writtenFixture
{id:'1',name:'a'},{id:'2',name:'b'},{id:'3',name:null}, driven throughInMemoryDriver.find(), with the reference matcher (memory-matcher.tsmatch()) as the oracle:{name: {$null: false, $ne: 'b'}}['1','3']['1']{name: {$ne: 'b', $null: false}}['1','2']['1']Exactly the card's numbers. The premise holds.
The must-answer question, answered by ENUMERATION
Every operator in the declared vocabulary was probed ALONE and its lowered key set recorded, then the sets were intersected. This is measurement, not reading — the probe is now the executable table in
mongodb-operator-key-clobber.test.ts.Declared authorable field operators,
n=18(SUPPORTED_FIELD_OPERATORS=FILTER_OPERATORSplus$likeand$ilike):$eq $ne $gt $gte $lt $lte $in $nin $between $contains $notContains $startsWith $endsWith $icontains $null $exists $like $ilike.Contested lowered keys — a key more than one authorable operator writes:
$eq$eq,$null: true,$exists: false$ne$ne,$null: false,$exists: true$gte$gte,$between$lte$lte,$between$lt$lt,$lteon a bare calendar day,$between(bare-day max)$regex$contains,$startsWith,$endsWith,$icontains(+$like,$ilikeon memory)_multiRegex$not$notContains— and NOTHING elseThree results the card did not have:
$ltewith a bareYYYY-MM-DDcomparand. The whole-day rewrite compiles it half-open, onto$lt, which an author writes too. Measured:{d: {$lte: '2026-07-28', $lt: '2026-07-02'}}answered['1']and its key-swapped twin['1','2'], oracle['1'].$regexstring family.driver-memoryhas promoted its string family to$andbranches for years; this face never did. Measured:{name: {$startsWith: 'a', $endsWith: 'z'}}emitted{name: {$regex: 'z$'}}and the swap emitted{name: {$regex: '^a'}}— one anchor silently gone in each direction.$notContainsis NOT reachable. Nothing else writes$not, and$notis a LOGICAL operator absent from the field vocabulary, so it cannot be authored beside it on one field. The card's third named member was reasoned, not executed. It is covered here by construction rather than curatively — which is the point of a rule for the class.One rule for the class, generalising the guard #13195 landed
#13195 hit this class from the other side and gave
$existsa guard: a lowered write whose key is free merges inline, a write whose key is taken becomes its own$andbranch. That guard was deliberately scoped to one operator. This generalises it rather than putting a second idiom beside it: every arm now records its writes through one accumulator, and one shared assembly applies the same rule to all of them._presenceAndis subsumed by a_extraAndlist, since one field constraint can contest more than one key.One addition to that shape: which writer keeps the inline slot is decided by the spec's declared operator order, not by the author's key order. Author order would keep the ANSWER correct —
$andis commutative — while leaving the emitted DOCUMENT a function of key order, which is the property this card exists to remove. Ranking byFILTER_OPERATORSorder also reproduces #13195's landed documents byte for byte, because$existsis last in that list.Writes are collected in author order and assembled afterwards, so refusals still fire in the order the author wrote them and an uncontested key keeps its original insertion position. A filter with no contested key emits exactly what it emitted before — which is why every pre-existing test in both packages passes unchanged, with nothing re-baselined.
The analytics face — measured separately, and it is a different, wider defect
MemoryAnalyticsService.query()built its$matchasmatchStage[fieldPath] = builder(...). That is not a per-key clobber but a wholesale one: the stage is keyed by field path alone, so a second predicate on a member replaced the first ENTIRELY, for every operator pair. Measured:{name: {$contains: 'a', $ne: 'b'}}aggregated['1','3']and its key-swapped twin['1'], oracle['1']— and neither operator shares a lowered key with the other, so the translators never lost this one.Worse,
flattenFilterConditionfolds$andinto the same flat list, so{$and: [{name: {$contains:'a'}}, {name: {$ne:'b'}}]}— the shape a dashboard actually authors — lost a constraint too.The remedy is the same RULE one level up (free member inline, taken member becomes its own
$andbranch of the same$match) but a different mechanism, keyed by field path rather than by lowered key, and it needs no ranking because nothing is being overwritten. It is eight lines and lives in the file the card names, so it is here rather than deferred. Also measured and pinned:generateSqlpushes into a LIST and never clobbered, so before this the echoed statement and the executed answer described different filters.Both key orders, and the ablation that proves the order cases bite
Every repaired cell has a case in each key order. Three ablations, each mutated on a committed implementation, proven on disk by grep counts of both the injected marker and the deleted text, restored under an
EXIT INT TERMtrap with an absolute repo root, and each restore proven by an emptygit diff HEADplus a HEAD-blob hash match:A3 is the one worth reading. It is semantically correct in both directions, so every row-set assertion passes; only the two-direction DOCUMENT assertions catch it. And the before-picture shows the same thing from the other side:
$between+$gtewas right by accident when the range was written first and wrong when it was written second, while$between+$ltefailed in the OPPOSITE direction. Neither one-direction suite catches the class.The sweep is written against the declared operator set rather than a hand-list, and fails loudly if the comparand table stops covering it, so a nineteenth operator cannot join the vocabulary without this coverage being told.
Ablation note: both new suites import their subject through RELATIVE paths inside their own package, so vitest compiles the source directly and no
distsits between the mutation and the reading. Every leg was nevertheless verified on disk before it was measured.Verification, all at
3b7b4d4c4bpnpm --filter @objectstack/driver-memory exec vitest run— 35 files, 959 tests passedpnpm --filter @objectstack/driver-mongodb exec vitest run— 24 files, 542 passed, 143 skipped (the gated real-mongod suites)...@objectstack/driver-memory,...@objectstack/driver-mongodb) — 16 of 20 packages ran to completion, all green: runtime 2973, rest 2709, plugin-auth 1744, driver-turso 1108, service-datasource 591, client 405, cloud-connection 340, http-conformance 86, hono 74, service-sms 70, verify 48, client-react 34, plugin-dev 58, embed-objectql 2, plus the two changed packages. Roughly 11,700 tests, zero failures.packages/cliand the three example apps exceeded the container's foreground ceiling as a batch. Narrowed and declared: the fourpackages/clitest files that namedriver-memoryorInMemoryDriverwere run directly — 90 tests, all passing.pnpm lint(eslint . --no-inline-config, the whole repo, not narrowed) — exit 0pnpm check:nul-bytes— OK, 7518 text files scanned; plus a direct control-character grep over every touched path, no hitsnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real change set — 34 commands, 32 exit 0. The two non-green are declared PREREQUISITE NOT MET by the gates themselves and measured nothing:check-test-completenessneeds a savedturbo run testlog, andcheck-half-statesneeds a real GitHub credential this container does not have.check:dual-build-cjs-loadsandcheck:type-check-debtboth refused until the workspace closure was built, then passed: 102 require entry points across 66 packages load, and 29 ledger entries re-measured with none above its recorded number.Scope
Untouched:
packages/spec,docs/adr,.claude,skills,AGENTS.md,CLAUDE.md,content/docs/releases. No test was skipped, disabled, quarantined, allow-listed, deleted or re-baselined — every pre-existing test passes as written.Filed while measuring, out of scope here: #13549 — the reference matcher matches a null-VALUED row against a well-formed bounded
$betweenwhile the live path excludes it. Distinct from the two queued matcher cards, both of which were read before filing: #13495 is the null-BOUND axis of the same arm, #13494 the$eq: nullguard. Neither of those is addressed here, and both remain open.Generated by Claude Code