You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.inputasany).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:
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.
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".
Detection is not obviously impossible. A guard could refuse when a per-row dispatch reads the row's pre-image (ctx.previous, ctx.input.id) and writes the payload — a provenance question rather than a value question, and so free of both measurements above. Whether that over-fires on legitimate handlers is unmeasured and is the first thing an implementing seat should measure.
Anything that makes the engine split its own write reverses an accepted ADR and needs a superseding ADR, not a changeset.
Filed by the
domain:engineexecution 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
beforeUpdatechain 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
SETclause 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:Two rows,
awithstatus: 'blocked'andbwithstatus: 'todo', updated in onemulti: truecall:statusablockedhighlowbtodolowlowdriver.updateManyPayloadshas length 1 — oneupdateMany, one payload. Rowagets rowb's answer. Nothing errors, nothing is refused, and no audit entry records thata'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
SETclause 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:sys_stamp_audit_updatebuiltin 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 differentupdated_atvalues on an entirely honest batch. A value comparison would refuse it non-deterministically.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's own row: a hook that deliberately writes the value the caller also sent (completed_at: nullon 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
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 published17.2.0; it lands in the release that carries Amulti: trueupdate applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099's refusal.What a triage decision has to weigh
multi: trueupdate applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099's confidence-gap list flagged exactly this: the in-repo population of same-key/per-row-value rewrites has not been counted. The three in-repobeforeUpdatepayload rewrites measured for Amulti: trueupdate applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099's Zone 0 gate (audit stamp, pinyin companion projection, service-storage copy-on-claim) are all row-invariant in value as well as in key set, so none of them is an instance — the population may be entirely downstream.ctx.previous,ctx.input.id) and writes the payload — a provenance question rather than a value question, and so free of both measurements above. Whether that over-fires on legitimate handlers is unmeasured and is the first thing an implementing seat should measure.Related
multi: trueupdate applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099 — the key-set refusal that ships the guard this residue sits beside; PR fix(objectql,spec): refuse amulti: trueupdate whose per-rowbeforeUpdatehooks write divergent key sets (#14099) #14734.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 — the hook-write provenance recorder both the refusal and any future guard read.Generated by Claude Code