fix(objectql,spec): refuse a multi: true update whose per-row beforeUpdate hooks write divergent key sets (#14099) - #14734
Conversation
…objectql-double-limit)
…load identity pin holds unchanged
📓 Docs Drift CheckThis PR changes 2 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 129 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 a02f72d09ad88e08bf69a6fc1a4e5d49a8f7b848 && git checkout a02f72d09ad88e08bf69a6fc1a4e5d49a8f7b848
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba1806db6659d5ea24521543848e5d618d5bf94d a59f92f375c62e54ecb87a47f80ed0e892caacb3 && git checkout -B drift-repro ba1806db6659d5ea24521543848e5d618d5bf94d && git merge --no-ff a59f92f375c62e54ecb87a47f80ed0e892caacb3
node scripts/docs-audit/affected-docs.mjs --json ba1806db6659d5ea24521543848e5d618d5bf94d
|
…ence blind spot The new module's "What is NOT covered" paragraph said the residue applies "the first row's value to every matched row", and the residue pin's own title said "row 1's value". Both are contradicted by the assertions directly beneath them: per-row rewrites accumulate onto ONE payload in dispatch order, so the LAST assignment to a key is what the single SET clause carries. The pins already measure it — `bulk-write-per-row-hooks.test.ts`'s D3 case reads `['stamped-2','stamped-2']`, and the residue pin reads `low` on the row whose own dispatch derived `high`. Prose only; no behaviour, no assertion and no exported symbol changes. The ruling's verbatim quotation is untouched — the correction is stated beside it, naming what the ruling said and what the engine does, because a docblock that names the wrong row sends the next author hunting for a per-row seam that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Seat decision on the
|
Contract review — FAIL, adopted verbatim. ⛔ This PR is not landing.Performed by an isolated reviewer at the configured tier, in its own worktree, with no access to this seat's reasoning. This seat is off The blocking finding — measured on this PR's head, not arguedThe reviewer built a probe with a real
⇒ On the shipped hook-body path, in one of two row orders, this PR's refusal does not fire and #14099's original corruption still lands. The outcome depends on the driver's row order — exactly the "failure direction nobody can debug" this PR's own docblock warns against at Mechanism, to the line:
⇒ Filed as #14758, with the reproduction, the mechanism and a suggested direction (carry back only the keys the body assigned or deleted; the in-tree pattern is the Disposition — and what this seat deliberately did NOT doPR #14734 stays a draft. #14099 goes to The reviewer named two routes to PASS. ⛔ This seat took neither on its own authority:
The engine-side work here is not being discarded and ⛔ nothing should be rebased, squashed or force-pushed. The refusal is correct as far as it reaches; it cannot be true end-to-end until the write-back reports honestly. Where the review agreed, with more evidence than the PR claimed
Advisory findings — recorded, none of which caused the FAIL
F3, F5, F7 and F8 are not yet carded. ⛔ This seat is not grading them; they are recorded here so the next round can file them rather than rediscover them. The reviewer's worktree is left at Generated by Claude Code |
⛔ STOP — this PR is in the merge queue and its contract review returned FAILPlease dequeue it. The Observed at 2026-09-03T01:0xZ: Why it should not land as it standsAn isolated reviewer at the required tier returned FAIL (full verdict in comment 5518180122), on a finding it measured on this PR's own head with a real
⇒ On the shipped hook-body path, in one of two row orders, the refusal does not fire and #14099's original corruption still lands. The named consumers are on exactly that path — The concrete harm of landing it now is not the incomplete fix — it is that the changeset says the transition-stamp hook "is REFUSED", which is false for shipped bodies. That sentence compiles into release notes and tells operators a p1 data-corruption hazard is closed when it is not. Mechanism, to the line: Current recorded state, which the enqueue contradicts
What this seat deliberately did not do, and still will not
⇒ If the intent is to land the in-process half now and track the sandbox half separately, that is a legitimate call — but it needs the changeset's "is REFUSED" claim corrected first, and it is triage's or the maintainer's to make, not something that should happen by enqueue. The reviewer's own note for whoever decides: "I would still call that a p1 residue, since the reporter's population is exactly the one left uncovered." ⛔ Nothing on Generated by Claude Code |
Fixes #14099
Enforces ADR-0058 Addendum II D3 instead of merely stating it. Executes maintainer ruling C (card comment 5511804838; maintainer verbatim, untranslated: 「#13564 转维护者处理,其他同意」 on decision batch 11, adopting recommendation C).
What was wrong
driver.updateManytakes oneSETclause for N rows, so a predicate update has exactly one payload — whatever abeforeUpdatehandler writes for one row was applied to every matched row. The transition stamp is the shape this breaks, and it is the standard way to record when a record entered a state:Measured downstream against published
17.2.0: twoduly_taskrows, one open and one completed earlier, updated in a singlemulti: truecall. The already-completed row'scompleted_atmoved from…:26.560Zto…:26.571Z. It never transitioned, nothing errored, and the corrupted row is byte-for-byte indistinguishable from one genuinely completed late. The whole class is exposed:approved_at,closed_at,shipped_at,first_responded_at.What this changes
D3 still stands: the payload stays batch-scoped and the engine never splits its own write. What is added is the measurement D3 always needed.
stripReadonlyFieldsusesObject.isto tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder (recordHookPayloadWrites) once, nested over the batch-scoped recordingupdate()already holds, and closes one observation window per row (HookWriteRecording.closeWindow()). One recording for the whole loop, not one per row, is a contract point rather than a saving: every per-row context must carry the same payload object, whichbulk-write-per-row-hooks.test.tspins by reference identity.MultiUpdateHookKeyDivergenceError, ADR-0112 codeMULTI_UPDATE_HOOK_KEY_DIVERGENCE, HTTP400, naming the object, the diverging keys (unionminusintersection, so every offending key and order-independent) and both prescriptions.updateManywith the batch payload, exactly as D3 says.sys_stamp_audit_updateis registered on'*'and reads the clock inside the per-record stamp, so an honest batch carries differentupdated_atvalues per row; and a value test re-opensstripReadonlyFieldsusesObject.isto tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088's own row, where a hook writing the value the caller also sent is indistinguishable from a hook that never touched the key.ctx.input.datarather than mutating it,sealreturns no attributable record and the batch abstains rather than being judged — the same fail-safe directionstripReadonlyFieldsusesObject.isto tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 chose for the same known limit.The throw sits in the per-row before-phase, which runs outside
update()'s owntry, ahead of the outer hook-write seal, both readonly strips,evaluateValidationRulesand everydriver.updateMany. Not "after the first row", not "inside a transaction that then rolls back": nothing is written.Zone 0 — the ruling's precondition, measured before any implementation code
Posted on the card as comment 5515903927 (2026-09-02 20:25Z), before the implementation. Each of the three in-repo
beforeUpdaterewrites was driven over a mixed batch through the exact layering the implementation uses:sys_stamp_audit_update[["updated_at","updated_by"], ["updated_at","updated_by"]][["__search"],["__search"],["__search"]][[],[],[]]All identical across rows ⇒ C's premise holds, and none of the platform's own hooks is refused. The reading carries its positive control: the card's own transition-stamping hook over the same mixed batch returned
[["completed_at"],[]]— divergent and detected — so the identical readings are readings rather than a silent instrument.The ruling's three pins
All three are in
packages/objectql/src/multi-update-hook-key-divergence.test.ts:codeandstatus(ADR-0112 envelope), with the message namingcompleted_atand both routes — and a companion pin that no row changed and no driver write ran.updateMany(driver.updateManyPayloads.length === 1,driver.updateCalls === 0) and stamps every row.Known limit, carried openly — filed as #14744
A handler writing the same key on every row with per-row values still passes the key-set test and still applies one dispatch's value to every matched row. That is D3's declared cost; the ruling names it and points at the two routes as the exit.
bulk-write-per-row-hooks.test.ts's D3 case is rewritten to pin exactly that residue (its old fixture stamped the first row only, which this PR now refuses — the fixture changed, never the contract). Filed as #14744 with the measured instance, rather than widened into this card.['stamped-2','stamped-2'], andlowon the row whose own dispatch derivedhigh); only the prose was wrong. The ruling's verbatim quotation is untouched; the correction is stated beside it, naming what the ruling said and what the engine does.Reviewer's attention:
packages/spec/src/api/error-code-ledger.zod.ts(+13)packages/specwas declared ⛔ out) and belongs to a different seat. It is reported, not decided, by the implementing seat.The 13 lines are 12 lines of comment plus one entry —
'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'— appended toERROR_CODE_LEDGER's@objectstack/objectqllist. The twocontent/docs/references/api/*.mdxlines in this diff are its generated downstream (… +291 more→+292 more, plus the code's row); they are not independent edits and revert with it.Whether it can be avoided: the ADR-0112 dispatcher demotes an unregistered code off
error.codeontodeclaredCode, which is the open author-authored channel. The refusal's entire value is that an application recognises it and takes the prescription (dulyandhotcrmhave to branch on it), sodeclaredCodeis the wrong channel for this one code. The alternative spelling — anERR_-prefixed operational code, as itsHookTargetRebindErrorneighbour uses — ships without the spec edit but gives consumers nothing stable to branch on.FILE_FIELD_BULK_WRITE_REFUSED, the same seam one predicate write over, made the same call for the same reason.Can the PR ship without them? Mechanically yes — the engine still refuses and still throws the class;
check:error-code-provenance,check:dispatcher-error-vocabularyandcheck:error-code-casingare green with the entry present, and dropping the entry would require reverting the two generated docs rows with it. What would be lost is the boundary-crossing identity the ruling's prescription depends on. The seat decides; the implementing seat neither expanded nor removed these lines.Verification
Union run on the final tree,
a59f92f37— the whole union was re-run on this head after the last commit, not carried over from the previous one:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson this tree: green, exit code captured before any pipe. Two exit 3 = the gate's ownNOT MEASUREDverdict, not a red:check-test-completeness("Nothing was measured … ⛔ It is not a red, and there is nothing here to fix" — it wants a savedturbo run testlog) andpm/check-half-states(needs a GitHub read channel this seat does not have; REST is 403 for this session).check-system-context-census:OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read— the census re-anchor verified against the final tree by the gate's own verdict line, not by trusting the commit message.check-nul-bytes:OK (scanned 8029 text file(s) … no raw ASCII control bytes).@objectstack/objectqlfull suite266 files / 4582 tests passed(3 shards);@objectstack/spec455 files / 12230 tests passed. Consumer sweep over every package that combines abeforeUpdatehandler with amulti: truewrite: runtime211/3096, plugin-auth91, plugin-approvals36, service-storage35, plugin-sharing30, plugin-audit22test files — all passed.@objectstack/objectqland@objectstack/specgreen, including the test layer (check:test-typecheckundertsconfig.test.json);tsc --listFilesconfirms the new module and its test are in those programs rather than excluded.The branch is 12 commits behind
origin/mainat the time of writing; none of the 13 intervening commits touches any file in this diff, and the four gate scripts that changed there (check-agent-model-declared,check-docs-image-tag,check-self-test-wired,check-whole-set-label-write) declare populations none of these paths is in — so the derived family above is the same one CI will schedule on the merge result.Attribution
Authored by the
domain:engineexecution seat, sessionsession_0112hMx9hjJ9BgB28X97DS68, across two container restarts; commits 1–7 are the recovered work and are deliberately unrebased.Review goes to an isolated reviewer explicitly passed
model: fable(CONTRACT_REVIEW_TIER); ⛔ not reviewable in-seat. Clause-② yes — a published path's accept set narrows — soneeds:contract-reviewis carried on both the card and this PR.🤖 Generated with Claude Code
Generated by Claude Code