Skip to content

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

Description

@os-musk

Filed by the domain:engine execution seat while implementing #14099 (PR #14734). Unassigned and untriaged. ⛔ Not fixed there, by ruling: the maintainer ruling recorded on #14099 (comment 5511804838) names this residue explicitly and instructs the engine seat to file it as its own finding with a measured instance rather than widening that card.

The residue

#14099's refusal compares, per row, the set of payload keys a beforeUpdate chain assigned, and refuses the batch when two rows disagree. The criterion is the key set and never the values, for two measured reasons recorded on that card.

A handler that writes the same key on every row therefore passes the test — even when the value it derives is different for each row. One SET clause still serves N rows (ADR-0058 Addendum II D3), so one row's derived value lands on all of them, silently, exactly as before.

Measured

Pinned in packages/objectql/src/multi-update-hook-key-divergence.test.ts (section 4, "same key + per-row VALUES still passes — D3's declared cost"), so the behaviour cannot drift in either direction unnoticed:

// beforeUpdate — derives from the row's own pre-image
(ctx.input as any).data.priority = prev.status === 'blocked' ? 'high' : 'low';

Two rows, a with status: 'blocked' and b with status: 'todo', updated in one multi: true call:

row pre-image status its own dispatch derived value actually stored
a blocked high low
b todo low low

driver.updateManyPayloads has length 1 — one updateMany, one payload. Row a gets row b's answer. Nothing errors, nothing is refused, and no audit entry records that a's own derivation was discarded.

Note the direction: the rewrites accumulate onto one payload in dispatch order, so the last dispatch's value is the one the SET clause carries. The #14099 ruling's prose says "the first row's value"; the engine's measured behaviour is the last. Same defect, and the pin names the measured direction.

Why the key-set criterion cannot see it, and why the obvious fix is wrong

⛔ The fix is not to compare values. That variant was rejected on measurement twice on #14099, and both measurements are recorded in packages/objectql/src/multi-update-hook-key-divergence.ts:

  1. objectql's own sys_stamp_audit_update builtin is registered on '*', so it runs in essentially every deployment, and it reads the clock inside the per-record stamp. Under per-row dispatch that is one clock read per row, so two rows either side of a millisecond boundary carry different updated_at values on an entirely honest batch. A value comparison would refuse it non-deterministically.
  2. A value comparison also re-opens 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's own row: a hook that deliberately writes the value the caller also sent (completed_at: null on a reopen) is indistinguishable, by value, from a hook that never touched the key.

So this residue is not closable by tightening the same instrument one notch. Closing it needs a decision about the write shape itself, which is ADR territory: Addendum II D3 says the engine never splits its own write, and it records that a refusal stays reversible in the safe direction while "a write that has learned to split itself cannot be un-split".

Today's exits, both real

  1. Route 2 — write the affected records from inside the handler with ctx.api, aimed with the per-row sandbox signals (ctx.dispatch.mode === 'per-row', ctx.input.id, ctx.input.options), and leave the batch payload alone. Not present in published 17.2.0; it lands in the release that carries 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's refusal.
  2. By-id updates from the caller when the value genuinely differs per record.

What a triage decision has to weigh

Related

Generated by Claude Code


Blocked-by: the Q3 hotcrm reading owed under decision batch #35 — run scripts/audits/14744-before-update-per-row-value-census.mjs (instrument A) and post the result; the duly tree remains the maintainer's. No domain:hotcrm card carries it today (measured: 0 open cards with that label, over a repo-scoped list of 563 open cards, with #14744 and #15090 as firing controls that the same walk did return).
Unlock-action: post the hotcrm Q3 reading on this card, AND document the residue — same key / per-row value through a registered beforeUpdate hook — with Route 2 / by-id updates as the exits and B (the value-provenance recorder) as the reserve instrument. Both are enumerated by the ruling 5542623847 as closing conditions; the third, the A fix with the harness adopted, landed as PR #15475 (4f85e4d11).


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions