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

Activity

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