Skip to content
Merged
77 changes: 77 additions & 0 deletions .changeset/hook-dispatch-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/plugin-sharing": patch
"@objectstack/service-storage": patch
---

fix(spec,objectql,sharing,storage): a hook can tell a per-row bulk dispatch from a single-record write again (#6966)

A predicate (`multi: true`) write dispatches its lifecycle hooks **once per
matched row** — `after*` since #5038, `before*` since #5574 — on a context
deliberately indistinguishable from a single-id write's, so a handler written
for one record works unchanged on a batch. That indistinguishability is the
feature, and it also erased the only signal several handlers had.

Before #5574 a bulk `before*` fired once with `input.id` present-but-`undefined`,
so "`input.id` is empty" meant "this call stands for N rows". Guards across the
platform were written on it. Every one of them **silently inverted** rather than
failing: a per-row context has an id, so the guard now answers "single write" for
every row of a batch. Two further assumptions broke with it — that the engine
reuses one `HookContext` across a write's before/after pair, and that `after*`
work keyed on the write's row set runs once.

### New: `HookContext.dispatch`

The engine now states the fact rather than leaving it to be inferred:

```ts
ctx.dispatch // { mode: 'record' | 'per-row', index: number, scope: object } | undefined
```

- `mode` — `'record'` when the call is the caller's whole write; `'per-row'`
when it is one of N.
- `index` — position in the fan-out. `index === 0` is how a handler does
batch-scoped work once instead of N times.
- `scope` — scratch shared by **every** dispatch of one write, both phases, same
object identity. This is the seam handlers used to get by stashing on the
context itself, which only ever worked because a single-id write reuses one
context across its pair.

Bound at every write dispatch site — insert, update, delete, both phases.
Optional, and an absent marker reads as "not a per-row dispatch", so a handler
reads `ctx.dispatch?.mode === 'per-row'` and existing code keeps its behaviour.
Reads carry no marker: a read has no fan-out.

It is deliberately **not** the `isPredicateBulkWrite` discriminator #5574
retired. That one was removed under ADR-0049 for having neither a producer nor a
reachable consumer — it inferred "bulk" from `input.id` and `options.multi` at
the consumer, which is exactly what `asScalarId` stays unexported to prevent
(#4434 / #4550). This one is produced by the engine at the point the dispatch
ladder is decided, and the platform's own handlers read it.

### Behaviour fixed

**Sharing rules and the record-share cascade (`@objectstack/plugin-sharing`).**
The `before*` hook stashes the write's affected row set for the `after*` hook to
act on. On a predicate write that stash was landing on a per-row context the
`after` phase never saw, so `readAffectedRows` answered `resolve-failed` and both
subscribers took their safe branch: every bulk update or delete on a ruled object
revoked **all** of that object's rule grants and queued a full asynchronous
re-grant — once per matched row, with the repeats racing each other's re-grants.
Access was never widened (the trade is the ruling's "over-granting is an
incident, under-granting is a wobble" direction), but a bounded write now takes
the bounded path again: the rows are unioned as the engine hands them over, the
cap still applies to the union, and the `after*` work runs once per write.

**File-reference ownership (`@objectstack/service-storage`).** The `beforeDelete`
hook that pre-resolved ids for a `where`-shaped delete was dead on every path,
and `afterDelete` was falling back to one `sys_file` lookup **per row** where the
batch fits one `$in`. Both are fixed by the marker, and the pre-resolution query
is gone entirely — the engine has already matched the rows and hands them over.
The `beforeUpdate` copy-on-claim pass no longer runs once per row against a
batch-scoped payload, which also removes a row-conditioned rewrite of a shared
`SET` clause (out of contract under ADR-0058 Addendum II D3).

No authored metadata changes, and no write's result, event or return contract
changes.
1 change: 1 addition & 0 deletions content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const result = HookContextSchema.parse(data);
| **input** | `Record<string, any>` | ✅ | Mutable input parameters |
| **result** | `any` | optional | Operation result (After hooks only) |
| **previous** | `Record<string, any>` | optional | Record state before operation |
| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record<string, any> }` | optional | How this hook call relates to the caller's write (engine-produced; #6966) |
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **transaction** | `any` | optional | Database transaction handle |
Expand Down
22 changes: 11 additions & 11 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 436 |
| Still-open (strip) sites | 180 |
| Object sites in them | 437 |
| Still-open (strip) sites | 181 |
| Files carrying at least one | 27 |

Remaining strip sites by class:
Expand All @@ -31,7 +31,7 @@ Remaining strip sites by class:
|---|---|
| authorable — the ruling's forced scope | 41 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 104 |
| wire / open — out of forced scope | 105 |
| no door — no carrier, ADR-0049 territory | 1 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |
Expand All @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th
| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 160 | 118 | 5 | 0 | 37 |
| `data/` | 164 | 56 | 1 | 0 | 107 |
| `data/` | 165 | 56 | 1 | 0 | 108 |
| `automation/` | 65 | 42 | 0 | 0 | 23 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **436** | **250** | **6** | **0** | **180** |
| **total** | **437** | **250** | **6** | **0** | **181** |

## File-level triage — site counts

Expand Down Expand Up @@ -102,14 +102,14 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `field.zod.ts` | 11 |
| `filter.zod.ts` | 11 |
| `hook-body.zod.ts` | 2 |
| `hook.zod.ts` | 6 |
| `hook.zod.ts` | 7 |
| `mapping.zod.ts` | 3 |
| `object.zod.ts` | 20 |
| `query.zod.ts` | 5 |
| `seed-loader.zod.ts` | 12 |
| `seed.zod.ts` | 1 |
| `validation.zod.ts` | 6 |
| **total** | **164** |
| **total** | **165** |

### `automation/` — sites

Expand Down Expand Up @@ -179,7 +179,7 @@ over it is here.

### `data/` — open

**107 strip of 164**, in 16 file(s).
**108 strip of 165**, in 16 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All @@ -195,17 +195,17 @@ over it is here.
| `field-value.zod.ts` | 1 | 2 |
| `field.zod.ts` | 3 | 11 |
| `filter.zod.ts` | 11 | 11 |
| `hook.zod.ts` | 4 | 6 |
| `hook.zod.ts` | 5 | 7 |
| `object.zod.ts` | 1 | 20 |
| `query.zod.ts` | 4 | 5 |
| `seed-loader.zod.ts` | 12 | 12 |
| **total** | **107** | **164** |
| **total** | **108** | **165** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 9 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 65 |
| wire / open — out of forced scope | 66 |
| no door — no carrier, ADR-0049 territory | 0 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 0 |
Expand Down
177 changes: 177 additions & 0 deletions packages/objectql/src/bulk-write-per-row-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,183 @@ describe('[#5574 / D7] the matched row set is read ONCE, and serves everything',
});
});

/* ────────────────────────────────────────────────────────────────────────────
* 8. [#6966] The dispatch marker — the fact D1/D2's indistinguishability erased
*
* Sections 1–7 make a per-row context deliberately indistinguishable from a
* single-id one, which is what lets a handler written for one record work
* unchanged on a batch. The cost is that "am I one of N?" became unanswerable,
* and the guards that had been answering it from `input.id`'s shape — "no id
* means bulk" — silently inverted rather than failing. `HookContext.dispatch`
* is that question restored as an engine-stated fact.
*
* Pinned here rather than in a file of its own for section 7's reason: the
* marker exists to describe THIS fan-out, and a marker that drifts from the
* dispatch it describes is worse than none.
* ──────────────────────────────────────────────────────────────────────────── */

describe('[#6966] every dispatched context carries the marker, on all four write paths', () => {
const seenOn = async (event: string, drive: (engine: ObjectQL, ids: string[]) => Promise<unknown>) => {
const seen: Array<{ mode: unknown; index: unknown; id: unknown }> = [];
const { engine } = await boot([hook('marker', event, (ctx) => {
const d = (ctx as any).dispatch;
seen.push({ mode: d?.mode, index: d?.index, id: (ctx.input as any)?.id });
})]);
const rows = await seedTasks(engine, [
{ title: 'a', status: 'todo' },
{ title: 'b', status: 'todo' },
]);
await drive(engine, rows.map((r) => String(r.id)));
return seen;
};

it('single-id UPDATE ⇒ one dispatch, mode "record", index 0', async () => {
for (const event of ['beforeUpdate', 'afterUpdate']) {
const seen = await seenOn(event, (engine, ids) =>
engine.update('task', { status: 'done' }, { where: { id: ids[0] } }));
expect(seen, event).toEqual([{ mode: 'record', index: 0, id: seen[0].id }]);
}
});

it('single-id DELETE ⇒ one dispatch, mode "record", index 0', async () => {
for (const event of ['beforeDelete', 'afterDelete']) {
const seen = await seenOn(event, (engine, ids) => engine.delete('task', { where: { id: ids[0] } } as any));
expect(seen, event).toEqual([{ mode: 'record', index: 0, id: seen[0].id }]);
}
});

it('predicate UPDATE ⇒ N dispatches, mode "per-row", index counting 0..N-1', async () => {
for (const event of ['beforeUpdate', 'afterUpdate']) {
const seen = await seenOn(event, (engine) =>
engine.update('task', { status: 'done' }, { multi: true, where: { status: 'todo' } }));
expect(seen.map((s) => s.mode), event).toEqual(['per-row', 'per-row']);
// The INDEX is the member a consumer keys "do this once per batch" on, so
// it must count rather than repeat the batch context's 0.
expect(seen.map((s) => s.index), event).toEqual([0, 1]);
expect(new Set(seen.map((s) => s.id)).size, event).toBe(2);
}
});

it('predicate DELETE ⇒ N dispatches, mode "per-row", index counting 0..N-1', async () => {
for (const event of ['beforeDelete', 'afterDelete']) {
const seen = await seenOn(event, (engine) =>
engine.delete('task', { multi: true, where: { status: 'todo' } } as any));
expect(seen.map((s) => s.mode), event).toEqual(['per-row', 'per-row']);
expect(seen.map((s) => s.index), event).toEqual([0, 1]);
expect(new Set(seen.map((s) => s.id)).size, event).toBe(2);
}
});

it('a batch INSERT is per-row too, and a single insert is not', async () => {
const batch: any[] = [];
const { engine } = await boot([hook('marker', 'beforeInsert', (ctx) => {
batch.push((ctx as any).dispatch);
})]);
await engine.insert('task', [{ title: 'a' }, { title: 'b' }, { title: 'c' }] as any);
expect(batch.map((d) => [d.mode, d.index])).toEqual([['per-row', 0], ['per-row', 1], ['per-row', 2]]);

batch.length = 0;
await engine.insert('task', { title: 'solo' } as any);
expect(batch.map((d) => [d.mode, d.index])).toEqual([['record', 0]]);
});

it('NO handler is ever dispatched on a context without the marker', async () => {
// The contract the JSDoc states, pinned as an invariant rather than
// path-by-path. It is what makes `ctx.dispatch?.mode` safe to read without
// a "what if the engine did not bind it" branch — and it covers the one
// context a reader might worry about: `update()`/`delete()` keep a
// BATCH-scoped `hookContext` on the predicate path, and the claim is that
// no handler ever sees it (the per-row loop runs whenever `hasHooksFor` is
// true, and when it is false no handler runs at all).
const unmarked: string[] = [];
const events = [
'beforeInsert', 'afterInsert',
'beforeUpdate', 'afterUpdate',
'beforeDelete', 'afterDelete',
];
const { engine } = await boot(events.map((e) => hook(`probe_${e}`, e, (ctx) => {
const d = (ctx as any).dispatch;
if (!d || (d.mode !== 'record' && d.mode !== 'per-row') || typeof d.index !== 'number' || !d.scope) {
unmarked.push(`${e}:${JSON.stringify(d)}`);
}
})));

// Every write shape this engine can take, in one pass.
await engine.insert('task', { title: 'solo', status: 'todo' } as any);
const batch: any = await engine.insert('task', [
{ title: 'a', status: 'todo' },
{ title: 'b', status: 'todo' },
] as any);
await engine.update('task', { status: 'mid' }, { where: { id: batch[0].id } });
await engine.update('task', { status: 'done' }, { multi: true, where: { status: 'todo' } });
await engine.delete('task', { where: { id: batch[0].id } } as any);
await engine.delete('task', { multi: true, where: { status: 'done' } } as any);

expect(unmarked).toEqual([]);
});
});

describe('[#6966] `scope` is one object per WRITE, spanning both phases', () => {
it('carries a before-phase stash to the after phase of a predicate update', async () => {
// The regression this closes: a per-row `before*` context is freshly built,
// so a handler stashing on the CONTEXT (which is what every consumer did,
// because a single-id write reuses one context across its pair) wrote to an
// object the after phase never sees.
const arrived: unknown[] = [];
const { engine } = await boot([
hook('stash', 'beforeUpdate', (ctx) => {
const scope = (ctx as any).dispatch.scope as Record<string, unknown>;
((scope.ids ??= []) as unknown[]).push((ctx.input as any).id);
}),
hook('read', 'afterUpdate', (ctx) => {
arrived.push(JSON.stringify(((ctx as any).dispatch.scope as any).ids));
}),
]);
const rows = await seedTasks(engine, [
{ title: 'a', status: 'todo' },
{ title: 'b', status: 'todo' },
]);
const ids = rows.map((r) => String(r.id));

await engine.update('task', { status: 'done' }, { multi: true, where: { status: 'todo' } });

// Every after dispatch sees the WHOLE batch's collection — all before
// dispatches complete before the write, so the union is closed by then.
expect(arrived).toEqual([JSON.stringify(ids), JSON.stringify(ids)]);
});

it('is per WRITE — a second call gets a fresh scope, never the first call\'s', async () => {
const scopes: unknown[] = [];
const { engine } = await boot([hook('mark', 'beforeUpdate', (ctx) => {
const scope = (ctx as any).dispatch.scope as Record<string, unknown>;
scopes.push(scope);
scope.touched = true;
})]);
const rows = await seedTasks(engine, [{ title: 'a', status: 'todo' }]);

await engine.update('task', { status: 'mid' }, { where: { id: rows[0].id } });
await engine.update('task', { status: 'done' }, { where: { id: rows[0].id } });

expect(scopes).toHaveLength(2);
expect(scopes[0]).not.toBe(scopes[1]);
});

it('is the SAME object across a single-id write\'s before/after pair', async () => {
const scopes: unknown[] = [];
const grab = (ctx: HookContext) => { scopes.push((ctx as any).dispatch.scope); };
const { engine } = await boot([
hook('b', 'beforeUpdate', grab),
hook('a', 'afterUpdate', grab),
]);
const rows = await seedTasks(engine, [{ title: 'a', status: 'todo' }]);

await engine.update('task', { status: 'done' }, { where: { id: rows[0].id } });

expect(scopes).toHaveLength(2);
expect(scopes[0]).toBe(scopes[1]);
});
});

/* ────────────────────────────────────────────────────────────────────────────
* Harness
* ──────────────────────────────────────────────────────────────────────────── */
Expand Down
Loading
Loading