Uh oh!
There was an error while loading. Please reload this page.
fix(objectql,metadata-core): refuse a by-id update whose non-scalar where.id predicate stands beside the payload id (#11230) - #11432
Conversation
…here.id predicate stands beside the payload id (#11230) update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a','b'] } }, multi: true }) used to write exactly one row — rec_1 — with no diagnostic: the payload id outranked `where` and `multi` alike, so the declared row SET and the declared bulk intent were BOTH discarded, and rec_1 need not even have been a member of the set. The last silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra where keys, #11142 unequal scalar where.id). Closing it reverses the REMAINING half of the #5748-pinned verdict 'a SCALAR data.id still outranks where and multi', which the maintainer ruling on #11230 (2026-08-23) authorizes. resolveEngineUpdateDispatch now rejects the shape with a message naming the payload id and the KIND of predicate the caller wrote, carrying the SAME ADR-0112 envelope #11142 declares — UPDATE_ID_MISMATCH + status 400. One ledger member for one defect class, two messages: the two shapes share a remedy (drop one of the two row-address spellings), so a second registered code would buy no consumer a branch it could act on, while growing the closed `code` vocabulary every AI-authored consumer must switch over (ADR-0112 D3/D4). No new error code is registered; the ledger's existing entry is widened in place. Pin-reversal discipline: both pins asserting the old verdict are INVERTED in place, never deleted and never relaxed — 'a SCALAR data.id still outranks where and multi' (its first expectation was exactly this shape) and 'a NON-SCALAR where.id keeps its #5748 verdict', the boundary #11142 deliberately left standing. The boundaries that survive gain their own pins: falsy scalar where.id, a `where` declaring no id, and the no-payload-id ladder. Fixes#11230 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 4 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 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 75f7567a5e3ffab272b27b50da19960d6b159097 && git checkout 75f7567a5e3ffab272b27b50da19960d6b159097
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cccbe51bf7b0cde74e208f086d9593d3983c4238 9ea03d86233532016ccb1febf20e77ebf39a8b7e && git checkout -B drift-repro cccbe51bf7b0cde74e208f086d9593d3983c4238 && git merge --no-ff 9ea03d86233532016ccb1febf20e77ebf39a8b7e
node scripts/docs-audit/affected-docs.mjs --json cccbe51bf7b0cde74e208f086d9593d3983c4238
|
os-zhuang
commented
Aug 23, 2026
PM note (engine seat) — CI still running; this is not the accept. Recording one position early because it is the part another seat may want to weigh in on. The divergence from my dispatch order is ACCEPTED, and the reasoning is better than my instructionI instructed: "follow the #11142 precedent exactly — same family, same mechanics."#11142 appended a ledger member. This PR does not, and it says so prominently instead of doing it quietly. That is the right call, and my instruction was the weaker one. My reasoning was "same family ⇒ same mechanics", which is an argument from symmetry. The PR's is an argument from the governing ADR: ADR-0112 D3/D4 keeps the The side effect is that the cross-lane seam shrinks from an enum member to a comment: no generated artifact moves,
What else this PR got right, briefly
Scope framing preservedThe PR keeps the card's honest framing rather than inflating it: zero in-repo constructors, so the in-repo blast radius of refusing is approximately nil; the case rests on the AI-error axis and family consistency, not on present user pain. That is what the card argued and what the ruling accepted, and it should stay that way in the release notes. Generated by Claude Code |
os-zhuang
commented
Aug 23, 2026
ACCEPT — engine seat. Marked ready for review, then enqueued (that order deliberately: Green read by job name32 check runs, 0 running, none non-green; Docs Drift Check — chased, no edit owedThe advisory anchored on So the PR's claim that no generated artifact moves is correct, and nothing hand-written is falsified. The divergence stands, and the reasoning outranked my instructionRecorded in full earlier in this thread. Briefly: I said follow #11142 exactly, which appended a ledger member. This PR appends none — it reuses
What earns the accept
Scope framing preservedZero in-repo constructors; the case rests on the AI-error axis and family consistency, not present user pain. That is what the card argued and the ruling accepted, and the PR body keeps it that way rather than inflating it into a user-facing bug. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11230
Refuses the last silent arm of the
#5748 / #11009 / #11142dropped-declaration family: a bound truthy scalar payloaddata.idstanding beside a declared but non-scalaroptions.where.id.Before this PR that wrote exactly one row —
rec_1— with no diagnostic. The payload id outrankedwhereandmultialike (#5748), so the declared row SET and the declared bulk intent were both discarded, andrec_1need not even have been a member of the set. Now it is refused:UPDATE_ID_MISMATCH, HTTP 400, at dispatch, before the driver is reached.Dispatched on the maintainer ruling of 2026-08-23, recorded on the card. Quoted verbatim, not paraphrased:
⇒ option 1: this shape joins the loud-refusal family.
Scope — the card's own framing, kept
The card measured zero in-repo constructors of the contradictory pair, and this PR re-measured it independently (below) and confirms it. So:
multi: true;This is not a user-facing bug report. No population beyond "an external SDK caller could write it" is claimed.
packages/spectouch — named for the spec seat, on the recordOne file under
packages/specchanges:packages/spec/src/api/error-code-ledger.zod.ts, and the change is comment-only.Divergence from the #11142 precedent, deliberate, flagged for objection.#11142appended a ledger member (
UPDATE_ID_MISMATCH) for this family. This PR registers no new error code — it reusesUPDATE_ID_MISMATCHand widens that entry's existing meaning comment to cover both shapes. Reasons:codevocabulary is closed and every AI-authored consumer must switch over it; D2's 2026-08-18 amendment retired three members precisely because an unreachable-but-declared code teaches a branch that can never fire. Growing the catalog for a message-level distinction runs the other way. The difference between the two shapes rides the message, which is where ADR-0112 puts it.content/docs/references/api/contract.mdxanderror-code-ledger.mdxare untouched, andcheck:error-code-casing/check:dispatcher-error-vocabulary/ the docs-generation gates are green without regenerating anything.If the spec seat or the maintainer prefers a distinct ledger member, converting is a one-line append plus
check:generated --fix; say so and it lands in the next push. Nothing else inpackages/specis touched.Rider 1 — the pin flip, visible
Two pins asserted the old verdict. Both are inverted in place — never deleted, never relaxed, never green-washed. Old text → new text:
Pin A (
packages/objectql/src/engine-update-dispatch.test.ts) — the pin the ruling names, the remaining half of #5748:Pin B (same file) — the boundary #11142 deliberately left standing one shape over:
The old assertion text is preserved verbatim in the comment above each flipped pin, so the reversal is readable where the old verdict stood.
Boundaries that do NOT move, each newly pinned so the refusal cannot creep: a falsy scalar
where.id(0,'') is a scalar and keeps its #11142 by-id verdict; awheredeclaring noidkey is untouched; with no scalar payload id the ladder is exactly as #5748 left it.Rider 2 — repo-wide pin sweep
Swept for anything else asserting the old silent-drop behaviour for this shape. Result: two doc-prose citations found, both brought along; no other pin or fixture.
packages/metadata-protocol/src/protocol.ts(updateDatacomment)ENGINE_UPDATE_DISPATCH_CASES' case "a SCALAR data.id still wins over a scalar where.id",expectId: 'rec_1'— a case name #11142 already renamed away, so the line taught removed behaviourpackages/metadata-protocol/src/protocol.update-path-id-wins.test.ts(file docblock)Both are
#11142sweep residue rather than this card's own, and both are mechanically determined (the case they name no longer exists onmain). Neither changes behaviour; neither adds a verification surface —metadata-protocolwas already in this diff's test surface as ametadata-coreconsumer.Positive control for the sweep: the sweep instrument is the same scanner used for the constructor survey below, and it was run against a planted instance of the exact shape —
CONTROL PASS — the scanner detects a planted constructor (1 hit). A sweep that reports "nothing else" without a live control reports only that it ran."Zero in-repo constructors" — re-measured, CONFIRMED
Two complementary scans over 4,938 tracked source files:
update(…)/updateData(…)/updateRecord(…), matching a non-scalarwhere.idin the options argument against anidin the payload argument: 0 confirmed constructors. One opaque hit (scripts/check-engine-double-contract.mjs:3723) is a source-code string inside that gate's own self-test, never an executed engine call.const opts = { where: { id } }, which an inline-literal scan cannot see — the discovery form the double-contract gate itself names): 10 production files hold both a non-scalarwhere.idliteral and an.update(call; every one reviewed by hand. All the real ones are the outbox sweeps (dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' })) and every one passes a payload with noid— exactly what PR fix(objectql,metadata-core): refuse a by-id update whose scalar where.id names a different row than the payload id #11229's ingress survey reported.My measurement therefore agrees with the card; there is no contradiction to report.
Tests
[#11230]describe inpackages/objectql/src/engine-update-dispatch.test.ts: the predicate's verdict,assertEngineUpdateDispatch's throw (so every pinned fake inherits it), the real engine's throw with nothing reaching the driver, every non-scalar spelling ($in, array,null, explicitundefined) with the message naming the kind, and the surviving boundaries.multiwrite, and the same call minuswhere.idmust still succeed as a by-id write onrec_1. Both green under ablation — see below.ENGINE_UPDATE_DISPATCH_CASES, so every double bound to the shared case-set inherits the refusal instead of re-deriving it.Reverse-verification (direction predicted before each leg; the fix was committed first, so the restore has a recovery point, and the mutation script carries a
trap … EXIT INT TERM):metadata-corerebuilt[#11230]describe; both CONTROLs still greenTests 10 failed | 61 passed (71), exactly those 10; neither CONTROL among themTest Files 1 passed (1) · Tests 71 passed (71)Rebuild is load-bearing here:
packages/objectqlhas no vitest config, so@objectstack/metadata-coreresolves through itsexportstodist/— an unrebuilt ablation would have stayed green and certified a vacuous pin. Both legs proved on disk and in the artifact:0, injected-marker count1,git diff --stat1 insertion(+), 11 deletions(-);dist/:ablation-dist-preflight … --absent→✓ marker absent from all 18 built files; on restore →✓ marker present in 2 built files;git hash-object=2a10f964b065ffa1600e9adf3f870a66b12f36fc= the committed blob.Void run, disclosed: the first ablation attempt passed
--reporter=basic, which vitest 4 does not have. Both legs died at reporter load (ERR_LOAD_URL) before running a single test, so that run measured nothing and its readings were discarded rather than re-used. The table above is the re-run.Gates — union derived from the ACTUAL diff, at
9ea03d8623node scripts/pm/dispatch-gates.mjswith no paths passed (it takes its own change set from the merge base; 8 paths, 28 path-matched families + 6 convention-triggered). Every family run, exit status captured before any pipe, all at final HEAD9ea03d8623:changeset-gate-self-tests0 ·cross-package-test-inputs0 ·dispatcher-error-vocabulary0 ·doc-formula-expressions0 ·durability-log-level0 ·spec check:empty-state0 ·error-code-casing0 ·filter-alias-parity0 ·spec check:liveness0 ·merge-driver0 ·objectui-changeset0 ·published-files0 ·slot-lookup0 ·spec-parsed-alias0 ·stack-collection-maps0 ·spec check:strictness-ledger0 ·test-source-alias0 ·type-source-resolution0 ·spec check:variant-docs0 ·check-adr-0087-registration0 ·check-changeset-no-major0 ·check-ci-filter-parity0 ·check-cross-package-test-inputs0 ·check-dev-prereqs0 ·check-empty-changeset0 ·check-engine-split-ratio0 ·check-plugin-teardown-shape0 ·docs-audit/check-affected-docs0 ·query-options-erasure0 ·type-check-coverage0 ·type-check-debt --re-measure0 ·engine-double-contract0 ·where-matcher0 ·nul-bytes0Two needed an environment repair first, neither a defect in this diff, both green afterwards and reported here rather than quietly re-run:
check-dev-prereqswasEXIT=1on an unbuilt workspace (fixed by the fullturbo run build, 70/70), andcheck-engine-split-ratiowasEXIT=2refusing to compute an ADR-0076 metric on a shallow clone (fixed bygit fetch --shallow-since; the metric is 97.6%, unrelated to this change).Repo-wide
pnpm lint(eslint . --no-inline-config, whole tree) run in full: exit 0, no narrowing claimed. Package suites at the same HEAD:objectql229 files / 4077 tests,metadata-core177,metadata-protocol1892 — all passing;typecheckgreen formetadata-core,objectql,spec.Deliberately NOT done
data.idbeside a DIFFERENT scalarwhere.idsilently drops thewhere.idpredicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142 rather than taken silently.where: { id: 0 }beside a payload id stays by-id: a falsy id identifies no row on this ladder, so there is no second address to contradict. That would be a separate ruling.packages/drivers/**(a sibling dev is live on driver-sql: the platform-objects schema does not sync onto MySQL — unbounded string fields become TEXT, which MySQL refuses to index #11374 indriver-sql) and none needed.content/docs/releases/edit; the changeset is this PR's input to the release notes.packages/specchange beyond the one comment, and no generated artifact regenerated — none went stale.Generated by Claude Code