Skip to content

Commit fb5fbb8

Browse files
os-litantclaude
andauthored
feat(runtime): marshal the per-row dispatch signal and the D2 options projection into the hook body sandbox context (#12217)
* feat(runtime): marshal the per-row dispatch signal and D2 options projection into the hook body sandbox context A shipped (L2) hook body now observes ctx.dispatch = frozen { mode, index } (the #6966 engine marker, minus scope — shared identity cannot survive a JSON copy) and ctx.input.options = frozen, non-enumerable { multi?, where? } (the projection ADR-0058 Addendum II D2 declares before*-visible). Closes the declared-vs-observable gap that made D3's routes 1 and 2 inexpressible from a body-only hook. Enumeration stays flat-only and the write-back channel cannot carry the grafted keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV * fix(runtime): pass the required packageId to registerObject in the #11552 conformance harness The TEST_DEBT re-measure lifts the tsconfig test exclusion, and the 1-arg registerObject spelling (copied from a sibling harness whose error is frozen debt) added one raw tsc error (TS2554) to runtime's frozen 227. Fixed at the call, not the ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b478e1 commit fb5fbb8

7 files changed

Lines changed: 468 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility.

packages/runtime/src/sandbox/body-runner.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,78 @@ describe('hookBodyRunnerFactory', () => {
256256
expect(await probeUser({})).toBe('null');
257257
});
258258
});
259+
260+
// [#11552] The per-row dispatch signal and the D2 options projection cross
261+
// the sandbox boundary — the unit half; the real-engine composition (flat
262+
// proxy from `installFlatInput`, predicate dispatch, write-back) is pinned
263+
// in `perrow-dispatch-signal.integration.test.ts`.
264+
describe('marshals ctx.dispatch and input.options onto the hook body face (#11552)', () => {
265+
const probeSignal = (engineCtx: Record<string, unknown>) => {
266+
const fn = hookBodyRunnerFactory(runner, { ql: {}, appId: 'crm' })({
267+
name: 'probe_signal',
268+
object: 'contact',
269+
events: ['beforeUpdate'],
270+
body: {
271+
language: 'js',
272+
source:
273+
'return { seen: JSON.stringify({'
274+
+ ' dispatchType: typeof ctx.dispatch,'
275+
+ ' mode: ctx.dispatch ? ctx.dispatch.mode : null,'
276+
+ ' index: ctx.dispatch ? ctx.dispatch.index : null,'
277+
+ ' scopeType: ctx.dispatch ? typeof ctx.dispatch.scope : null,'
278+
+ ' optionsType: typeof ctx.input.options,'
279+
+ ' multi: ctx.input.options ? ctx.input.options.multi : null,'
280+
+ ' where: ctx.input.options ? ctx.input.options.where : null,'
281+
+ ' contextType: ctx.input.options ? typeof ctx.input.options.context : null,'
282+
+ ' inputKeys: Object.keys(ctx.input).sort(),'
283+
+ ' }) };',
284+
capabilities: [],
285+
},
286+
} as any);
287+
const ctx = { input: {} as Record<string, unknown>, ...engineCtx } as any;
288+
return fn!(ctx).then(() => JSON.parse(String(ctx.input.seen)));
289+
};
290+
291+
it('copies { mode, index } and deliberately NOT `scope` — a JSON copy cannot keep its shared identity', async () => {
292+
const seen = await probeSignal({
293+
dispatch: { mode: 'per-row', index: 3, scope: { stash: 1 } },
294+
});
295+
expect(seen.dispatchType).toBe('object');
296+
expect(seen.mode).toBe('per-row');
297+
expect(seen.index).toBe(3);
298+
expect(seen.scopeType).toBe('undefined');
299+
});
300+
301+
it('leaves ctx.dispatch ABSENT on an unrecognised marker shape — never guessed at', async () => {
302+
const seen = await probeSignal({ dispatch: { mode: 'weird', index: 0, scope: {} } });
303+
expect(seen.dispatchType).toBe('undefined');
304+
});
305+
306+
it('projects input.options to multi/where — the caller bag\'s other keys do not cross', async () => {
307+
// The wrapper shape `installFlatInput` presents: `options` passes through
308+
// the get trap while `ownKeys` hides it. A plain object models the get
309+
// half; the enumeration half is pinned on the real proxy in the
310+
// integration test.
311+
const seen = await probeSignal({
312+
input: { options: { multi: true, where: { status: 'draft' }, context: { secret: 'S' } } },
313+
});
314+
expect(seen.optionsType).toBe('object');
315+
expect(seen.multi).toBe(true);
316+
expect(seen.where).toEqual({ status: 'draft' });
317+
expect(seen.contextType).toBe('undefined');
318+
// Non-enumerable graft: the snapshot's own enumerable copy (this bare
319+
// context has no ownKeys-hiding proxy) is REPLACED by the hidden one, so
320+
// even here enumeration stays clean.
321+
expect(seen.inputKeys).toEqual([]);
322+
});
323+
324+
it('carries neither key when the engine context has neither — the action-face and legacy shape', async () => {
325+
const seen = await probeSignal({ input: { email: 'a@b.co' } });
326+
expect(seen.dispatchType).toBe('undefined');
327+
expect(seen.optionsType).toBe('undefined');
328+
expect(seen.inputKeys).toEqual(['email']);
329+
});
330+
});
259331
});
260332

