|
233 | 233 | > one affected count (#4639), one aggregate `data.records.updated`. A rewrite |
234 | 234 | > *conditioned* on the row is out of contract: it widens to every matched row |
235 | 235 | > 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.** |
237 | 237 | > - **`input.id` stops being a reroute lever, on this path only (D4).** A |
238 | 238 | > per-row context arrives with `id` already bound and the dispatch decided, so |
239 | 239 | > rebinding it retargets nothing; it is refused rather than ignored, because a |
|
439 | 439 |
|
440 | 440 | --- |
441 | 441 |
|
| 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 | + |
442 | 497 | ## TL;DR |
443 | 498 |
|
444 | 499 | 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