Commit ea1d916
* 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 move #5038 made for the `after*` phase, held to the
same yardstick. ADR-0058 Addendum II (ruling B, 2026-08-06) is the contract;
`packages/spec/src/data/bulk-write-hook-conformance.ts` states it as D1-D7 and
its `delivered` flags flip here.
The harm: `ctx.previous` was never bound in the before phase of a predicate
write, so every guard written as `if (ctx.previous?.locked) throw` passed
silently on every batch — fail-open, and invisible.
- D1/D2: one dispatch per matched row; `previous` is that row's pre-image,
`result` stays absent, `input.options` is still the caller's bag. Zero
matched rows is zero dispatches.
- D3: the payload stays BATCH-scoped. Per-row contexts share THE payload
object, rewrites apply to the whole batch and accumulate in dispatch order
(including a REPLACED `input.data`). One updateMany, one affected count.
- D4 + ADR-0058 Amendment II.1: `input.id` stops being a reroute lever, on the
by-id path too. Clearing it converted a by-id write into a predicate write;
rebinding moved the write to a row whose pre-image, readonlyWhen locks and
validation rules were never evaluated. Both now reject with
`HookTargetRebindError` (`ERR_HOOK_TARGET_REBIND`), naming the retired
capability and its three replacements.
- D6: one ceiling for both phases, checked before the FIRST dispatch. The
engine's open-coded ceiling and message are replaced by the spec module's
`resolveBulkPerRowHookBudget`.
- D7: the matched row set is read ONCE and serves validation (#3106), the
readonlyWhen strip (#3042) and both per-row dispatches.
#5846 (a): `update()` reads its prior row BEFORE dispatching `beforeUpdate` and
binds `previous` there, matching `delete()` since #5272, so both phases share
one read. `sys_fetch_previous_update` is retired — its `!ctx.previous` guard is
now permanently false.
ADR-0049: `HookConditionLimitation` (both members), `isPredicateBulkWrite` and
`predicateBulkWrite` are retired — a batch-scoped `before*` dispatch 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.
fix(plugin-auth): the last-administrator break-glass guard resolved its target
set as "a scalar `input.id` if there is one, else the predicate", which was
sound only while a predicate write's `before*` left `input.id` undefined. Under
per-row dispatch a `multi` ban of every administrator arrived as N individually
legitimate by-id bans and locked the environment out. `resolveTargetIds` now
asks `options.multi` first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZgeUyxzRnXzNCq8vizVoQ
* chore(objectql,scripts): satisfy the erasure ratchet and the ADR-0112 anchor (#5574)
- The new bulk-write before-phase cases passed their engine options through
`as any`. They are ordinary in-contract calls (`EngineUpdateOptions` /
`EngineDeleteOptions` both declare `multi` and `where`), so they are typed
instead: the test surface returns to its 263 ceiling rather than being raised
for tests that never needed the erasure.
- The `update()` restructure removed three `any`-erased engine option sites, so
`packages/objectql/src/engine.ts` ratchets 12 → 9 in the baseline.
- `hook-wrappers.ts` lost its ADR-0112 reference when the batch-write branch of
the condition diagnosis was retired. The rule it anchored did NOT go with the
branch — no discriminator on `HookConditionError` is ever `error.code`, for
every field the class still carries — so it is restated where it now applies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZgeUyxzRnXzNCq8vizVoQ
* docs(objectql): record the measured #5929 position of sys_fetch_previous_delete
The retirement note next door claimed the delete-side builtin's guard was still
reachable. It is not, and the reason is worth handing to #5929 rather than
leaving to be rediscovered: the builtin is a 'beforeDelete' hook on '*', so it
holds open the very demand gate whose read then binds 'previous' before it runs
— its only remaining effect is to make itself redundant. Retiring it stays
#5929's card, since that card owns the gate's per-object honesty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZgeUyxzRnXzNCq8vizVoQ
* refactor(objectql): split the delete-side by-id repoint out of #5574's settlement (#6752)
Amendment II.1 retired the `input.id` reroute lever, and the first cut applied
the refusal to `delete()`'s by-id REPOINT as well — folding a behaviour removal
into an ordering change. Split out; #5272's re-read is restored verbatim.
The two verbs now answer a rebind differently, and the asymmetry is the point
rather than a leftover. The case against honouring a rebind is that the write
lands 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" the ruling forbids. So `update()` refuses and `delete()` keeps
honouring, until the repoint is ruled on as its own question.
A CLEARED id stays refused on both verbs, and that is not a discretionary
choice: it worked by falling through to the predicate branch, and the ladder is
now resolved before any handler runs because a per-row `before*` context is
built from the matched row set. That is the capability the ruling names.
- `engine.ts`: delete() by-id restores the repoint re-read; refuses only a clear.
- `hook-target-rebind-errors.ts`, ADR-0058 Amendment II.1 (now a scope table),
the changeset: state which cell answers what, and why the row is uneven.
- `bulk-write-per-row-hooks.test.ts` §7 D4: the repoint case is inverted into a
pin that the REPOINTED row is the one deleted and `afterDelete` sees its
pre-image, so neither direction gets "tidied up" by a later reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZgeUyxzRnXzNCq8vizVoQ
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 559206b commit ea1d916
25 files changed
Lines changed: 2062 additions & 960 deletions
File tree
- .changeset
- docs/adr
- packages
- objectql/src
- plugins/plugin-auth/src
- spec/src/data
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
289 | 289 | | |
290 | 290 | | |
291 | 291 | | |
| 292 | + | |
292 | 293 | | |
293 | 294 | | |
294 | | - | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
295 | 375 | | |
296 | 376 | | |
297 | 377 | | |
| |||
0 commit comments