|
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 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 | + |
442 | 509 | ## TL;DR |
443 | 510 |
|
444 | 511 | 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