Uh oh!
There was an error while loading. Please reload this page.
fix(drivers): a declared field written as an explicit undefined is indistinguishable from one never written - #12641
Conversation
…indistinguishable from one never written A row has two states to say about a field — the key is absent, or the key holds a value. An own key holding `undefined` is neither, so every consumer had to invent a reading of it, and measured on `origin/main` they disagreed: CEL (`has(record.f)` is `false`) and `materializeDeclaredFields` read it as absent, a bare `f in row` reads it as present. Both JS-backed drivers were measured separately and did NOT match. `driver-memory` emitted the own key from `create` and `find` alike, while its own projection path and its own matcher already read the shape as absent. `driver-mongodb` split: `create()` returned an own key holding `undefined` while BSON stored `null` for the same field (MongoClient default `ignoreUndefined: false`, no override here), so `find()` answered with a value — one write, two answers, from one driver. Both drivers now drop own keys holding `undefined` on the way into storage. `null` is untouched and stays a value. No accept set moves and no exported name changes. On `driver-mongodb` the scope is the insert doors and the returned values; `$set`-shaped patches are left alone, and on `driver-memory` the normalisation is post-merge, so neither driver answers "what does a patch carrying `undefined` mean" — that is a storage-contract question this repair does not reopen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…ver-own-key-undefined
…led seat NOT REVIEWED, NOT VERIFIED. The resumed dispatch was killed before it committed these; recovered so they survive the container. Both files are the card's two outstanding obligations by name: memory-own-key-undefined.test.ts and mongodb-own-key-undefined.test.ts — the second being the driver-mongodb measurement the card asked for explicitly and told the dev not to assume matched driver-memory. No gate was run against them, no ablation exists, and the fail-OPEN consumer sweep the card stars as a separate, more urgent card is not evidenced anywhere in this branch.
…erification exists e87cf37 was written by a worktree-rescue actor at 2026-08-27T02:23:47Z. Its content is correct and is kept as-is; its MESSAGE is not, and it cannot be amended from this seat without a force-push, which the dev contract forbids. So the correction is additive, here. That message says the dispatch "was killed before it committed these", and that "No gate was run against them, no ablation exists, and the fail-OPEN consumer sweep ... is not evidenced anywhere in this branch." The seat was not killed. It was mid-task, in the same session that had already run all three of those things against exactly the content e87cf37 committed, and it continued from there to finish this branch. On that content: * 23 gate families derived by scripts/pm/dispatch-gates.mjs --repo, all green, each exit code captured before any pipe and each quoted from the gate's own verdict line; * two ablation legs with the direction AND the exact failure count written down first — 8 of 10 in driver-memory, 4 of 6 in driver-mongodb, both as predicted, mutation proved on disk with anchored grep -cF counts, restored under `trap ... EXIT INT TERM` with an empty `git diff` verified; * the fail-OPEN consumer sweep, over 110 non-test presence tests under packages/, with a synthetic positive control that fires in the same scan. No fail-open consumer found. Recorded because the branch is squash-merged and `git log` otherwise ends on a commit stating that this branch's verification does not exist. The mechanism defect is filed as #12637, a sub-issue of #12627 (which proposes the rescue step that has no liveness precondition). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 c1fbd4e102658f977ccc3ba5323c184c5029be32 && git checkout c1fbd4e102658f977ccc3ba5323c184c5029be32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c32357ea670f38f503350c9a092fb476be85967 1aad15dba5256aa3f2ea5905bb5d8a6c5db86149 && git checkout -B drift-repro 9c32357ea670f38f503350c9a092fb476be85967 && git merge --no-ff 1aad15dba5256aa3f2ea5905bb5d8a6c5db86149
node scripts/docs-audit/affected-docs.mjs --json 9c32357ea670f38f503350c9a092fb476be85967
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9276
A resumed dispatch. The previous dev seat was killed by a container restart; its work was
recovered and pushed unverified as
263c74db00. This branch inspects that commit ratherthan trusting it, and supplies the three things the recovery note said were missing from
the record: the
driver-mongodbmeasurement, the fail-OPEN consumer sweep, and gate/ablationevidence.
The defect, re-measured on current
origin/mainThe card's reproduction was taken at
2d11ddbe3. Re-taken here against the built dist ofboth JS-backed drivers, with the repair reverted (the ablation leg below):
The shape still stands, unchanged from the card.
driver-mongodbwas measured separately, and it does not match its siblingThe card asked for this explicitly and told the dev not to assume it matched
driver-memory.It does not. This driver splits across two of its own doors:
create()returns the object it built in process, so the field comes back as an own keyholding
undefined. But the MongoClient default isignoreUndefined: falseand this driversets no override, so BSON stores
nullfor that same field and a subsequentfind()answerswith a value. One write, two answers, from one driver — and CEL reads those two answers
differently (
has(record.f)isfalsefor own-key-undefined,truefor a presentnull).So
driver-memoryis consistently wrong anddriver-mongodbis inconsistently wrong. TwoJS-backed drivers in one family, two different defects.
The fork, and why the key is dropped rather than returned as
nullThe card names the repair as "either dropped from the row or returned as
null". Dropped.Both measured consumers already read an own key holding
undefinedas absent — CEL, andmaterializeDeclaredFieldsby documented design. Returningnullwould make it a value,which is the one thing the two states exist to distinguish; it would make the two rows
indistinguishable in the wrong direction. Dropping the key is the reading the platform already
holds, and
driver-memoryalready held it in two of its own places (projectFieldsskipsundefined; the matcher's$exists/$nulltreat it as absent) — the returned row was thelast surface in that driver still claiming the key was present.
The storage-contract boundary was respected, not crossed. Scope is the insert doors and the
values returned.
$set-shaped patches are deliberately untouched, and ondriver-memorythenormalisation is applied POST-merge, so neither driver answers "what does a patch carrying
undefinedmean — clear the field, or leave the prior value standing". That is a question aboutwhat a stored row may contain, it is a maintainer floor, and this repair does not reopen it.
Fail-OPEN consumer sweep — none found
The card stars this: a fail-OPEN consumer on this input class is a different card entirely and
must not wait for this one. The killed seat's sweep died with it, so it was re-run, not presumed.
Scope: a bare
in/hasOwnProperty/Object.hasOwnpresence test against a declared fieldunder
packages/, excludingmaterializeDeclaredFields. 110 non-test hits. Of those, 13take a driver-returned row as their subject (the rest test schema/
fieldsmaps, caller writepayloads, config objects or response envelopes — none of which a driver emits). All 13 were
hand-triaged and every one is fail-closed or neutral on this input class. The sharpest is
engine.tsmaskSecretFields, where presence is the safer branch: an own key holdingundefinedmakes the masker run and normalise the field tonull; absence skips it. Nothinggrants on presence.
fail-open shapes (
if (f in row) return true,if (!(f in row)) return false, and ahasOwnPropertyallow-predicate). All three fire under the same patterns that produced the 110.The control is in the scratchpad, not the repo.
Ablation — direction and exact count predicted in writing first
Predictions were recorded before any result was read; the mutation was proved on disk with
anchored
grep -cFcounts before any verdict was consulted; both legs restored undertrap … EXIT INT TERMwith an emptygit diffverified afterwards.driver-memory—memory-driver.tsreverted toorigin/main, rebuiltdriver-mongodb—mongodb-driver.tsreverted toorigin/main, rebuiltThe two predicted survivors in each file are the ones that should survive: the "filter semantics
are unchanged" and "
nullstays a VALUE" cases indriver-memory, and the pure-BSON mechanism pinplus "
nullis a VALUE" indriver-mongodb.Rebuild, justified by import form. The two
.test.tsfiles import./memory-driver.js/./mongodb-driver.js— intra-package relative specifiers, so vitest resolves them fromsrcandtheir verdicts do not depend on build state. The reproduction probe imports each package's
dist/index.mjs, so its verdict does. Both packages were therefore rebuilt on both legs and thereach was proved with
scripts/ablation-dist-preflight.mjs(--absenton the mutation leg, presenton the restore leg). Corroboration: the ablation was run twice, once with
distablated and oncewith
distcarrying the fix, and produced identical counts — confirming these files' verdicts aresrc-mediated, exactly as the import form predicts.A predicted reversal, reported as observed. The probe prints a line comparing whether
create()and
find()agree about'status'. It compares key presence only, so it readstruein theablated state as well — both doors say "present". The line is insensitive to this defect; the split
lives in the VALUES (
UNDEFINEDvsnull), not in presence. Predicted in advance and recorded hererather than quietly dropped.
What was kept, re-derived, and discarded from
263c74db00Kept — both driver implementations unchanged.
withoutUndefinedOwnKeysin each package,toStoredRecordas the single write-door choke point indriver-memory, the insert-door placementin
driver-mongodb, and the changeset. Inspected line by line and independently confirmed by themeasurements above; the fork it chose is the one the evidence supports.
Re-derived — everything evidential. The reproduction, both legs, on current
origin/main. Thedriver-mongodbmeasurement. The fail-open sweep. The gate union and every gate run. None of itexisted in the record.
Discarded — the central assertion's spelling. The recovered tests stated the contract as
and on this repo's vitest that is vacuous on precisely this input class. Measured in a scratch
spec:
toEqualignores own keys holdingundefined. So the one assertion stating the contract wouldhave stayed green against the unrepaired driver, and both files owed their whole discriminating
power to the key-list and
inassertions riding alongside — which pin a spelling ("the key isdropped"), not the rule ("the two rows are indistinguishable"). Three assertion sites per file
moved to
toStrictEqual, with the measurement written beside the one that matters so it is notrelaxed back. The ablation counts are unchanged by this; what changed is that those failures are now
carried by the contract assertion rather than only by the diagnostics.
Clause ② — judged against
git diff --statObservable behaviour on a shipped surface: YES. What these two published packages return for one
input class changes, and what
driver-mongodbstores for it changes. In-process code passing anexplicitly-
undefinedproperty tocreate/bulkCreate/update/updateMany(or seedinginitialData) no longer sees that key in the returned row, and nonullis written for it inMongoDB.
undefineddoes not survive JSON, so the shape cannot arrive over the wire — reaching itrequires in-process code.
Accept set: does NOT move. No schema, Zod file, refine, validator or public type is touched —
git diff --name-onlyis five files, none of them matching.zod.|schema|refine|validat. Nothingthat parsed before is refused now. And no exported name is added, removed or moved: the whole
diff contains zero added-or-removed lines matching
\bexport\b(git diff origin/main | grep -cE '^[+-][^+-].*\bexport\b'→0).withoutUndefinedOwnKeysis module-local in each package;toStoredRecordis a private method. That is whycheck:api-surface/check:export-origins/check:docsare not owed here — stated with the measurement rather than assumed.Changeset graded minor for both packages, and defended on exactly that split: more than a patch
because observable behaviour on a shipped surface moves, not major because no API, type, exported
name or accept set does.
check:changeset-no-majoris green.Verification
Every exit code below was captured before any pipe, and each verdict is quoted from the gate's
own output line rather than from
$?. Run at final head1aad15dba5; the ratchet families werere-run at that head after the last commit.
driver-memory—pnpm test: 29 files, 833 passed.typecheck: exit 0.driver-mongodb—pnpm test: 19 passed / 5 skipped files, 438 passed / 143 skipped(the skips are the mongod-backed suites).
typecheck: exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackover the real changeset — 20 path-matched plus the convention-triggered test-file families.
23 families run, all green, including
check:driver-conformance,check:engine-double-contract,check:where-matcher,check:cross-package-test-inputs,check:test-source-alias,check:query-options-erasure,check:slot-lookup,check:objectql-double-limit,check:published-files,check:nul-bytesandcheck:type-check-debt(
--re-measure: OK — 31 ledger entries re-measured, 1687 raw tsc errors, none above its recorded number, on a fully built workspace closure).check:driver-memory-censuswas run by hand. The derivation reports it unreachable byconstruction — its population literal is the package name
@objectstack/driver-memory, not apath — so no path derivation can ever name it, on this card or any other, while it is plainly the
gate most specific to this diff. Green: "every declaration is ledgered, every ledger entry is live".
pnpm lint(eslint . --no-inline-config) run in full: exit 0. No narrowing claimed.One thing that happened to this branch, recorded rather than hidden
At
2026-08-27T02:23:47Za worktree-rescue actor committed this seat's working tree ase87cf37e86, whose message states the dispatch "was killed" and that "No gate was run againstthem, no ablation exists, and the fail-OPEN consumer sweep … is not evidenced anywhere in this
branch."
The seat was not killed — it was mid-task, and had already run all three of those things against
exactly the content that commit captured. Its content is correct and is kept as-is; the message
cannot be amended from this seat without a force-push, which the dev contract forbids, so the
correction is additive in
1aad15dba5. Not reverted, not rewritten — another actor's write is notthis seat's to undo. The mechanism defect is filed as #12637, a sub-issue of #12627 (which proposes
the rescue step that currently has no liveness precondition, and whose remedy this is the inverse
failure of).
Generated by Claude Code