Skip to content

Commit 5e7e227

Browse files
committed
docs(spec): correct two claims in the amended D3 clause (review REWORK F1/F2)
F1 — the key-divergence refusal's message does not END with "Nothing was written". `buildMessage` (`packages/objectql/src/multi-update-hook-key- divergence.ts`) continues "Write those records individually, from inside the handler with 'ctx.api' or by id.", and the pin is `toContain(...)`. Say the message SAYS the phrase and then names the remedy, in `hook.zod.ts` and in the changeset that repeated it. F2 — the refusal was stated unconditionally. `dispatchPerRowBeforeHooks` only compares when `seal()` returned a key record, and `seal()` returns none when a hook REPLACED `ctx.input.data` instead of assigning into it. So the admitted shape is now qualified as an IN-PLACE assignment, the abstention is named where the refusal is claimed, and a row-conditioned REPLACEMENT is listed as a third shape the rule does not admit — it clears with no refusal at all. Mirrored in every carrier of the same clause that this PR authored: `bulk-write-hook-conformance.ts` (D3 docblock and its unenforced-residue note), ADR-0058 Amendment II.3, and the anchor JSON's `invariant` print text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
1 parent d6711fc commit 5e7e227

5 files changed

Lines changed: 77 additions & 50 deletions

File tree

.changeset/hook-previous-row-invariant-rewrite.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the pe
88

99
The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states:
1010

