Repository navigation
A multi: true update applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099
Description
Activity
os-project-manager commented
on Sep 2, 2026 CollaboratorMore actionsMaintainer ruling recorded — C: enforce ADR-0058 Addendum II D3 — a
multi: trueupdate whose hooks write divergent key sets across rows is refused loudly; identical key sets stay oneupdateManyDirector seat (objectstack #12708), summon #10, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #11 in which this card was item 5 with the recommendation C (fallback D; A only if the maintainer chose to overturn Addendum II D3; B not viable), the same recommendation the
domain:engineseat attached. Verbatim reply: 「#13564 转维护者处理;其他同意」 — "其他同意" covers this card, so C is adopted as recommended. Addendum II D3 (2026-08-06) stands: the batch payload stays batch-scoped and the engine never splits its own write.Ruled: C. On a
multi: trueupdate the engine keeps dispatching the before-phase hooks per row with the per-row pre-image and records, per row, the set of payload keys the hook chain assigned (the #14088 recorder,recordHookPayloadWrites, already armed on the update path). If the recorded key sets differ between any two rows, the whole batch is refused before any write, with a structured error envelope that names the object, the diverging keys and the prescription: write per row from inside the hook viactx.api(route 2, PR #12217, in the next release) or issue by-id updates. If every row's key set is identical, the batch proceeds as oneupdateManywith the batch payload, exactly as D3 says. The criterion is the key set, never the values, so the audit stamp's per-row clock reads cannot make an honest batch non-deterministic.Not taken: A (per-row writes when a hook is bound; overturns D3 and is irreversible), B (refuse any hook payload mutation on multi; kills legitimate row-invariant rewrites), D (lint and docs only; the silent corruption the card measured stays).
Blind spot, named and carried, not hidden. A hook that writes the same key on every row but with per-row values (a per-row derived priority, say) still passes the key-set test and still applies the first row's value to all rows. That is D3's cost by design; the refusal envelope's prescription is the exit for it, and the engine seat files it as its own finding with a measured instance rather than widening this card.
Execution:
domain:enginelane, M. First step, before the code: measure the three in-repobeforeUpdaterewrites (audit stamp, pinyin projection, copy-on-claim) under the key-set criterion on a mixed batch; any divergence there breaks C's premise and returns to the inbox. A published path's accept set narrows ⇒ Clause-② yes,CONTRACT_REVIEW_TIER;@objectstack/objectqlchangeset with a BREAKING banner naming the refusal and route 2; ships in the same release as PR #12217 so the prescription is actionable the day the refusal appears. Pins: the card's own two-row fixture (one open, one already done) is refused with the envelope namingcompleted_at; a batch of rows that all transition proceeds as oneupdateManyand stamps them all; the audit-stamp-only batch is byte-identical before and after. hotcrm and duly are pointed at route 2 in the changeset text.State transition, same stroke:
needs-user-decision→pm:queue;priority:p1,bugretained. Ledger: objectstack director seat post #12708, summon #10.
Generated by Claude Code
os-project-manager commented
on Sep 2, 2026 CollaboratorMore actionsContract review — PR #14734 at head
a59f92f37— VERDICT: PASSVERDICT: PASS Implemented-by: session_0112hMx9hjJ9BgB28X97DS68 Reviewed-by: session_01ShyhexkB2d1AeRZ85tgAAeDirector seat (objectstack #12708), summon #10, 2026-09-02. Reviewer served at
claude-fable-5-1(get_session, this summon; constantCONTRACT_REVIEW_TIERread as the floor per the round-start marker 5511327941). Reviewed the diff against merge base3c1bbd2a8directly (engine, provenance, new module, index, spec ledger, changeset, both test files), not the PR body's account of it. Reason the director seat reviews: the engine seat is off tier (claude-opus-5) and the fable entitlement it would have used for an isolated reviewer is exhausted (14333#issuecomment-5517569335); this seat is on tier and is not the implementing session.① Derived judgments — the accept-set and public-surface changes, named and judged
multi: trueupdate: divergent per-row hook key sets ⇒ the batch is refused before any write. Correct against ruling C (5511804838). ThestripReadonlyFieldsusesObject.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 is armed once more, nested overupdate()'s batch recording (writes through the inner view land on the outer view, so the read-only strips still see every hook write), onecloseWindow()per row, the diverging set isunion \ intersectionover all rows (order-independent, names every offending key), the throw sits in the per-row before phase outsideupdate()'stry, ahead of the outer seal, both readonly strips, validation and everydriver.updateMany. Pinned: refused withcode+status, no driver write ran, the already-done row'scompleted_atunchanged.- Key set, never values. Correct and pinned both ways: same key with per-row values proceeds (the audit stamp's per-row clock), and the byte-identical audit-stamp-only batch. The ruling's blind spot (same key, per-row values applies one dispatch's value to every row) is pinned as the residue and filed as A
multi: truehook that writes the SAME key with per-row VALUES still applies one row's value to every matched row — the residue #14099's key-set refusal deliberately leaves open #14744; the prose correction "first row" → "last dispatch" is the engine's measured behaviour (rewrites accumulate in dispatch order) and changes nothing in the verdict. - Abstain when a hook replaced the payload (no attributable record) — the same fail-safe
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 chose; pinned. Judged correct: a verdict on windows that describe a discarded payload would be fabricated. - Scope: by-id update and predicate delete untouched; pinned.
- Public surface: four new exports from
@objectstack/objectql(MultiUpdateHookKeyDivergenceError, its_CODE,_STATUS,divergingHookPayloadKeys);closeWindow()onHookWriteRecording, which is not on the package's published entry (the provenance module is internal), so no consumer-implemented interface widens. OneERROR_CODE_LEDGERentry inpackages/spec— see ③.
② Semver
@objectstack/objectql: minorand@objectstack/spec: minorwith a BREAKING banner, under the repo's launch-window convention (check:changeset-no-majorrefuses majors) — exactly what the ruling ordered. ADR-0087 dispositionnot-required (no-migration-prescription)is right: no authorable key moves; the artifact is hook body code. The migration prose names both routes and the release coupling with PR #12217 (route 2 lands in the same release as this refusal).③ Boundary flags
- The
packages/specledger entry (+13, one code) outside the claim's declared surface — open question A/B/C. Ruled here: A, keep it. The refusal's prescription depends on the application branching on a stableerror.code; an unregistered code is demoted todeclaredCodeat the dispatcher door, which is the wrong channel for the one code duly and hotcrm must branch on.FILE_FIELD_BULK_WRITE_REFUSEDis the standing precedent on the same seam. The three error-code gates are green with it. The spec seat is informed by this comment; no separate PR. - Zone 0 precondition (the three in-repo rewrites are row-invariant under the key-set criterion) was measured before implementation with a positive control (5515903927). Accepted.
- Branch is 12 commits behind
origin/main; the intervening commits touch none of these paths; the merge queue merges on landing.
CI: every check run on
a59f92f37issuccessorskipped. Governed-surface test: none of the 11 paths is governed ⇒ ordinary queue landing.Disposition:
needs:contract-reviewcleared on this card and on PR #14734 in this stroke (provenance: the 2026-08-31 in-seat release ruling; reviewer on tier, independent of the implementer); PR flipped to ready with auto-merge (squash).pm:dispatchedstays until the merge closes the card throughFixes #14099.
Generated by Claude Code
- added a commit that references this issue
on Sep 4, 2026 - added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 10, 2026 - added a commit that references this issue
on Sep 17, 2026
Found while building an ObjectStack application in
objectstack-ai/dulyagainst published@objectstack/*17.2.0. Filed here because no application can fix it.Blocked-by: #14758
Unlock-action: re-check PR #14734
The defect
A
beforeUpdatehook is documented and used as a per-record seam. On amulti: trueupdate it is not one:driver.updateManytakes a single SET clause, so whatever the hook writes into the payload for one row is applied to every matched row.The common shape this breaks is the transition stamp — the standard way to record "when did this reach that state":
Correct per record. On a batch it stamps rows that never transitioned.
Measured
duly_taskhas areadonlycompleted_atstamped by abeforeUpdatehook on the transition intodone. Two rows, one open and one completed earlier, updated in a single call:The already-done row's
completed_atmoved — from…:26.560Zto…:26.571Z. It did not transition. Nothing errored.Why it is worse than a cosmetic timestamp
In the application that found it,
completed_atis what every on-time measure reads. A task completed comfortably before its deadline, swept up in a later batch, silently acquires a completion instant that can fall past its due date — turning a compliant record into a breach in the metric, with no error, no audit entry, and nothing in the data that shows it happened. The row looks exactly like one that really was completed late.Any object with a state-entry timestamp has the same exposure:
approved_at,closed_at,shipped_at,first_responded_at.Why the application cannot fix it
Related, and possibly the same root
objectstack-ai/duly's #3 measured that an unscoped predicate write dispatches the hook once for the whole operation with no pre-image, so it stamps nothing at all. Combined with this report,beforeUpdatehas three different contracts depending on the write path — by-id (pre-image present, per record), scoped multi (one payload, many rows), unscoped predicate (no pre-image). That divergence is worth resolving as one question rather than three.Suggested direction
Either make the batch path genuinely per-record when a hook is bound to the object (splitting the SET clause, or falling back to per-row writes), or make it refuse loudly — a hook that mutates the payload on a
multiupdate is a correctness hazard the engine can detect and reject at dispatch, which is far better than applying it. Silently applying one row's derived value to N rows is the one option that cannot be reasoned about from the application side.Application-side tracking:
objectstack-ai/duly#39, which pins the behaviour in both directions.Unassigned and untriaged, per the single-producer rule for
domain:*.