261333
describe('actionBodyRunnerFactory', () => {

packages/runtime/src/sandbox/body-runner.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,48 @@ function buildSandboxContext(
555555
// than left as a second de-facto contract (PD #12).
556556
const inputSnapshot = unwrapProxyToPlain(engineCtx?.input);
557557
const previousRaw = engineCtx?.previous;
558+
559+
// [#11552] The per-row dispatch signal, and the D2 options visibility, both
560+
// of which the snapshot above DROPS by construction: `unwrapProxyToPlain`
561+
// materialises only what `installFlatInput`'s `ownKeys` enumerates (the
562+
// payload fields), and `dispatch` was never marshalled at all. ADR-0058
563+
// Addendum II D3 names three routes for row-specific work, and routes 1
564+
// (scoped throw) and 2 (`ctx.api` per row) both require the handler to KNOW
565+
// it is on the per-row path — a guard written `ctx.dispatch?.mode ===
566+
// 'per-row'` in a shipped body lowered cleanly and evaluated `false` on
567+
// every dispatch in production (maintainer ruling on #11552: close the
568+
// declared≠observable gap; the D3 contract itself is untouched).
569+
//
570+
// Copy `{ mode, index }` only when the engine marker carries its declared
571+
// shape — an unrecognised shape is left ABSENT, never guessed at, so
572+
// `ctx.dispatch?.mode` reads "not a per-row dispatch" exactly as the spec's
573+
// back-compat rule prescribes. `scope` is deliberately not copied (see
574+
// {@link ScriptContext.dispatch}).
575+
const dispatchRaw = engineCtx?.dispatch;
576+
const dispatch =
577+
dispatchRaw &&
578+
typeof dispatchRaw === 'object' &&
579+
(dispatchRaw.mode === 'record' || dispatchRaw.mode === 'per-row') &&
580+
typeof dispatchRaw.index === 'number'
581+
? { mode: dispatchRaw.mode as 'record' | 'per-row', index: dispatchRaw.index as number }
582+
: undefined;
583+
584+
// [#11552] The caller's bag, read THROUGH the flat proxy's get trap (wrapper
585+
// keys pass through even though `ownKeys` hides them), projected to the two
586+
// members D2 declares `before*`-visible. `{}` when a bag exists but carries
587+
// neither — presence mirrors the engine face; absence stays absence.
588+
const optionsRaw =
589+
engineCtx?.input && typeof engineCtx.input === 'object'
590+
? (engineCtx.input as { options?: unknown }).options
591+
: undefined;
592+
let inputOptions: ScriptContext['inputOptions'];
593+
if (optionsRaw && typeof optionsRaw === 'object') {
594+
const bag = optionsRaw as Record<string, unknown>;
595+
inputOptions = {};
596+
if ('multi' in bag) inputOptions.multi = bag.multi;
597+
if ('where' in bag) inputOptions.where = bag.where;
598+
}
599+
558600
return {
559601
input: inputSnapshot ?? {},
560602
// Preserve `undefined` for `previous` on insert events so hooks can
@@ -587,6 +629,10 @@ function buildSandboxContext(
587629
// sites; widening the accessor to it is a separate capability call and is
588630
// deliberately not taken here — the ruling names hook bodies.
589631
title,
632+
// [#11552] Hook face only, both of them: an action is never one of N
633+
// dispatches for one write, and its params bag has no caller options.
634+
dispatch,
635+
inputOptions,
590636
crypto: globalThis.crypto,
591637
};
592638
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#11552] The per-row dispatch signal, and D2's `input.options` visibility,
5+
* OBSERVED FROM INSIDE A SHIPPED BODY — the conformance face of the maintainer
6+
* ruling that closed ADR-0058 Addendum II's declared≠observable gap for
7+
* body-only hooks.
8+
*
9+
* ## What was measured broken (and is pinned fixed here)
10+
*
11+
* D3 names three routes for row-specific work — throw, `ctx.api` per row, or
12+
* caller-side pagination — and routes 1 and 2 both require the handler to KNOW
13+
* it is on the per-row predicate path. The signal existed on the engine context
14+
* (`dispatch`, #6966; `input.options`, D2) and was dropped at the sandbox
15+
* boundary: `unwrapProxyToPlain` materialises only what `installFlatInput`'s
16+
* `ownKeys` enumerates (payload fields), and `dispatch` was never marshalled.
17+
* So the natural guard — `ctx.dispatch?.mode === 'per-row'` — lowered cleanly,
18+
* passed in-process handler tests, and evaluated `false` on EVERY production
19+
* dispatch: the inert-guard shape, shipped.
20+
*
21+
* ## Why this harness and not a unit mock
22+
*
23+
* The drop happened between two real components whose composition no unit
24+
* mock exercises: objectql's flat-input proxy (its `ownKeys`/descriptor
25+
* hiding) and the QuickJS marshalling. So this drives the REAL `ObjectQL` +
26+
* REAL `SqlDriver` (better-sqlite3) + REAL `QuickJSScriptRunner` behind
27+
* `hookBodyRunnerFactory` — the same wiring `AppPlugin` performs — and every
28+
* assertion lands on what a body OBSERVED, reported out through the `log`
29+
* capability. It mirrors the tripwire test on
30+
* `hotcrm@claude/issue-1265-batch-scoped-payload`
31+
* (`#1265 — the shipped hook body cannot tell it is on a per-row predicate
32+
* dispatch`), which asserts the four broken facts and is written to go red as
33+
* this lands; this file is the framework-side twin asserting the fixed ones.
34+
*
35+
* ## The contract pinned, member by member
36+
*
37+
* - `ctx.dispatch` = frozen `{ mode, index }` — `'per-row'` + row index on
38+
* the predicate path, `'record'` on single-record writes. NOT `scope`:
39+
* shared-identity scratch cannot survive a JSON copy into an isolated heap,
40+
* so marshalling it would ship a silently-inert write channel (see
41+
* `ScriptContext.dispatch`).
42+
* - `ctx.input.options` = frozen, NON-ENUMERABLE `{ multi?, where? }` — the
43+
* projection D2 declares `before*`-visible, not the whole caller bag (the
44+
* host-error-allowlist reasoning in `quickjs-runner.ts`: everything
45+
* marshalled becomes readable by untrusted code).
46+
* - Enumeration stays flat-only: `Object.keys(ctx.input)` lists payload
47+
* fields, exactly as the #7254 witness pins for bodies — so the payload
48+
* diff idiom cannot pick up a phantom `options` field.
49+
* - `ctx.input.id` stays ABSENT on the body face (not part of the ruling);
50+
* the row id a per-row body needs is `ctx.previous.id`, bound since #5574.
51+
* - The write-back channel still works and still cannot carry `options`:
52+
* payload writes land on the batch payload; the caller's live bag is never
53+
* overwritten by a JSON copy (non-enumerable ⇒ excluded from the post-run
54+
* `JSON.stringify` dump `applyMutationsToInput` consumes).
55+
*/
56+
57+
import { describe, it, expect, afterEach } from 'vitest';
58+
import { mkdtempSync, rmSync } from 'node:fs';
59+
import { tmpdir } from 'node:os';
60+
import { join } from 'node:path';
61+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
62+
import { SqlDriver } from '@objectstack/driver-sql';
63+
import { hookBodyRunnerFactory } from './body-runner.js';
64+
import { QuickJSScriptRunner } from './quickjs-runner.js';
65+
import {
66+
captureExpectedReadRefusals,
67+
type ExpectedReadRefusalCapture,
68+
} from '../expected-read-refusal-noise.js';
69+
70+
const ARTICLE = {
71+
name: 'probe_article',
72+
fields: {
73+
title: { type: 'text' },
74+
status: { type: 'text' },
75+
published_at: { type: 'text' },
76+
},
77+
};
78+
79+
/**
80+
* Reports what the body can OBSERVE, then attempts the mutations the contract
81+
* forbids, then reports what it observes AFTER the attempts — so the frozen
82+
* halves are asserted from inside the VM rather than inferred.
83+
*/
84+
const PROBE_SOURCE = `
85+
const o = {
86+
event: ctx.event,
87+
dispatchType: typeof ctx.dispatch,
88+
dispatchMode: ctx.dispatch ? ctx.dispatch.mode : null,
89+
dispatchIndex: ctx.dispatch ? ctx.dispatch.index : null,
90+
dispatchScopeType: ctx.dispatch ? typeof ctx.dispatch.scope : null,
91+
inputKeys: Object.keys(ctx.input).sort(),
92+
inputIdType: typeof ctx.input.id,
93+
optionsType: typeof ctx.input.options,
94+
optionsMulti: ctx.input.options ? ctx.input.options.multi : null,
95+
optionsWhere: ctx.input.options ? ctx.input.options.where : null,
96+
previousId: ctx.previous ? typeof ctx.previous.id : null,
97+
};
98+
try { ctx.dispatch.mode = 'record'; } catch (e) { /* frozen */ }
99+
try { ctx.input.options.multi = false; } catch (e) { /* frozen */ }
100+
try { ctx.input.options = { multi: false } } catch (e) { /* non-writable */ }
101+
o.postDispatchMode = ctx.dispatch ? ctx.dispatch.mode : null;
102+
o.postOptionsMulti = ctx.input.options ? ctx.input.options.multi : null;
103+
ctx.log.info('probe', o);
104+
`;
105+
106+
const ABSENT_TENANCY_TABLE = 'sys_organization';
107+
108+
describe('#11552 — a shipped body observes the per-row dispatch signal and the D2 options projection', () => {
109+
let engine: ObjectQL | null = null;
110+
let dir: string | null = null;
111+
let noise: ExpectedReadRefusalCapture | null = null;
112+
113+
afterEach(async () => {
114+
try { await engine?.destroy(); } catch { /* noop */ }
115+
engine = null;
116+
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
117+
});
118+
119+
it('per-row: mode/index/options visible, frozen, and invisible to enumeration; single-record: mode is record', async () => {
120+
dir = mkdtempSync(join(tmpdir(), 'os-11552-'));
121+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true });
122+
noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]);
123+
noise.captureDriver(driver);
124+
await driver.initObjects([ARTICLE]);
125+
engine = new ObjectQL();
126+
noise.captureEngine(engine);
127+
engine.registerDriver(driver, true);
128+
await engine.init();
129+
// `packageId` is a required parameter (`registerObject(schema, packageId, …)`)
130+
// — the sibling harness's 1-arg spelling is frozen TEST_DEBT, not a template.
131+
engine.registry.registerObject(ARTICLE as any, 'probe');
132+
133+
const seen: any[] = [];
134+
const logger = {
135+
debug: () => {},
136+
info: (_msg: string, meta?: any) => { seen.push(meta); },
137+
warn: () => {},
138+
error: () => {},
139+
};
140+
engine.setDefaultBodyRunner(
141+
hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'probe', logger }),
142+
);
143+
bindHooksToEngine(engine, [{
144+
name: 'probe_perrow_signal',
145+
object: 'probe_article',
146+
events: ['beforeInsert', 'beforeUpdate'],
147+
body: { language: 'js', source: PROBE_SOURCE, capabilities: ['log'] },
148+
} as any], { packageId: 'probe' });
149+
150+
await engine.insert('probe_article', { title: 'a', status: 'draft', published_at: 'x' });
151+
await engine.insert('probe_article', { title: 'b', status: 'draft', published_at: 'y' });
152+
await engine.insert('probe_article', { title: 'c', status: 'live', published_at: 'z' });
153+
const inserts = seen.splice(0);
154+
expect(inserts.length).toBe(3);
155+
for (const o of inserts) {
156+
// An insert is the caller's whole write: the marker says so.
157+
expect(o.dispatchMode).toBe('record');
158+
expect(o.dispatchIndex).toBe(0);
159+
}
160+
161+
// ── The predicate path (multi: true + where) — one write, two matched rows.
162+
const callerOptions = { multi: true, where: { status: 'draft' } };
163+
await engine.update('probe_article', { title: 'renamed' }, callerOptions as any);
164+
const perRow = seen.splice(0);
165+
expect(perRow.length).toBe(2);
166+
167+
for (const o of perRow) {
168+
expect(o.event).toBe('beforeUpdate');
169+
// Route 1/2's precondition — the signal, now observable (was
170+
// `dispatchType: 'undefined'` before #11552, measured on this exact
171+
// harness).
172+
expect(o.dispatchType).toBe('object');
173+
expect(o.dispatchMode).toBe('per-row');
174+
// `scope` deliberately does not cross — see the module doc.
175+
expect(o.dispatchScopeType).toBe('undefined');
176+
// D2's projection, under D2's own spelling.
177+
expect(o.optionsType).toBe('object');
178+
expect(o.optionsMulti).toBe(true);
179+
expect(o.optionsWhere).toEqual({ status: 'draft' });
180+
// Enumeration is STILL flat-only — no phantom `options` in a payload
181+
// diff, exactly the #7254 witness contract.
182+
expect(o.inputKeys).toEqual(['title']);
183+
// `input.id` stays absent (not part of the ruling); the row id channel
184+
// on the per-row path is `previous.id`.
185+
expect(o.inputIdType).toBe('undefined');
186+
expect(o.previousId).toBe('string');
187+
// Frozen: the body's own mutation attempts changed nothing it can read.
188+
expect(o.postDispatchMode).toBe('per-row');
189+
expect(o.postOptionsMulti).toBe(true);
190+
}
191+
expect(perRow.map((o) => o.dispatchIndex).sort()).toEqual([0, 1]);
192+
193+
// The caller's live bag was not clobbered by any write-back of the graft
194+
// (non-enumerable ⇒ excluded from the mutatedInput dump), nor by the
195+
// body's frozen-write attempts. `toMatchObject`, not `toEqual`: the
196+
// engine's post-`before*` driver merge is allowed to ADD keys, never to
197+
// flip these.
198+
expect(callerOptions).toMatchObject({ multi: true, where: { status: 'draft' } });
199+
200+
// The payload write channel itself still works under the graft: both
201+
// matched rows took the batch payload.
202+
const renamed = (await engine.find('probe_article', { where: { title: 'renamed' } })) as any[];
203+
expect(renamed.length).toBe(2);
204+
205+
// ── The single-record path: same hook, by-id write.
206+
const live = ((await engine.find('probe_article', { where: { status: 'live' } })) as any[])[0];
207+
await engine.update('probe_article', { id: live.id, title: 'single' });
208+
const single = seen.splice(0);
209+
expect(single.length).toBe(1);
210+
expect(single[0].dispatchMode).toBe('record');
211+
expect(single[0].dispatchIndex).toBe(0);
212+
// Whatever options bag a by-id write carries, it must not read as a
213+
// predicate write from inside a body.
214+
expect(single[0].optionsMulti).not.toBe(true);
215+
216+
// [#10629] Withheld-noise pin, same as the sibling real-SQLite harness.
217+
expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]);
218+
}, 30000);
219+
});

0 commit comments

Comments
 (0)