11-
- Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row.
12-
- What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned, and if any two rows disagree the whole batch is refused **before any write**.
13-
- What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message ending "Nothing was written". A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`.
14-
- Two shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself), and the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row. It stays out of contract.
11+
- Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`).
12+
- What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all.
13+
- What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`.
14+
- Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract.
1515

1616
Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit.

docs/adr/0058-expression-and-predicate-surface.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,30 +455,41 @@
455455
> **What the rule now is.** On a predicate write (`multi: true`), per-row
456456
> `previous` is supplied so a guard can REFUSE (throw) **and** so a `before*`
457457
> hook can make a **row-invariant-in-effect** rewrite: one whose written KEY
458-
> SET is the same on every matched row. The shape that ships is the worked
458+
> SET is the same on every matched row AND is assigned IN PLACE
459+
> (`ctx.input.data.customized = true`). The shape that ships is the worked
459460
> example — a provenance stamp writing `customized: true` on every row whose
460461
> `previous.managed_by` is package-seeded. D3's merge rule is untouched: the
461462
> payload stays BATCH-scoped, so what "row-invariant in effect" buys is the
462463
> right to DECIDE per row while writing the same keys for all of them.
463464
>
464465
> **The mechanism that makes it safe is the ENGINE, not the hook.** The
465466
> dispatch's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099) records, per
466-
> row, the payload keys that row's hook chain assigned, and if any two rows
467-
> disagree it refuses the WHOLE batch BEFORE any write — nothing is written,
468-
> not the first row. To an operator that refusal is an ADR-0112 envelope,
469-
> `status: 400`, carrying `keys` (the sorted keys some rows' hooks wrote and
470-
> other rows' did not) and `rows` (how many rows the predicate matched). So an
467+
> row, the payload keys that row's hook chain assigned IN PLACE, and if any two
468+
> rows disagree it refuses the WHOLE batch BEFORE any write — nothing is
469+
> written, not the first row. In-place is the condition the refusal rests on,
470+
> which is why the admitted shape carries it: a hook that REPLACES
471+
> `ctx.input.data` hands the dispatch a fresh object whose keys it cannot
472+
> attribute, the recording yields nothing, and the comparison is SKIPPED — the
473+
> batch is not judged at all. To an operator that refusal is an ADR-0112
474+
> envelope, `status: 400`, carrying `keys` (the sorted keys some rows' hooks
475+
> wrote and other rows' did not) and `rows` (how many rows the predicate
476+
> matched). So an
471477
> author does not have to be TRUSTED to be row-invariant; a hook that is not
472478
> gets a loud, whole-batch 400 instead of a half-stamped table.
473479
>
474-
> **The two shapes the rule does NOT admit.**
480+
> **The three shapes the rule does NOT admit.**
475481
>
476482
> - A rewrite whose written KEY SET differs across rows. That IS the refusal
477483
> above: out of contract, and the engine says so before anything is written.
478484
> - The same key written with a per-row VALUE. The engine judges KEY SETS,
479485
> never values — the clock-reading audit stamp has to pass — so this shape
480486
> CLEARS the divergence check and applies the LAST dispatch's value to every
481487
> matched row. It stays out of contract and stays unenforced.
488+
> - A row-conditioned REPLACEMENT of `ctx.input.data`. The recording cannot
489+
> attribute a replacement's keys, so the batch ABSTAINS and no refusal fires
490+
> at all. It stays out of contract and stays unenforced, for the same reason
491+
> as the shape above: what admits the new shape is the refusal, and the
492+
> refusal does not reach here.
482493
>
483494
> **Why option 2 (change the three stamps) was not adopted.** #15302 measured
484495
> the cost of the alternative: a stamp that DECLINES on a predicate write

packages/spec/src/data/bulk-write-hook-conformance.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,21 @@
7171
* every matched row. Per-row `previous` is supplied so a guard can REFUSE
7272
* the write, and — ruled on #16074 — so a hook can make a
7373
* ROW-INVARIANT-IN-EFFECT rewrite: one whose written KEY SET is the same on
74-
* every matched row. The engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`
75-
* refusal (#14099; `400`, naming the diverging `keys` and the matched
76-
* `rows`) is what makes that shape safe — when two rows' hook chains
77-
* assign different key sets the WHOLE batch is refused before any write.
78-
* A rewrite aimed at ONE row — a per-row key set, or the same key with a
79-
* per-row value, which the key-set test cannot see — is still **outside
80-
* this contract**; `hook.zod.ts` carries the operator-facing shape of the
81-
* refusal. The three supported routes for row-specific work are: throw
82-
* (which is what the guard case wants), write through `ctx.api` per row, or
83-
* have the CALLER paginate the batch into by-id updates.
74+
* every matched row AND is assigned IN PLACE. The engine's
75+
* `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099; `400`, naming the
76+
* diverging `keys` and the matched `rows`) is what makes that shape safe —
77+
* when two rows' hook chains assign different key sets in place the WHOLE
78+
* batch is refused before any write. In-place is the condition the refusal
79+
* rests on, not a detail of spelling: a hook that REPLACES `ctx.input.data`
80+
* rather than assigning into it leaves the dispatch unable to attribute
81+
* keys, so the recording yields nothing and the batch is NOT JUDGED at all.
82+
* A rewrite aimed at ONE row — a per-row key set; the same key with a
83+
* per-row value, which the key-set test cannot see; or a row-conditioned
84+
* REPLACEMENT of the payload, which the recording cannot see — is still
85+
* **outside this contract**; `hook.zod.ts` carries the operator-facing
86+
* shape of the refusal. The three supported routes for row-specific work
87+
* are: throw (which is what the guard case wants), write through `ctx.api`
88+
* per row, or have the CALLER paginate the batch into by-id updates.
8489
*
8590
* On a predicate DELETE this clause is vacuous — a delete context carries an
8691
* id and no payload — which is why `payloadScope` is `'none'` there.
@@ -161,9 +166,11 @@
161166
* *expressible*. No static rule can decide whether a rewrite is row-invariant,
162167
* but since #14099 the dispatch MEASURES the half that matters — the key set
163168
* each row's chain writes — and refuses divergence, which is what lets #16074
164-
* admit the row-invariant-in-effect shape above. What stays unenforced is the
165-
* same key with per-row VALUES; that belongs in the authoring docs and, if it
166-
* ever earns one, an advisory lint over hook bodies (`packages/lint`'s
169+
* admit the row-invariant-in-effect shape above. That measurement has two
170+
* blind spots, and both stay unenforced: the same key with per-row VALUES, and
171+
* a payload REPLACED wholesale rather than assigned into, whose keys the
172+
* dispatch cannot attribute at all. Both belong in the authoring docs and, if
173+
* they ever earn one, an advisory lint over hook bodies (`packages/lint`'s
167174
* `validate-hook-body-writes` is the existing seam). Naming an unenforceable
168175
* clause is the honest half of ADR-0049, not a breach of it: the alternative
169176
* was to leave the same hazard undocumented.

packages/spec/src/data/hook.zod.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -559,31 +559,40 @@ export const HookContextSchema = lazySchema(() => z.object({
559559
* Per-row `previous` is supplied so a guard can REFUSE (throw), and —
560560
* ruled on #16074 — so a `before*` hook can make a
561561
* ROW-INVARIANT-IN-EFFECT rewrite: one whose written KEY SET is the same
562-
* on every matched row, such as a provenance stamp that writes
563-
* `customized: true` on every row whose `previous.managed_by` is
564-
* package-seeded. What makes that shape
565-
* safe is not the hook but the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`
566-
* refusal (#14099): the dispatch records, per row, the payload keys that
567-
* row's hook chain assigned, and if any two rows disagree the WHOLE batch
568-
* is refused before any write — nothing is written, not the first row.
569-
* To an operator that refusal is an ADR-0112 envelope with `status: 400`
570-
* and `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, carrying `keys` — the
571-
* sorted keys some rows' hooks wrote and other rows' did not (for the
572-
* stamp above, `['customized']`) — and `rows` — how many rows the
573-
* predicate matched (`2` for a two-row batch) — plus `object` naming the
574-
* target and a message ending "Nothing was written". So a bulk edit over
575-
* rows that ALREADY disagree on the stamp's condition (one row still
576-
* package-managed, one already customized) is refused whole rather than
577-
* half-stamped; that is the engine working, not the hooks misbehaving,
578-
* and the remedy is the caller's: write those rows by id, or from inside
579-
* the handler through `ctx.api`. Two shapes this rule does NOT admit: a
580-
* rewrite whose written key set differs across rows (that IS the refusal
581-
* above), and a rewrite that writes the same key with a per-row VALUE —
582-
* the engine judges key sets, never values (the clock-reading audit stamp
583-
* must pass), so that shape clears the check and applies the LAST
584-
* dispatch's value to every row; it stays out of contract. The refusal's
585-
* class and both rejected value-comparison variants are recorded on
586-
* `packages/objectql/src/multi-update-hook-key-divergence.ts`.
562+
* on every matched row AND is assigned IN PLACE
563+
* (`ctx.input.data.customized = true`), such as a provenance stamp that
564+
* writes `customized: true` on every row whose `previous.managed_by` is
565+
* package-seeded. What makes that shape safe is not the hook but the
566+
* engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the
567+
* dispatch records, per row, the payload keys that row's hook chain
568+
* assigned IN PLACE, and if any two rows disagree the WHOLE batch is
569+
* refused before any write — nothing is written, not the first row.
570+
* In-place is the load-bearing half of that condition, not a detail of
571+
* spelling: a hook that REPLACES `ctx.input.data` (assigning
572+
* `ctx.input.data = { ...ctx.input.data, customized: true }`) hands the
573+
* dispatch a fresh object whose keys it cannot attribute, so the
574+
* recording yields nothing and the comparison is SKIPPED — the batch is
575+
* not judged at all, rather than judged and passed. To an operator that
576+
* refusal is an ADR-0112 envelope with `status: 400` and
577+
* `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, carrying `keys` — the sorted
578+
* keys some rows' hooks wrote and other rows' did not (for the stamp above,
579+
* `['customized']`) — and `rows` — how many rows the predicate matched
580+
* (`2` for a two-row batch) — plus `object` naming the target and a
581+
* message that says "Nothing was written" and then names the remedy. So a
582+
* bulk edit over rows that ALREADY disagree on the stamp's condition (one
583+
* row still package-managed, one already customized) is refused whole
584+
* rather than half-stamped; that is the engine working, not the hooks
585+
* misbehaving, and the remedy is the caller's: write those rows by id, or
586+
* from inside the handler through `ctx.api`. Three shapes this rule does
587+
* NOT admit: a rewrite whose written key set differs across rows (that IS
588+
* the refusal above); a rewrite that writes the same key with a per-row
589+
* VALUE — the engine judges key sets, never values (the clock-reading
590+
* audit stamp must pass), so that shape clears the check and applies the
591+
* LAST dispatch's value to every row; and a row-conditioned REPLACEMENT
592+
* of `ctx.input.data`, which silences the recording described above, so
593+
* that shape is judged by nothing at all. All three stay out of contract.
594+
* The refusal's class and both rejected value-comparison variants are
595+
* recorded on `packages/objectql/src/multi-update-hook-key-divergence.ts`.
587596
* - `input.id` is NOT a reroute lever (D4). It used to be: on the batch
588597
* dispatch `input.id` was present-but-`undefined`, and binding it moved
589598
* the write onto the single-id path. A per-row context arrives with `id`

