Skip to content

Commit d6711fc

Browse files
committed
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
1 parent 3c1f7c1 commit d6711fc

1 file changed

Lines changed: 56 additions & 1 deletion

File tree

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

Lines changed: 56 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,61 @@
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 sentence 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, on this card #16074). Option 1 adopted: the
452+
> CONTRACT admits the shape. The three shipped provenance stamps are made
453+
> conforming by amending the contract, not by being changed to fit it.
454+
>
455+
> **What the rule now is.** On a predicate write (`multi: true`), per-row
456+
> `previous` is supplied so a guard can REFUSE (throw) **and** so a `before*`
457+
> 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
459+
> example — a provenance stamp writing `customized: true` on every row whose
460+
> `previous.managed_by` is package-seeded. D3's merge rule is untouched: the
461+
> payload stays BATCH-scoped, so what "row-invariant in effect" buys is the
462+
> right to DECIDE per row while writing the same keys for all of them.
463+
>
464+
> **The mechanism that makes it safe is the ENGINE, not the hook.** The
465+
> 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
471+
> author does not have to be TRUSTED to be row-invariant; a hook that is not
472+
> gets a loud, whole-batch 400 instead of a half-stamped table.
473+
>
474+
> **The two shapes the rule does NOT admit.**
475+
>
476+
> - A rewrite whose written KEY SET differs across rows. That IS the refusal
477+
> above: out of contract, and the engine says so before anything is written.
478+
> - The same key written with a per-row VALUE. The engine judges KEY SETS,
479+
> never values — the clock-reading audit stamp has to pass — so this shape
480+
> CLEARS the divergence check and applies the LAST dispatch's value to every
481+
> matched row. It stays out of contract and stays unenforced.
482+
>
483+
> **Why option 2 (change the three stamps) was not adopted.** #15302 measured
484+
> the cost of the alternative: a stamp that DECLINES on a predicate write
485+
> leaves unstamped exactly the rows the next boot overwrites, so it converts a
486+
> visible 400 into the silent loss of an admin edit.
487+
>
488+
> **Where the contract text lives now.** The D3 bullet of
489+
> `packages/spec/src/data/hook.zod.ts#HookContextSchema`'s `input` clause set,
490+
> mirrored in `packages/spec/src/data/bulk-write-hook-conformance.ts` — both
491+
> amended in PR #17249. The refusal's own class, and the value-comparison
492+
> variants that were rejected with it, are
493+
> `packages/objectql/src/multi-update-hook-key-divergence.ts`.
494+
495+
---
496+
442497
## TL;DR
443498

444499
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.

0 commit comments

Comments
 (0)