Skip to content

fix(drivers): a declared field written as an explicit undefined is indistinguishable from one never written - #12641

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-9276-driver-own-key-undefined
Aug 27, 2026
Merged

fix(drivers): a declared field written as an explicit undefined is indistinguishable from one never written#12641
os-warren merged 4 commits into
mainfrom
claude/issue-9276-driver-own-key-undefined

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 rather
than trusting it, and supplies the three things the recovery note said were missing from
the record: the driver-mongodb measurement, the fail-OPEN consumer sweep, and gate/ablation
evidence.

The defect, re-measured on current origin/main

The card's reproduction was taken at 2d11ddbe3. Re-taken here against the built dist of
both JS-backed drivers, with the repair reverted (the ablation leg below):

driver-memory
create({id:'a1', title:'t', status: undefined}) -> find()
own keys : [ id, title, status, created_at, updated_at ]
'status' in row: true row.status : UNDEFINED
create({id:'a2', title:'t2'}) -> find()
own keys : [ id, title, created_at, updated_at ]
'status' in row: false
>>> INDISTINGUISHABLE: false

The shape still stands, unchanged from the card.

driver-mongodb was measured separately, and it does not match its sibling

The 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:

driver-mongodb (repair reverted)
create(...) -> RETURNED row : 'status' in row: true row.status : UNDEFINED
what a real find() reads back (BSON round trip of the doc that was inserted)
-> 'status' in row: true row.status : null

create() returns the object it built in process, so the field comes back as an own key
holding undefined. But the MongoClient default is ignoreUndefined: false and this driver
sets no override, so BSON stores null for that same field and a subsequent find() answers
with a value. One write, two answers, from one driver — and CEL reads those two answers
differently (has(record.f) is false for own-key-undefined, true for a present null).

So driver-memory is consistently wrong and driver-mongodb is inconsistently wrong. Two
JS-backed drivers in one family, two different defects.

The fork, and why the key is dropped rather than returned as null

