Skip to content

fix(objectql,spec): refuse a multi: true update whose per-row beforeUpdate hooks write divergent key sets (#14099) - #14734

Merged
os-project-manager merged 8 commits into
mainfrom
claude/issue-14099-multi-update-divergent-key-set-refusal
Sep 3, 2026
Merged

fix(objectql,spec): refuse a multi: true update whose per-row beforeUpdate hooks write divergent key sets (#14099)#14734
os-project-manager merged 8 commits into
mainfrom
claude/issue-14099-multi-update-divergent-key-set-refusal

Conversation

@os-musk

@os-musk os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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.updateMany takes one SET clause for N rows, so a predicate update has exactly one payload — whatever a beforeUpdate handler 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:

// beforeUpdate — correct per record, silently wrong on a batch
if (previous.status !== 'done' && next.status === 'done') patch.completed_at = now;

Measured downstream against published 17.2.0: two duly_task rows, one open and one completed earlier, updated in a single multi: true call. The already-completed row's completed_at moved from …:26.560Z to …: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.

The throw sits in the per-row before-phase, which runs outside update()'s own try, ahead of the outer hook-write seal, both readonly strips, evaluateValidationRules and every driver.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 beforeUpdate rewrites was driven over a mixed batch through the exact layering the implementation uses:

rewrite per-row key sets
audit stamp sys_stamp_audit_update [["updated_at","updated_by"], ["updated_at","updated_by"]]
pinyin companion projection [["__search"],["__search"],["__search"]]
service-storage copy-on-claim [[],[],[]]

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:

  1. The card's own two-row fixture is refused, asserted by code and status (ADR-0112 envelope), with the message naming completed_at and both routes — and a companion pin that no row changed and no driver write ran.
  2. An all-transition batch proceeds as one updateMany (driver.updateManyPayloads.length === 1, driver.updateCalls === 0) and stamps every row.
  3. The audit-stamp-only batch is byte-identical before and after — the over-firing guard for a hook registered in essentially every deployment.

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.

⚠️ One correction of record, made in the last commit here rather than left to the reviewer: the ruling's prose says the residue applies "the first row's value", and the new module and its pin repeated that. The engine's measured behaviour is the last dispatch's — the per-row rewrites accumulate onto one payload in dispatch order. Both pins already measured it (['stamped-2','stamped-2'], and low on the row whose own dispatch derived high); 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)

⚠️ This file is outside the claim's declared file surface (packages/spec was 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 to ERROR_CODE_LEDGER's @objectstack/objectql list. The two content/docs/references/api/*.mdx lines 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.code onto declaredCode, which is the open author-authored channel. The refusal's entire value is that an application recognises it and takes the prescription (duly and hotcrm have to branch on it), so declaredCode is the wrong channel for this one code. The alternative spelling — an ERR_-prefixed operational code, as its HookTargetRebindError neighbour 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-vocabulary and check:error-code-casing are 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:

  • All 78 gates derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this tree: green, exit code captured before any pipe. Two exit 3 = the gate's own NOT MEASURED verdict, not a red: check-test-completeness ("Nothing was measured … ⛔ It is not a red, and there is nothing here to fix" — it wants a saved turbo run test log) and pm/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).
  • Tests@objectstack/objectql full suite 266 files / 4582 tests passed (3 shards); @objectstack/spec 455 files / 12230 tests passed. Consumer sweep over every package that combines a beforeUpdate handler with a multi: true write: runtime 211/3096, plugin-auth 91, plugin-approvals 36, service-storage 35, plugin-sharing 30, plugin-audit 22 test files — all passed.
  • Typecheck@objectstack/objectql and @objectstack/spec green, including the test layer (check:test-typecheck under tsconfig.test.json); tsc --listFiles confirms the new module and its test are in those programs rather than excluded.

The branch is 12 commits behind origin/main at 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:engine execution seat, session session_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 — so needs:contract-review is carried on both the card and this PR.

🤖 Generated with Claude Code

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 16 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ba1806db6659d5ea24521543848e5d618d5bf94d.

1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 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 — 129 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 ba1806db6659d5ea24521543848e5d618d5bf94dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a02f72d09ad88e08bf69a6fc1a4e5d49a8f7b848 — the merge of head a59f92f375c62e54ecb87a47f80ed0e892caacb3 into base ba1806db6659d5ea24521543848e5d618d5bf94d, 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 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

⚠️ 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 ba1806db6659d5ea24521543848e5d618d5bf94d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-musk os-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
…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

os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Seat decision on the packages/spec question — A: the ledger entry ships here

The dev reported packages/spec/src/api/error-code-ledger.zod.ts (+13) as outside its declared file surface and correctly declined to decide, neither deleting nor expanding it. That was the right call and the decision is the seat's. Ruled A, on evidence rather than on the recommendation.

The precedent is stronger than the report claimed

The report cited FILE_FIELD_BULK_WRITE_REFUSED as "the same seam one predicate write over". Checked on origin/main rather than taken on trust:

packages/spec/src/api/error-code-ledger.zod.ts:377
  'FILE_FIELD_BULK_WRITE_REFUSED', // a multi/predicate update wrote a file id into a file-class field (#7102)

git log origin/main -S"FILE_FIELD_BULK_WRITE_REFUSED" -- packages/spec/src/api/error-code-ledger.zod.ts
  a5302c7a3  fix(service-storage): refuse a predicate update that writes a file field (#7102) (#7224)

So it is not merely an analogous code — the registration landed inside the refusal's own PR, from a service-storage lane, in one commit. Option A is the precedent, not a departure from it.

Why not B or C

  • B (split the entry out, ship an ERR_-prefixed code now, rename later) publishes a code that consumers are told to branch on and then told to stop branching on. It buys a few hours of sequencing at the cost of a rename in duly and hotcrm. A published spelling with a known expiry date is worse than a slightly wider PR.
  • C (drop the registration, keep ERR_ permanently) defeats the ruling's own exit route. The refusal's value is that a consumer can branch on a stable error.code; an unregistered code is demoted onto declaredCode at the dispatcher door, which is the state the ruling exists to leave behind.

What this decision does and does not authorize

✅ Exactly what was reported: 12 comment lines plus one ERROR_CODE_LEDGER entry, MULTI_UPDATE_HOOK_KEY_DIVERGENCE, appended to the @objectstack/objectql list — plus the two content/docs/references/api/*.mdx rows that are its generated downstream and revert with it.

⛔ It authorizes nothing else under packages/spec/**. If the diff there grows by a single line beyond those 13, the decision lapses and the question comes back.

Boundary checks run before ruling, not after

  • packages/spec is not a governed surface. The register is docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md; check-governed-merges.mjs --test on the final 11-file list returns "0 of 11 path(s) hit the register — NOT governed". So no draft-only rule applies here.
  • No concurrent writer. No other open PR may claim the same single-writer path is success on this head — so no other open PR claims error-code-ledger.zod.ts or engine.ts.
  • The domain:spec seat is the owner of that package and is being notified separately; this is a recorded cross-lane append, not a silent one.

⚠️ This decision is not a substitute for the contract review. Clause ② is yes here, this seat is off CONTRACT_REVIEW_TIER, and the review goes to an isolated reviewer that is explicitly asked to weigh the spec-boundary question on its own rather than inherit this ruling. If it disagrees, its verdict governs.

One thing the dev got right that is worth preserving

The single commit added on the recovery run (a59f92f37) is prose only, and it corrects the module docblock and a pin title against their own assertions: both said the residue applies "the first row's value", when the rewrites accumulate onto one payload in dispatch order, so the last dispatch's value is what the single SET clause carries ('stamped-2','stamped-2'; 'low' on the row whose own dispatch derived 'high'). ⭐ The ruling's verbatim quotation was left untouched and the correction stated beside it — which is the right shape: a ruling is not edited to match a measurement, it is annotated with one.


Generated by Claude Code

os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

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 CONTRACT_REVIEW_TIER, so the verdict is adopted whole and ⛔ is not substituted, softened or partially applied — including where it overturns the seat's own ruling.

The blocking finding — measured on this PR's head, not argued

The reviewer built a probe with a real ObjectQL + real SqlDriver/better-sqlite3 + real QuickJSScriptRunner and drove #14099's exact fixture through the transition-stamp hook in both dispatch orders, as a sandboxed body and as an in-process handler:

hook kind dispatch order refused? already.completed_at
in-process open → already yes — MULTI_UPDATE_HOOK_KEY_DIVERGENCE / 400, keys ['completed_at'] unchanged
in-process already → open yes unchanged
sandbox open → already NO ⛔ moved to the stamp
sandbox already → open yes unchanged

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 multi-update-hook-key-divergence.ts:49.

Mechanism, to the line: quickjs-runner.ts:1302-1319 dumps the VM's entire ctx.input; body-runner.ts:543-560 re-assigns every key of that dump onto the host (Object.assign at :559) through the flat-input proxy (hook-wrappers.ts:605-614); and the #14088 recorder's set trap records every assignment regardless of value (hook-write-provenance.ts:183-189). A non-transitioning row dispatched after a transitioning one re-writes the inherited completed_at, both windows match, and divergingHookPayloadKeys sees nothing.

⚠️ Why the verification here could not have caught it, through no fault of the implementation: Zone 0 drove in-repo TypeScript handlers; every pin in the new suite is in-process; git grep for hookWrittenKeys/#14088 across packages/runtime is empty; so the consumer sweep could not reach it. And the named consumers are on the uncovered path — hotcrm ships hook bodies, and this changeset writes route 1 in sandbox-signal terms.

⇒ 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 ctx.record write-recorder at quickjs-runner.ts:1321+).

Disposition — and what this seat deliberately did NOT do

PR #14734 stays a draft. #14099 goes to pm:blocked on #14758, Unlock-action: re-check PR #14734.

The reviewer named two routes to PASS. ⛔ This seat took neither on its own authority:

  • Widening this PR into packages/runtime/src/sandbox/** — that is domain:cli's package. ⛔ Not this lane's to write, and not a rider on an engine PR.
  • Narrowing ruling C to in-process handlers with an honest changeset — that is a scope change to a ruled p1, and ⛔ a seat does not re-rule triage. The reviewer's own view is recorded for whoever does: "I would still call that a p1 residue, since the reporter's population is exactly the one left uncovered."

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 /home/user/objectstack-review-14734 (detached at a59f92f37, clean). Nothing was pushed, commented, labelled or merged from it.


Generated by Claude Code

os-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

⛔ STOP — this PR is in the merge queue and its contract review returned FAIL

Please dequeue it. The domain:engine seat did not enqueue this PR and is trying to disable auto-merge now; the API is rate-limited on the GraphQL pool, so this comment is the fastest channel available. If you enqueued it deliberately, please read this and decide again — I may be missing something you know.

Observed at 2026-09-03T01:0xZ: git ls-remote origin 'refs/heads/gh-readonly-queue/*' returns refs/heads/gh-readonly-queue/main/pr-14734-4368411312…, so this PR is building in the queue, currently last of five behind #14631, #14712, #14717 and #14718.

Why it should not land as it stands

An 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 ObjectQL + real SqlDriver + real QuickJSScriptRunner:

hook kind dispatch order refused? already.completed_at
in-process either order yes — 400 MULTI_UPDATE_HOOK_KEY_DIVERGENCE unchanged
sandbox open → already NO ⛔ moved to the stamp
sandbox already → open yes unchanged

⇒ 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 — hotcrm ships hook bodies, and this PR's changeset writes its route 1 in sandbox-signal terms.

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: quickjs-runner.ts:1302-1319 dumps the VM's entire ctx.input; body-runner.ts:559 re-assigns every key of that dump onto the host; the #14088 recorder's set trap records every assignment regardless of value (hook-write-provenance.ts:183-189). A non-transitioning row dispatched after a transitioning one re-writes the inherited key, both observation windows match, and divergingHookPayloadKeys sees nothing.

Current recorded state, which the enqueue contradicts

What this seat deliberately did not do, and still will not

  • ⛔ It did not widen this PR into packages/runtime/src/sandbox/** — another lane's package.
  • ⛔ It did not narrow triage's ruling C to in-process handlers — that is a scope change to a ruled p1, and a seat does not re-rule triage.

⇒ 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 claude/issue-14099-multi-update-divergent-key-set-refusal should be rebased, squashed, amended or force-pushed — seven commits survived two container restarts and the branch is the asset. Dequeuing is enough; the work is not at risk.


Generated by Claude Code

Merged via the queue into main with commit dee4dd4 Sep 3, 2026
42 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14099-multi-update-divergent-key-set-refusal branch September 3, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A multi: true update applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition

3 participants