Skip to content

Commit 681871e

Browse files
os-billclaude
andauthored
feat(spec): HookContext admits a row-invariant-in-effect rewrite by per-row previous on a predicate write, kept safe by the key-divergence refusal (#17249)
* feat(spec): HookContext admits a row-invariant-in-effect rewrite by per-row previous on a predicate write Amend the D3 clause in hook.zod.ts (and its mirror in bulk-write-hook-conformance.ts, plus the ADR-0058 anchor's invariant text) so that per-row `previous` on a predicate write is supplied for a guard to REFUSE and for a `before*` hook to make a row-invariant-in-effect rewrite — one whose written key set is the same on every matched row — naming the engine's MULTI_UPDATE_HOOK_KEY_DIVERGENCE (400, `keys`, `rows`) refusal as the mechanism that makes the shape safe, and stating plainly what that refusal looks like to an operator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * docs(adr-0058): record the #16074 ruling as Amendment II.3, point D3 at it Amendment by addition, in ADR-0058's own idiom: a new blockquoted block after Amendment II.2 recording the maintainer ruling (decision batch #59, 2026-09-06) that per-row `previous` on a predicate write may serve a row-invariant-in-effect rewrite, with MULTI_UPDATE_HOOK_KEY_DIVERGENCE (#14099) as the engine mechanism that makes it safe and the two shapes the rule does not admit. The superseded 2026-08 D3 sentence is left standing as the dated record and carries a forward pointer to the new block, so the AGENTS.md directive-13 grep lands on the pointer at the line that would otherwise read as the live rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * 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 * docs(spec): name the recording comment and fix three prose claims (review F1-F4) Four prose corrections from the delta contract review's non-blocking findings. No behaviour change, no contract change, no new argument. F1 — ADR-0058 Amendment II.3's ruling paragraph cited the date and the decision batch but not the recording comment. It now names comment `5560086928`, the comment on this card that records the maintainer reply the block quotes. F2 — round 3's rewrap left a stub line (`matched). So an`) mid-paragraph. The paragraph is rewrapped to the block's own idiom; the prose is word-identical. F3 — the block said it amends D3's closing SENTENCE. It amends the bullet's last two: the "rewrite *conditioned* on the row is out of contract" sentence is superseded for the in-place / same-key-set case alongside the "not so a rewrite can be aimed" one. Now "closing sentences". F4 — the changeset attributed the ruling to the director seat. The MAINTAINER ruled; the director seat recorded it. This text ships to consumers inside the package's CHANGELOG.md, so the misattribution was published. Now "Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06)". Level re-derived rather than inherited: `packages/spec`'s files[] carries src/**/*.zod.ts and dist, while docs/adr/** is in no package's files[]. This round moves no published carrier and changes nothing behavioural, so the existing `@objectstack/spec: minor` stands. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 929d9e3 commit 681871e

5 files changed

Lines changed: 155 additions & 19 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074)
6+
7+
The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.**
8+
9+
The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states:
10+
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.
15+
16+
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: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@
233233
> one affected count (#4639), one aggregate `data.records.updated`. A rewrite
234234
> *conditioned* on the row is out of contract: it widens to every matched row
235235
> rather than scoping itself. Per-row `previous` is supplied so a guard can
236-
> REFUSE, not so a rewrite can be aimed.
236+
> REFUSE, not so a rewrite can be aimed. **→ Amended in Amendment II.3 below.**
237237
> - **`input.id` stops being a reroute lever, on this path only (D4).** A
238238
> per-row context arrives with `id` already bound and the dispatch decided, so
239239
> rebinding it retargets nothing; it is refused rather than ignored, because a
@@ -439,6 +439,73 @@
439439
440440
---
441441

442+
> **Amendment II.3 (2026-09, #16074 maintainer ruling, decision batch #59) —
443+
> a ROW-INVARIANT-IN-EFFECT rewrite is ADMITTED, and the ENGINE is what makes
444+
> it safe.**
445+
> _Amends D3's closing sentences in Addendum II above by ADDITION: the 2026-08
446+
> text is left standing as the dated record of what was decided then, carrying
447+
> a forward pointer to here. This block RECORDS a decision already taken; it
448+
> takes none._
449+
>
450+
> **The ruling.** Maintainer reply, verbatim: 「16063 c, 其他同意」
451+
> (2026-09-06, decision batch #59, recorded on this card #16074 in comment
452+
> `5560086928`). Option 1 adopted: the CONTRACT admits the shape. The three
453+
> shipped provenance stamps are made conforming by amending the contract, not
454+
> by being changed to fit it.
455+
>
456+
> **What the rule now is.** On a predicate write (`multi: true`), per-row
457+
> `previous` is supplied so a guard can REFUSE (throw) **and** so a `before*`
458+
> hook can make a **row-invariant-in-effect** rewrite: one whose written KEY
459+
> SET is the same on every matched row AND is assigned IN PLACE
460+
> (`ctx.input.data.customized = true`). The shape that ships is the worked
461+
> example — a provenance stamp writing `customized: true` on every row whose
462+
> `previous.managed_by` is package-seeded. D3's merge rule is untouched: the
463+
> payload stays BATCH-scoped, so what "row-invariant in effect" buys is the
464+
> right to DECIDE per row while writing the same keys for all of them.
465+
>
466+
> **The mechanism that makes it safe is the ENGINE, not the hook.** The
467+
> dispatch's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099) records, per
468+
> row, the payload keys that row's hook chain assigned IN PLACE, and if any two
469+
> rows disagree it refuses the WHOLE batch BEFORE any write — nothing is
470+
> written, not the first row. In-place is the condition the refusal rests on,
471+
> which is why the admitted shape carries it: a hook that REPLACES
472+
> `ctx.input.data` hands the dispatch a fresh object whose keys it cannot
473+
> attribute, the recording yields nothing, and the comparison is SKIPPED — the
474+
> batch is not judged at all. To an operator that refusal is an ADR-0112
475+
> envelope, `status: 400`, carrying `keys` (the sorted keys some rows' hooks
476+
> wrote and other rows' did not) and `rows` (how many rows the predicate
477+
> matched). So an author does not have to be TRUSTED to be row-invariant; a
478+
> hook that is not gets a loud, whole-batch 400 instead of a half-stamped
479+
> table.
480+
>
481+
> **The three shapes the rule does NOT admit.**
482+
>
483+
> - A rewrite whose written KEY SET differs across rows. That IS the refusal
484+
> above: out of contract, and the engine says so before anything is written.
485+
> - The same key written with a per-row VALUE. The engine judges KEY SETS,
486+
> never values — the clock-reading audit stamp has to pass — so this shape
487+
> CLEARS the divergence check and applies the LAST dispatch's value to every
488+
> matched row. It stays out of contract and stays unenforced.
489+
> - A row-conditioned REPLACEMENT of `ctx.input.data`. The recording cannot
490+
> attribute a replacement's keys, so the batch ABSTAINS and no refusal fires
491+
> at all. It stays out of contract and stays unenforced, for the same reason
492+
> as the shape above: what admits the new shape is the refusal, and the
493+
> refusal does not reach here.
494+
>
495+
> **Why option 2 (change the three stamps) was not adopted.** #15302 measured
496+
> the cost of the alternative: a stamp that DECLINES on a predicate write
497+
> leaves unstamped exactly the rows the next boot overwrites, so it converts a
498+
> visible 400 into the silent loss of an admin edit.
499+
>
500+
> **Where the contract text lives now.** The D3 bullet of
501+
> `packages/spec/src/data/hook.zod.ts#HookContextSchema`'s `input` clause set,
502+
> mirrored in `packages/spec/src/data/bulk-write-hook-conformance.ts` — both
503+
> amended in PR #17249. The refusal's own class, and the value-comparison
504+
> variants that were rejected with it, are
505+
> `packages/objectql/src/multi-update-hook-key-divergence.ts`.
506+
507+
---
508+
442509
## TL;DR
443510

444511
ObjectStack exposes **~50 authorable declarations** that hold an expression — formulas, visibility/required/readonly predicates, validation rules, hook conditions, flow/edge conditions, sharing-rule conditions, RLS `using`/`check`, action/view/app visibility, notification/ETL/export/sync/connector conditions — and they all funnel through **one authoring primitive** (`ExpressionInputSchema``{ dialect: 'cel', source }`, helpers `cel`/`F`/`P`). The authoring surface is already unified and clean.

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

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,26 @@
6666
* single-row writes. One `updateMany`, one affected count (#4639), one
6767
* aggregate `data.records.updated` — the write's own contract is untouched,
6868
* exactly as #5038 left it;
69-
* - a rewrite CONDITIONED on the row (`ctx.previous`, `ctx.input.id`) is
70-
* therefore **outside this contract**: it does not scope itself to the row
71-
* it was decided on, it widens to every matched row. Per-row `previous` is
72-
* supplied so a guard can REFUSE the write, not so a rewrite can be aimed
73-
* at one row. The three supported routes for row-specific work are: throw
74-
* (which is what the guard case wants), write through `ctx.api` per row, or
75-
* have the CALLER paginate the batch into by-id updates.
69+
* - a rewrite CONDITIONED on the row (`ctx.previous`, `ctx.input.id`)
70+
* therefore cannot scope itself to the row it was decided on: it widens to
71+
* every matched row. Per-row `previous` is supplied so a guard can REFUSE
72+
* the write, and — ruled on #16074 — so a hook can make a
73+
* ROW-INVARIANT-IN-EFFECT rewrite: one whose written KEY SET is the same on
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.
7689
*
7790
* On a predicate DELETE this clause is vacuous — a delete context carries an
7891
* id and no payload — which is why `payloadScope` is `'none'` there.
@@ -150,12 +163,17 @@
150163
*
151164
* The residual hazard is named rather than hidden: D3 hands authors per-row
152165
* `previous` and a batch-scoped payload, so a row-conditional rewrite is
153-
* *expressible* and wrong. That is a contract statement, not an enforcement —
154-
* no static rule can decide whether a rewrite is row-invariant — so it belongs
155-
* in the authoring docs and, if it ever earns one, an advisory lint over hook
156-
* bodies (`packages/lint`'s `validate-hook-body-writes` is the existing seam).
157-
* Naming an unenforceable clause is the honest half of ADR-0049, not a breach
158-
* of it: the alternative was to leave the same hazard undocumented.
166+
* *expressible*. No static rule can decide whether a rewrite is row-invariant,
167+
* but since #14099 the dispatch MEASURES the half that matters — the key set
168+
* each row's chain writes — and refuses divergence, which is what lets #16074
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
174+
* `validate-hook-body-writes` is the existing seam). Naming an unenforceable
175+
* clause is the honest half of ADR-0049, not a breach of it: the alternative
176+
* was to leave the same hazard undocumented.
159177
*
160178
* @see docs/adr/0058-expression-and-predicate-surface.md — Addendum II
161179
* @see HookContextSchema in `data/hook.zod.ts` — the per-event shape the engine

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,10 +581,45 @@ export const HookContextSchema = lazySchema(() => z.object({
581581
* context carries THE one payload, not a copy — `driver.updateMany` takes
582582
* one SET clause for N rows — so a rewrite applies to the whole batch
583583
* whichever row's dispatch made it, and rewrites accumulate in dispatch
584-
* order. A rewrite CONDITIONED on the row is therefore out of contract:
585-
* it widens to every matched row instead of scoping itself. Per-row
586-
* `previous` is supplied so a guard can REFUSE (throw), not so a rewrite
587-
* can be aimed.
584+
* order. A rewrite CONDITIONED on the row therefore cannot scope itself:
585+
* whatever one row's dispatch writes is written to every matched row.
586+
* Per-row `previous` is supplied so a guard can REFUSE (throw), and —
587+
* ruled on #16074 — so a `before*` hook can make a
588+
* ROW-INVARIANT-IN-EFFECT rewrite: one whose written KEY SET is the same
589+
* on every matched row AND is assigned IN PLACE
590+
* (`ctx.input.data.customized = true`), such as a provenance stamp that
591+
* writes `customized: true` on every row whose `previous.managed_by` is
592+
* package-seeded. What makes that shape safe is not the hook but the
593+
* engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the
594+
* dispatch records, per row, the payload keys that row's hook chain
595+
* assigned IN PLACE, and if any two rows disagree the WHOLE batch is
596+
* refused before any write — nothing is written, not the first row.
597+
* In-place is the load-bearing half of that condition, not a detail of
598+
* spelling: a hook that REPLACES `ctx.input.data` (assigning
599+
* `ctx.input.data = { ...ctx.input.data, customized: true }`) hands the
600+
* dispatch a fresh object whose keys it cannot attribute, so the
601+
* recording yields nothing and the comparison is SKIPPED — the batch is
602+
* not judged at all, rather than judged and passed. To an operator that
603+
* refusal is an ADR-0112 envelope with `status: 400` and
604+
* `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, carrying `keys` — the sorted
605+
* keys some rows' hooks wrote and other rows' did not (for the stamp above,
606+
* `['customized']`) — and `rows` — how many rows the predicate matched
607+
* (`2` for a two-row batch) — plus `object` naming the target and a
608+
* message that says "Nothing was written" and then names the remedy. So a
609+
* bulk edit over rows that ALREADY disagree on the stamp's condition (one
610+
* row still package-managed, one already customized) is refused whole
611+
* rather than half-stamped; that is the engine working, not the hooks
612+
* misbehaving, and the remedy is the caller's: write those rows by id, or
613+
* from inside the handler through `ctx.api`. Three shapes this rule does
614+
* NOT admit: a rewrite whose written key set differs across rows (that IS
615+
* the refusal above); a rewrite that writes the same key with a per-row
616+
* VALUE — the engine judges key sets, never values (the clock-reading
617+
* audit stamp must pass), so that shape clears the check and applies the
618+
* LAST dispatch's value to every row; and a row-conditioned REPLACEMENT
619+
* of `ctx.input.data`, which silences the recording described above, so
620+
* that shape is judged by nothing at all. All three stay out of contract.
621+
* The refusal's class and both rejected value-comparison variants are
622+
* recorded on `packages/objectql/src/multi-update-hook-key-divergence.ts`.
588623
* - `input.id` is NOT a reroute lever (D4). It used to be: on the batch
589624
* dispatch `input.id` was present-but-`undefined`, and binding it moved
590625
* 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, not so a rewrite can be aimed at one row. 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)