scripts/adr-anchors/packages__spec__src__data__bulk-write-hook-conformance.ts.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"adrs": [
44
"ADR-0058"
55
],
6-
"invariant": "A predicate (`multi: true`) write dispatches lifecycle hooks PER MATCHED ROW in BOTH phases (ADR-0058 Addendum I for `after*`, Addendum II for `before*`), each context carrying that row's `previous` — the fix for a guard hook reading `previous?.x` and passing silently on every batch. The write's own payload stays BATCH-scoped: one `updateMany` carries one payload, so a `before*` rewrite applies to every matched row, N post-hook payloads cannot diverge, nothing is reconciled and no predicate write is ever split into N single-row writes (one affected count, #4639). Per-row `previous` exists so a guard can REFUSE and so a hook can make a row-invariant-in-effect rewrite (the same written key set on every row) — never so a rewrite can be aimed at one row: the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (400, `keys`, `rows`) rejects the whole batch when two rows' hook chains write different key sets. One ceiling (`MAX_BULK_PER_ROW_HOOK_ROWS`) governs both phases and is checked BEFORE the first dispatch; exceeding it REFUSES the write and is never downgraded to one dispatch for the batch. Per-row payload copies plus a converge-or-refuse rule were rejected on measured evidence: objectql's `'*'`-registered audit stamp reads the clock inside the per-record stamp, so rows either side of a millisecond boundary would diverge and refuse honest batches non-deterministically."
6+
"invariant": "A predicate (`multi: true`) write dispatches lifecycle hooks PER MATCHED ROW in BOTH phases (ADR-0058 Addendum I for `after*`, Addendum II for `before*`), each context carrying that row's `previous` — the fix for a guard hook reading `previous?.x` and passing silently on every batch. The write's own payload stays BATCH-scoped: one `updateMany` carries one payload, so a `before*` rewrite applies to every matched row, N post-hook payloads cannot diverge, nothing is reconciled and no predicate write is ever split into N single-row writes (one affected count, #4639). Per-row `previous` exists so a guard can REFUSE and so a hook can make a row-invariant-in-effect rewrite (the same written key set on every row, assigned IN PLACE) — never so a rewrite can be aimed at one row: the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (400, `keys`, `rows`) rejects the whole batch when two rows' hook chains write different key sets in place, and ABSTAINS — judging the batch not at all — when a hook REPLACES `ctx.input.data` instead of assigning into it. One ceiling (`MAX_BULK_PER_ROW_HOOK_ROWS`) governs both phases and is checked BEFORE the first dispatch; exceeding it REFUSES the write and is never downgraded to one dispatch for the batch. Per-row payload copies plus a converge-or-refuse rule were rejected on measured evidence: objectql's `'*'`-registered audit stamp reads the clock inside the per-record stamp, so rows either side of a millisecond boundary would diverge and refuse honest batches non-deterministically."
77
}

0 commit comments

Comments
 (0)