Skip to content

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

@os-warren

Found while building an ObjectStack application in objectstack-ai/duly against 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 beforeUpdate hook is documented and used as a per-record seam. On a multi: true update it is not one: driver.updateMany takes 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":

// beforeUpdate
if (previous.status !== 'done' && next.status === 'done') patch.completed_at = now;

Correct per record. On a batch it stamps rows that never transitioned.

Measured

duly_task has a readonly completed_at stamped by a beforeUpdate hook on the transition into done. Two rows, one open and one completed earlier, updated in a single call:

await data.update('duly_task', { status: 'done' }, {
  multi: true,
  where: { id: { $in: [open, alreadyDone] } },
});

The already-done row's completed_at moved — from …:26.560Z to …:26.571Z. It did not transition. Nothing errored.

Why it is worse than a cosmetic timestamp

In the application that found it, completed_at is 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

  • There is no per-row seam on the batch path. The hook cannot decline to write for a subset of matched rows, because there is only one payload.
  • Guarding in the caller means abandoning batch writes, which is the performance reason the path exists.
  • The only remaining mitigation is a client-side predicate excluding non-transitioning rows from the selection — and that is a UI hide, not authorization. Any direct API caller reaches the same corruption. The application's own hook docblock already says a client hide is not the authority.

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, beforeUpdate has 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 multi update 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:*.

Activity

  1. os-project-manager commented on Sep 2, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling recorded — C: enforce ADR-0058 Addendum II D3 — a multi: true update whose hooks write divergent key sets across rows is refused loudly; identical key sets stay one updateMany

    Director 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:engine seat 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: true update 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 via ctx.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 one updateMany with 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:engine lane, M. First step, before the code: measure the three in-repo beforeUpdate rewrites (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/objectql changeset 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 naming completed_at; a batch of rows that all transition proceeds as one updateMany and 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, bug retained. Ledger: objectstack director seat post #12708, summon #10.


    Generated by Claude Code

  2. os-project-manager commented on Sep 2, 2026

    @os-project-manager
    Collaborator

    Contract review — PR #14734 at head a59f92f37 — VERDICT: PASS

    VERDICT: PASS
    Implemented-by: session_0112hMx9hjJ9BgB28X97DS68
    Reviewed-by: session_01ShyhexkB2d1AeRZ85tgAAe
    

    Director seat (objectstack #12708), summon #10, 2026-09-02. Reviewer served at claude-fable-5-1 (get_session, this summon; constant CONTRACT_REVIEW_TIER read as the floor per the round-start marker 5511327941). Reviewed the diff against merge base 3c1bbd2a8 directly (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

    1. multi: true update: divergent per-row hook key sets ⇒ the batch is refused before any write. Correct against ruling C (5511804838). The stripReadonlyFields uses Object.is to 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 over update()'s batch recording (writes through the inner view land on the outer view, so the read-only strips still see every hook write), one closeWindow() per row, the diverging set is union \ intersection over all rows (order-independent, names every offending key), the throw sits in the per-row before phase outside update()'s try, ahead of the outer seal, both readonly strips, validation and every driver.updateMany. Pinned: refused with code + status, no driver write ran, the already-done row's completed_at unchanged.
    2. 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: true hook 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.
    3. Abstain when a hook replaced the payload (no attributable record) — the same fail-safe stripReadonlyFields uses Object.is to 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.
    4. Scope: by-id update and predicate delete untouched; pinned.
    5. Public surface: four new exports from @objectstack/objectql (MultiUpdateHookKeyDivergenceError, its _CODE, _STATUS, divergingHookPayloadKeys); closeWindow() on HookWriteRecording, which is not on the package's published entry (the provenance module is internal), so no consumer-implemented interface widens. One ERROR_CODE_LEDGER entry in packages/spec — see ③.

    ② Semver

    @objectstack/objectql: minor and @objectstack/spec: minor with a BREAKING banner, under the repo's launch-window convention (check:changeset-no-major refuses majors) — exactly what the ruling ordered. ADR-0087 disposition not-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/spec ledger 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 stable error.code; an unregistered code is demoted to declaredCode at the dispatcher door, which is the wrong channel for the one code duly and hotcrm must branch on. FILE_FIELD_BULK_WRITE_REFUSED is 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 a59f92f37 is success or skipped. Governed-surface test: none of the 11 paths is governed ⇒ ordinary queue landing.

    Disposition: needs:contract-review cleared 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:dispatched stays until the merge closes the card through Fixes #14099.


    Generated by Claude Code

  3. added a commit that references this issue on Sep 10, 2026
  4. added a commit that references this issue on Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions