From 967187354d51a9caa514d2d2d27ab75711790d64 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 18:09:13 +0000 Subject: [PATCH] =?UTF-8?q?docs(skills):=20before*=20hooks=20on=20a=20pred?= =?UTF-8?q?icate=20write=20dispatch=20per=20row=20=E2=80=94=20the=20two=20?= =?UTF-8?q?published=20skills=20stop=20teaching=20a=20batch=20dispatch=20a?= =?UTF-8?q?nd=20a=20retired=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two published skills still taught the retired batch model for the `before*` phase of a predicate (`multi: true`) write: that `beforeUpdate` / `beforeDelete` fire ONCE for the batch, that `previous` is unbound there, that `record` is the bare payload, and that reading `previous` on that dispatch is "rejected by name" (a guard retired under ADR-0049 with no producer). The contract is per-row dispatch in both phases (ADR-0058 Addendum II, D1/D2; `packages/objectql/src/engine.ts` `dispatchPerRowBeforeHooks`): `input.id` names the row, `previous` is that row's pre-image, `record` is stored ⊕ payload. What stays batch-scoped is the payload (D3), with key-set divergence refused whole before any write (`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, ADR-0058 Amendment II.3), and one per-row ceiling for both phases (D6). Eight sites corrected — five in `skills/objectstack-formula/SKILL.md` (the `before*` row of the `previous` binding table, the "write it once on after*" lead-in, the "one exception is the before* row" paragraph, the ceiling sentence, the OLD.x / ISCHANGED callout) and three in `skills/objectstack-data/references/data-hooks.md` (the "after* events instead dispatch per row" callout, the "two cases … whole batch … rejected by name" bullet, the "put previous on an after-type event" consequence). The true rows (`after*` per row, insert unbound) are byte-identical to origin/main. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr --- .../objectstack-data/references/data-hooks.md | 36 ++++++++++-------- skills/objectstack-formula/SKILL.md | 37 +++++++++++-------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 8e1fa560eb..2bd5173314 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -44,9 +44,10 @@ ObjectStack provides **8 lifecycle events** organized by operation type: > bulk write hands hooks **no** row-scoping predicate: it lives on the engine-internal > `OperationContext.ast`, so the RLS / sharing filters composed onto it bind > the driver call itself, where no handler can widen them — scope a batch through -> `options.where` at the caller. The `after*` events instead dispatch **once per -> matched row**, each on a single-record-shaped context whose `input.id` names that -> row. And there is no `beforeCount`/`beforeAggregate`: read authorization and +> `options.where` at the caller. Both phases dispatch **once per matched row**, +> each on a single-record-shaped context whose `input.id` names that row and +> whose `previous` is that row's pre-image (ADR-0058 Addendum II). And there is +> no `beforeCount`/`beforeAggregate`: read authorization and > row filtering belong to **RLS / permission rules**, and field masking to > **field-level metadata** — declarative mechanisms that apply everywhere, rather than > a hook every author must remember to re-attach. @@ -238,14 +239,20 @@ in neither). So: pre-write row, made total over the same declared fields, and it is the same binding a validation predicate reads. - **`previous` is UNBOUND where there is no prior state**, and a reference to an - unbound root makes the whole condition unevaluable. Two cases: insert events - (`beforeInsert` / `afterInsert`) — write those over `record` alone — and the - **`before*` dispatch of a predicate (`multi: true`) write**, which fires **once - for the whole batch**: a `before*` hook may still rewrite the shared payload and - one batch carries one payload, so there is no single prior record to bind. - (`record` is that bare payload there too, so a *declared* field this write does - not set is unevaluable as well.) Reading `previous` on that dispatch is rejected - **by name**, and the rejection points you at the after-type event. + unbound root makes the whole condition unevaluable. One case: insert events + (`beforeInsert` / `afterInsert`) — write those over `record` alone. The + `before*` dispatch of a predicate (`multi: true`) write is **not** a second + case: `beforeUpdate` / `beforeDelete` dispatch once per matched row, each on a + single-record-shaped context whose `previous` is that row's pre-image and whose + `input.id` names the row (ADR-0058 Addendum II, D1/D2), so a transition + condition evaluates there exactly as on a single-record write, and `record` is + that row's state, stored ⊕ payload. Only the *payload* stays batch-scoped (D3): + every row's dispatch carries the one payload, so a rewrite made on any row + applies to every matched row, and a rewrite *decided* per row must assign the + same key set on every row, in place (`ctx.input.x = …`, never a replaced + object) — the engine refuses the batch whole, before any write, when two rows' + key sets differ (`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, status 400; ADR-0058 + Amendment II.3). - **`after*` hooks fire PER ROW, so a bulk write needs no special condition.** A predicate (`multi: true`) update/delete dispatches its `after*` hooks **once per matched row**, each on a single-record-shaped context — @@ -272,10 +279,9 @@ platform could not work out what the condition says" are now different outcomes and the second one is loud. Practical consequence when authoring: spell keys against the object's **declared** -fields, and put a condition that reads `previous` on an **after-type** event — -never on an insert event, and never on a `before*` hook that can fire on a -`multi: true` write. That mistake used to cost you a hook that quietly never ran, -and now costs you every write the hook is attached to. +fields, and put a condition that reads `previous` on an update or delete event — +either phase — never on an insert event. That mistake used to cost you a hook +that quietly never ran, and now costs you every write the hook is attached to. #### `onError` — Error Handling diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 5381d60503..5852c9cbb4 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -305,7 +305,7 @@ roots — one scope, one meaning, whichever surface reads it. | Insert events (`beforeInsert` / `afterInsert`), validation rule on insert | **unbound** — there is no prior state | | **`after*` hook `condition` / record-change flow trigger on a predicate (`multi: true`) write** | **that row's pre-write row** — a bulk write fires after-hooks once PER MATCHED ROW | | Validation rule on a predicate bulk update | that row's pre-write row — per row | -| `before*` hook `condition` on a predicate (`multi: true`) write | **unbound** — a `before*` hook fires ONCE for the whole batch (it may still rewrite the shared payload), so there is no single prior record. `record` is the bare payload here too, so a *declared* field this write does not set is unevaluable as well | +| `before*` hook `condition` on a predicate (`multi: true`) write | **that row's pre-write row** — `beforeUpdate` / `beforeDelete` dispatch once PER MATCHED ROW too, each on a single-record-shaped context whose `input.id` names the row (ADR-0058 Addendum II, D1/D2); `record` is that row's state, stored ⊕ payload, as on any update. Only the *payload* stays batch-scoped — see below the table | ⚠️ **An unevaluable condition ABORTS the operation.** Referencing `previous` where it is unbound — like a typo'd key (`record.stauts`), a retired @@ -317,8 +317,8 @@ runs). A condition that does not even **compile** aborts the same way. So write insert-event conditions over `record` alone. **A transition condition needs no special handling for bulk writes.** -Write it once, on an `after*` event, and it means the same thing whether the -write carries an id or a predicate: +Write it once, on a `before*` or an `after*` event, and it means the same thing +whether the write carries an id or a predicate: ```ts // Fires once per row that ACTUALLY transitioned — on `update(id)` and on @@ -329,14 +329,21 @@ P`previous.status != 'done' && record.status == 'done'` A predicate (`multi: true`) write is N record changes, so every record-scoped declaration on it is evaluated **per row** — `previous` is that row's own pre-write state, `record` its real state, not the bare payload (ADR-0058, -bulk-write addendum). Record-change flow triggers ride the same dispatch. The -one exception is the `before*` row of the table above, and it is not a bug to be -fixed later: put transition conditions on `after*`, and keep `before*` -conditions to the incoming payload (`record.`). - -Above ~10 000 matched rows the platform refuses a predicate write on an object -with after-hooks rather than fan out that many handler runs inside one write — -paginate the write. It is a refusal, never a silent downgrade to one hook call. +bulk-write Addenda I and II). Record-change flow triggers ride the same +dispatch. `before*` is no exception to the *condition*; the asymmetry is what a +`before*` handler WRITES: one `updateMany` takes one SET clause, so every row's +dispatch carries the one payload and a rewrite made on any row applies to every +matched row (D3). A rewrite *decided* per row must assign the same key set on +every row, in place — assigned onto the payload object, never a replaced one — +because the engine records the keys each row's chain wrote and refuses the +batch whole, before any write, when two rows disagree +(`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, status 400; ADR-0058 Amendment II.3). + +Above 10 000 matched rows the platform refuses a predicate write on an object +with per-row hooks in either phase — one ceiling for `before*` and `after*`, +checked before the first dispatch (ADR-0058 Addendum II, D6) — rather than fan +out that many handler runs inside one write; paginate the write. It is a +refusal, never a silent downgrade to one hook call. --- @@ -364,10 +371,10 @@ When migrating Salesforce-flavor metadata, apply these rules in order: | `MONTH_DIFF`, `MID`, `LEFT`, `RIGHT`, `SUBSTITUTE` | _not in stdlib — propose addition_ | > ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which exists only -> where `previous` is **bound** — see §5. On an insert event, or in a `before*` -> hook condition on a `multi: true` predicate write, it is not; that -> does not quietly skip the hook, it **fails the write**. On `after*` events it -> IS bound, per matched row, on bulk and single-record writes alike. +> where `previous` is **bound** — see §5. On an insert event it is not; that +> does not quietly skip the hook, it **fails the write**. On update and delete +> events it IS bound, per matched row, in `before*` and `after*` alike, on bulk +> and single-record writes. > ⚠️ **Flow conditions are the exception to row 1.** The automation engine > spreads the record's variables to top level, so a bare `status` resolves in a