Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .changeset/bulk-write-before-hooks-per-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
"@objectstack/objectql": minor
"@objectstack/spec": patch
---

feat(objectql): dispatch `before*` hooks per matched row on a predicate bulk write (#5574, #5846)

A `multi: true` update or delete now dispatches `beforeUpdate` / `beforeDelete`
**once per matched row**, on a single-record-shaped context carrying that row's
`id` and `previous` — the same move #5038 made for the `after*` phase, held to
the same yardstick. ADR-0058 Addendum II (maintainer ruling B, 2026-08-06) is
the contract; `packages/spec/src/data/bulk-write-hook-conformance.ts` states it
as clauses D1–D7, and its `delivered` flags flip with this change.

**The harm this fixes.** `ctx.previous` was never bound in the before phase of a
predicate write, so every guard written the way guards are written —
`if (ctx.previous?.locked) throw` — passed **silently** on every batch. The
failure direction is fail-OPEN and the optional chaining that makes it silent is
exactly what an AI writes. One measured deployment had all 15 of its guard hooks
bypassed by a single batch edit, including writing `null` into a `readonly: true`
field that the single-id path refuses.

**Two visible behaviour changes, both loud.**

- **Guards now fire per row on predicate writes.** A `beforeUpdate` /
`beforeDelete` hook on an object targeted by a `multi: true` write runs N times
instead of once, each time with that row's `previous` bound. Zero matched rows
is zero dispatches. A hook that throws refuses the whole batch before anything
is written. The payload stays **batch-scoped** (D3): every per-row context
carries the one payload, so a rewrite applies to every matched row whichever
row's dispatch made it, rewrites accumulate in dispatch order, and no predicate
write is ever split into N single-row writes — one `updateMany`, one affected
count (#4639), one aggregate event. A rewrite *conditioned* on the row is
therefore out of contract: it widens to the whole batch rather than scoping
itself. Per-row `previous` is supplied so a guard can REFUSE, not so a rewrite
can be aimed.
- **The `input.id` reroute lever is retired and now refuses.** The dispatch
ladder is resolved **before** the before phase — it has to be, since per-row
contexts are built from the matched row set — so the id slot can no longer
steer the write. Rather than ignore an assignment (a silent no-op) or honour
it blindly, the write is rejected with `HookTargetRebindError`
(`ERR_HOOK_TARGET_REBIND`), whose message names the retired capability and the
three supported replacements. Recorded as ADR-0058 Amendment II.1. Precisely:

| | CLEARED id | REBOUND to another id |
|---|---|---|
| `update()` by-id | refused | refused |
| `delete()` by-id | refused | **honoured, unchanged** (#5272's re-read) |
| either, per-row | refused (D4) | refused (D4) |

Clearing is uniform because it worked by falling through to the predicate
branch, and that branch is now chosen before any handler runs. Rebinding is
not uniform, deliberately: the case against honouring it is that the write
lands on a row whose pre-image and rules were never evaluated, and on
`delete()` that is simply not true — #5272 already re-resolves the new target
before `afterDelete` or the summary recompute sees it. `update()` has no such
mechanism and building one would be the "silently pick re-resolution instead"
the ruling forbids. Retiring the delete-side repoint is its own question,
filed as #6752 rather than ridden in on an ordering change.

**Also in this change.**

- **One read, reused (D7).** The matched row set is read ONCE per predicate
write, with the write's own composed AST, and serves per-row validation
(#3106), the `readonlyWhen` strip (#3042) and both per-row dispatches.
- **One ceiling, both phases (D6).** `MAX_BULK_PER_ROW_HOOK_ROWS` (10 000) now
governs `before*` as well as `after*`, checked **before the first dispatch**, so
an over-ceiling batch runs zero handlers and writes nothing — a refusal, never
a downgrade to one dispatch. The engine's open-coded ceiling and refusal
message are replaced by the spec module's `resolveBulkPerRowHookBudget`, so the
number and the wording have one definition again.
- **`update()` binds `previous` before the before phase (#5846 (a)).** The by-id
path reads its prior row ahead of the dispatch, matching `delete()`'s shape
since #5272, so both phases share one read. objectql's
`sys_fetch_previous_update` builtin is **retired**: it existed to bind
`previous` for the before phase behind `if (input.id && !ctx.previous)`, and
that guard is now permanently false. A by-id update on a kernel used to read
the same row three times; this removes one and makes the engine's read the
single producer.
- **`HookConditionLimitation` is retired** (ADR-0049 enforce-or-remove), with
`isPredicateBulkWrite` and the `predicateBulkWrite` flag. Both members
(`bulk_write_previous_unbound`, `bulk_write_stored_state_unavailable`)
described a batch-scoped `before*` dispatch that no longer exists, leaving them
with neither producer nor reachable consumer. A `previous`-reading `before*`
condition on a bulk write now **evaluates as authored**, per row, instead of
rejecting the batch. `HookConditionError` itself is unchanged — an unevaluable
condition still aborts the operation (#4775).

**Migrating.** A handler that cleared `ctx.input.id` — or rebound it on an
`update()` — must instead write through `ctx.api` / `ctx.ql` for the row it
means, have the caller pass `{ multi: true, where: … }`, or throw to refuse the
write. A `beforeDelete` handler that repoints the target is unaffected. A `beforeUpdate` hook
with side effects on an object that receives bulk writes should expect to run
per row; a batch-wide effect belongs in a payload rewrite, which is still
batch-scoped.
25 changes: 25 additions & 0 deletions .changeset/last-admin-guard-per-row-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/plugin-auth": patch
---

fix(plugin-auth): keep the last-administrator guard exact when `before*` hooks fire per row (#5574)

The break-glass guard resolved a write's target set as "a scalar `input.id` if
there is one, otherwise the caller's predicate". That was sound only because a
predicate (`multi: true`) write's `before*` dispatch left `input.id`
present-but-**undefined**. ADR-0058 Addendum II makes the `before*` phase fire
once per MATCHED ROW, each context naming its own row — so read that way, a
`multi` ban of every administrator arrives as N separate by-id bans, each of
which is legitimately allowed (banning one admin out of three leaves two), and
the batch locked the environment out with no refusal anywhere.

`resolveTargetIds` now asks `options.multi` FIRST: on a predicate write the
target set is the caller's predicate, whichever row the current dispatch names;
the id is consulted only when the write really is by-id. `input.options` is the
caller's bag during `before*` — `where` and `multi` included — and the contract
preserves that deliberately, so the discriminator the guard needs is unchanged.

All eight guarded halves (#5892 ban, #5941 delete, #5978 standing) are covered
by the existing predicate cases, which went red on the engine change and are now
the pin that a population-scoped invariant survives being asked one row at a
time.
82 changes: 81 additions & 1 deletion docs/adr/0058-expression-and-predicate-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,89 @@
> here: it is a live-behaviour question on a seam another card owns, and this
> appendix will not presume the answer. The engine half and #5846 settle it
> together, in one edit to one ordering, and record it as an amendment.
> **→ Settled in Amendment II.1 below.**
> - **`scripts/adr-anchors.json`'s `hook-wrappers.ts` invariant still describes
> the batch dispatch.** It is TRUE today and must move with the engine half,
> not before it.
> not before it. **→ Moved with #5574's engine half.**

---

> **Amendment II.1 (2026-08, #5574 engine half + #5846 (a)) — the `input.id`
> reroute lever is RETIRED, and refused loudly.**
> _Settles the item Addendum II left open by name. Delivered in the same PR that
> delivered the per-row `before*` dispatch, because it is the same edit to the
> same ordering._
>
> **The capability, stated exactly.** `update()` and `delete()` dispatched their
> `before*` event FIRST and only then read `hookContext.input.id` to choose the
> driver call. The id slot therefore doubled as a control lever: a handler
> assigning `ctx.input.id = undefined` on a by-id call converted the write into
> a PREDICATE write over the caller's `where`; a handler assigning a different
> id moved the write to another row (`delete()` supported that explicitly, by
> re-reading the pre-image for the new target — #5272).
>
> **Why it cannot survive the reorder.** A per-row `before*` context is BUILT
> from the matched row set, so the row set must be in hand before the first
> dispatch, so the branch that decides whether there IS a row set must be
> decided before that. #5846's (a) direction lands in the same edit: the by-id
> path reads its prior row ahead of the dispatch and binds `previous` there. By
> the time any handler runs, the target is settled — `previous`, the
> `readonlyWhen` strip and every validation rule have already been computed
> against the row the ladder chose.
>
> **The three options, and the choice.** *Ignore it* — the assignment retargets
> nothing and says nothing, which is the silent no-op D4 exists to abolish, and
> here the write still lands on the ORIGINAL row. *Honour it by re-resolving* —
> the write lands on a row whose pre-image was never read, whose `readonlyWhen`
> locks were never evaluated and whose rules were checked against a different
> record: silently weaker enforcement, aimed by a hook. *Refuse* — chosen. The
> write is rejected with `HookTargetRebindError`
> (`objectql/src/hook-target-rebind-errors.ts`, code `ERR_HOOK_TARGET_REBIND`,
> an `ERR_`-prefixed operational code on the error's own bag and deliberately
> NOT an ADR-0112 wire code, same reasoning as the budget refusal). The message
> NAMES the retired capability, so an author whose handler stopped working
> learns what changed instead of watching a write land somewhere unexpected.
>
> **Scope, stated precisely, because the two verbs do NOT answer alike.**
>
> | | CLEARED id | REBOUND to another id |
> |---|---|---|
> | `update()` by-id | refused | refused |
> | `delete()` by-id | refused | **honoured** (#5272's re-read, unchanged) |
> | either, per-row | refused (D4) | refused (D4) |
>
> The CLEARED column is uniform because the ladder reorder leaves it no answer
> of its own: clearing worked by falling through to the predicate branch, and
> that branch is chosen before any handler runs. That is the capability this
> amendment retires, and it is the one the ruling names.
>
> The REBOUND column is not uniform, and the asymmetry is principled rather
> than an oversight. The case against honouring a rebind is that the write would
> land on a row whose pre-image, `readonlyWhen` locks and validation rules were
> never evaluated — and on `delete()` that is simply not true: #5272 already
> RE-RESOLVES the new target, re-reading its pre-image and rebinding `previous`
> before `afterDelete` or the summary recompute can see it. `update()` has no
> such mechanism and would have to grow one, which is the "silently pick
> re-resolution instead" this ruling forbids. So `update()` refuses and
> `delete()` keeps honouring, until the delete-side repoint is ruled on as its
> own question (#6752) — deliberately NOT folded in here as a rider on an
> ordering change.
>
> Premise for the retirement, checked against `origin/main`: the only
> `ctx.input.id` assignment in the whole repository was one engine test forcing
> the fail-closed AST assertion, which is now the refusal's own pin.
>
> **What replaces it, for each thing it was used for.** Write a different row:
> `ctx.api` / `ctx.ql` for that row explicitly. Write many rows: have the caller
> pass `{ multi: true, where: … }`. Stop this write: throw from the handler —
> the supported way for a `before*` guard to refuse, and the one the per-row
> `previous` binding exists to enable.
>
> **One consequence priced with it.** `ENGINE_UPDATE_REJECT_MESSAGE` /
> `ENGINE_DELETE_REJECT_MESSAGE` used to be raised AFTER the before phase, so a
> handler binding `input.id` could convert a rejecting call into a by-id write.
> That is the same lever pointed the other way; with the ladder resolved first
> the refusal lands before any handler runs and before anything is read.

---

Expand Down
Loading
Loading