The card names the repair as "either dropped from the row or returned as null". Dropped.
Both measured consumers already read an own key holding undefined as absent — CEL, and
materializeDeclaredFields by documented design. Returning null would 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-memory already held it in two of its own places (projectFields skips
undefined; the matcher's $exists / $null treat it as absent) — the returned row was the
last 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 on driver-memory the
normalisation is applied POST-merge, so neither driver answers "what does a patch carrying
undefined mean — clear the field, or leave the prior value standing"
. That is a question about
what 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.hasOwn presence test against a declared field
under packages/, excluding materializeDeclaredFields. 110 non-test hits. Of those, 13
take a driver-returned row as their subject (the rest test schema/fields maps, caller write
payloads, 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 holding
undefined makes the masker run and normalise the field to null; absence skips it. Nothing
grants on presence.

⚠️ A zero-hit is not a reading, so the scan carries a synthetic positive control — three
fail-open shapes (if (f in row) return true, if (!(f in row)) return false, and a
hasOwnProperty allow-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 -cF counts before any verdict was consulted; both legs restored under
trap … EXIT INT TERM with an empty git diff verified afterwards.

legpredictedobserved
driver-memorymemory-driver.ts reverted to origin/main, rebuiltRED, 8 of 10RED, 8 of 10
driver-mongodbmongodb-driver.ts reverted to origin/main, rebuiltRED, 4 of 6RED, 4 of 6

The two predicted survivors in each file are the ones that should survive: the "filter semantics
are unchanged" and "null stays a VALUE" cases in driver-memory, and the pure-BSON mechanism pin
plus "null is a VALUE" in driver-mongodb.

Rebuild, justified by import form. The two .test.ts files import ./memory-driver.js /
./mongodb-driver.js — intra-package relative specifiers, so vitest resolves them from src and
their 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 the
reach was proved with scripts/ablation-dist-preflight.mjs (--absent on the mutation leg, present
on the restore leg). Corroboration: the ablation was run twice, once with dist ablated and once
with dist carrying the fix, and produced identical counts — confirming these files' verdicts are
src-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 reads true in the
ablated state as well — both doors say "present". The line is insensitive to this defect; the split
lives in the VALUES (UNDEFINED vs null), not in presence. Predicted in advance and recorded here
rather than quietly dropped.

What was kept, re-derived, and discarded from 263c74db00

Kept — both driver implementations unchanged. withoutUndefinedOwnKeys in each package,
toStoredRecord as the single write-door choke point in driver-memory, the insert-door placement
in driver-mongodb, and the changeset. Inspected line by line and independently confirmed by the
measurements above; the fork it chose is the one the evidence supports.

Re-derived — everything evidential. The reproduction, both legs, on current origin/main. The
driver-mongodb measurement. The fail-open sweep. The gate union and every gate run. None of it
existed in the record.

Discarded — the central assertion's spelling. The recovered tests stated the contract as

expect(comparable(written)).toEqual(comparable(neverWritten));

and on this repo's vitest that is vacuous on precisely this input class. Measured in a scratch
spec:

expect({ a: 1, status: undefined }).toEqual({ a: 1 }) -> PASSES
expect({ a: 1, status: undefined }).toStrictEqual({ a: 1 }) -> fails

toEqual ignores own keys holding undefined. So the one assertion stating the contract would
have stayed green against the unrepaired driver, and both files owed their whole discriminating
power to the key-list and in assertions riding alongside — which pin a spelling ("the key is
dropped"), 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 not
relaxed 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 --stat

Observable behaviour on a shipped surface: YES. What these two published packages return for one
input class changes, and what driver-mongodb stores for it changes. In-process code passing an
explicitly-undefined property to create / bulkCreate / update / updateMany (or seeding
initialData) no longer sees that key in the returned row, and no null is written for it in
MongoDB. undefined does not survive JSON, so the shape cannot arrive over the wire — reaching it
requires in-process code.

Accept set: does NOT move. No schema, Zod file, refine, validator or public type is touched —
git diff --name-only is five files, none of them matching .zod.|schema|refine|validat. Nothing
that 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). withoutUndefinedOwnKeys is module-local in each package;
toStoredRecord is a private method. That is why check:api-surface / check:export-origins /
check:docs are 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-major is 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 head 1aad15dba5; the ratchet families were
re-run at that head after the last commit.

  • driver-memorypnpm test: 29 files, 833 passed. typecheck: exit 0.
  • driver-mongodbpnpm test: 19 passed / 5 skipped files, 438 passed / 143 skipped
    (the skips are the mongod-backed suites). typecheck: exit 0.
  • Gate union, derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    over 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-bytes and check: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-census was run by hand. The derivation reports it unreachable by
    construction
    — its population literal is the package name@objectstack/driver-memory, not a
    path — 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".
  • Full-repo 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:47Z a worktree-rescue actor committed this seat's working tree as
e87cf37e86, whose message states the dispatch "was killed" 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, 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 not
this 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

…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
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-memory, @objectstack/driver-mongodb, touching 11 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via createMany (sdk), data.createMany (sdk), data.updateMany (sdk), updateMany (sdk))
  • content/docs/api/data-api.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk), /:object/createMany (route), /:object/updateMany (route))
  • content/docs/automation/webhooks.mdx(via updateMany (symbol), updateMany (sdk))
  • content/docs/data-modeling/drivers.mdx(via InMemoryDriver (symbol))
  • content/docs/permissions/authentication.mdx(via InMemoryDriver (symbol))
  • content/docs/plugins/packages.mdx(via InMemoryDriver (symbol))
  • content/docs/protocol/kernel/http-protocol.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk))
  • content/docs/protocol/knowledge.mdx(via updateMany (symbol), updateMany (sdk))
  • content/docs/protocol/objectql/query-syntax.mdx(via InMemoryDriver (symbol))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via InMemoryDriver (symbol), updateMany (symbol), createMany (sdk), updateMany (sdk), /:object/createMany (route), /:object/updateMany (route))
  • content/docs/releases/v16.mdx(via InMemoryDriver (symbol), updateMany (symbol), createMany (sdk), updateMany (sdk))
  • content/docs/releases/v17.mdx(via InMemoryDriver (symbol), updateMany (symbol), updateMany (sdk))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 8 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 9c32357ea670f38f503350c9a092fb476be85967packageMentionDocs.

Which tree this was computed on

This run read content/docs from c1fbd4e102658f977ccc3ba5323c184c5029be32 — the merge of head 1aad15dba5256aa3f2ea5905bb5d8a6c5db86149 into base 9c32357ea670f38f503350c9a092fb476be85967, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9c32357ea670f38f503350c9a092fb476be85967 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-warren
os-warren marked this pull request as ready for review August 27, 2026 03:28
@os-warren
os-warren added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit aa3f9baAug 27, 2026
34 checks passed
@os-warren
os-warren deleted the claude/issue-9276-driver-own-key-undefined branch August 27, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[drivers] a driver returning a declared field as an own key holding undefined is a storage-contract defect — Option C from #8489

2 participants

@os-warren@claude