From 9cf36155059459790a84b2aaa541cc76eb765891 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:21:10 +0000 Subject: [PATCH 1/2] feat(runtime): marshal the per-row dispatch signal and D2 options projection into the hook body sandbox context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- .changeset/perrow-sandbox-signal.md | 6 + .../runtime/src/sandbox/body-runner.test.ts | 72 ++++++ packages/runtime/src/sandbox/body-runner.ts | 46 ++++ ...perrow-dispatch-signal.integration.test.ts | 217 ++++++++++++++++++ .../runtime/src/sandbox/quickjs-runner.ts | 56 +++++ packages/runtime/src/sandbox/script-runner.ts | 45 ++++ packages/spec/src/data/hook.zod.ts | 25 +- 7 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 .changeset/perrow-sandbox-signal.md create mode 100644 packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts diff --git a/.changeset/perrow-sandbox-signal.md b/.changeset/perrow-sandbox-signal.md new file mode 100644 index 0000000000..9404a42453 --- /dev/null +++ b/.changeset/perrow-sandbox-signal.md @@ -0,0 +1,6 @@ +--- +"@objectstack/runtime": minor +"@objectstack/spec": patch +--- + +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. diff --git a/packages/runtime/src/sandbox/body-runner.test.ts b/packages/runtime/src/sandbox/body-runner.test.ts index 2819c60f69..85357087f5 100644 --- a/packages/runtime/src/sandbox/body-runner.test.ts +++ b/packages/runtime/src/sandbox/body-runner.test.ts @@ -256,6 +256,78 @@ describe('hookBodyRunnerFactory', () => { expect(await probeUser({})).toBe('null'); }); }); + + // [#11552] The per-row dispatch signal and the D2 options projection cross + // the sandbox boundary — the unit half; the real-engine composition (flat + // proxy from `installFlatInput`, predicate dispatch, write-back) is pinned + // in `perrow-dispatch-signal.integration.test.ts`. + describe('marshals ctx.dispatch and input.options onto the hook body face (#11552)', () => { + const probeSignal = (engineCtx: Record) => { + const fn = hookBodyRunnerFactory(runner, { ql: {}, appId: 'crm' })({ + name: 'probe_signal', + object: 'contact', + events: ['beforeUpdate'], + body: { + language: 'js', + source: + 'return { seen: JSON.stringify({' + + ' dispatchType: typeof ctx.dispatch,' + + ' mode: ctx.dispatch ? ctx.dispatch.mode : null,' + + ' index: ctx.dispatch ? ctx.dispatch.index : null,' + + ' scopeType: ctx.dispatch ? typeof ctx.dispatch.scope : null,' + + ' optionsType: typeof ctx.input.options,' + + ' multi: ctx.input.options ? ctx.input.options.multi : null,' + + ' where: ctx.input.options ? ctx.input.options.where : null,' + + ' contextType: ctx.input.options ? typeof ctx.input.options.context : null,' + + ' inputKeys: Object.keys(ctx.input).sort(),' + + ' }) };', + capabilities: [], + }, + } as any); + const ctx = { input: {} as Record, ...engineCtx } as any; + return fn!(ctx).then(() => JSON.parse(String(ctx.input.seen))); + }; + + it('copies { mode, index } and deliberately NOT `scope` — a JSON copy cannot keep its shared identity', async () => { + const seen = await probeSignal({ + dispatch: { mode: 'per-row', index: 3, scope: { stash: 1 } }, + }); + expect(seen.dispatchType).toBe('object'); + expect(seen.mode).toBe('per-row'); + expect(seen.index).toBe(3); + expect(seen.scopeType).toBe('undefined'); + }); + + it('leaves ctx.dispatch ABSENT on an unrecognised marker shape — never guessed at', async () => { + const seen = await probeSignal({ dispatch: { mode: 'weird', index: 0, scope: {} } }); + expect(seen.dispatchType).toBe('undefined'); + }); + + it('projects input.options to multi/where — the caller bag\'s other keys do not cross', async () => { + // The wrapper shape `installFlatInput` presents: `options` passes through + // the get trap while `ownKeys` hides it. A plain object models the get + // half; the enumeration half is pinned on the real proxy in the + // integration test. + const seen = await probeSignal({ + input: { options: { multi: true, where: { status: 'draft' }, context: { secret: 'S' } } }, + }); + expect(seen.optionsType).toBe('object'); + expect(seen.multi).toBe(true); + expect(seen.where).toEqual({ status: 'draft' }); + expect(seen.contextType).toBe('undefined'); + // Non-enumerable graft: the snapshot's own enumerable copy (this bare + // context has no ownKeys-hiding proxy) is REPLACED by the hidden one, so + // even here enumeration stays clean. + expect(seen.inputKeys).toEqual([]); + }); + + it('carries neither key when the engine context has neither — the action-face and legacy shape', async () => { + const seen = await probeSignal({ input: { email: 'a@b.co' } }); + expect(seen.dispatchType).toBe('undefined'); + expect(seen.optionsType).toBe('undefined'); + expect(seen.inputKeys).toEqual(['email']); + }); + }); }); describe('actionBodyRunnerFactory', () => { diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 25a9d72aaf..843608b88a 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -555,6 +555,48 @@ function buildSandboxContext( // than left as a second de-facto contract (PD #12). const inputSnapshot = unwrapProxyToPlain(engineCtx?.input); const previousRaw = engineCtx?.previous; + + // [#11552] The per-row dispatch signal, and the D2 options visibility, both + // of which the snapshot above DROPS by construction: `unwrapProxyToPlain` + // materialises only what `installFlatInput`'s `ownKeys` enumerates (the + // payload fields), and `dispatch` was never marshalled at all. ADR-0058 + // Addendum II D3 names three routes for row-specific work, and routes 1 + // (scoped throw) and 2 (`ctx.api` per row) both require the handler to KNOW + // it is on the per-row path — a guard written `ctx.dispatch?.mode === + // 'per-row'` in a shipped body lowered cleanly and evaluated `false` on + // every dispatch in production (maintainer ruling on #11552: close the + // declared≠observable gap; the D3 contract itself is untouched). + // + // Copy `{ mode, index }` only when the engine marker carries its declared + // shape — an unrecognised shape is left ABSENT, never guessed at, so + // `ctx.dispatch?.mode` reads "not a per-row dispatch" exactly as the spec's + // back-compat rule prescribes. `scope` is deliberately not copied (see + // {@link ScriptContext.dispatch}). + const dispatchRaw = engineCtx?.dispatch; + const dispatch = + dispatchRaw && + typeof dispatchRaw === 'object' && + (dispatchRaw.mode === 'record' || dispatchRaw.mode === 'per-row') && + typeof dispatchRaw.index === 'number' + ? { mode: dispatchRaw.mode as 'record' | 'per-row', index: dispatchRaw.index as number } + : undefined; + + // [#11552] The caller's bag, read THROUGH the flat proxy's get trap (wrapper + // keys pass through even though `ownKeys` hides them), projected to the two + // members D2 declares `before*`-visible. `{}` when a bag exists but carries + // neither — presence mirrors the engine face; absence stays absence. + const optionsRaw = + engineCtx?.input && typeof engineCtx.input === 'object' + ? (engineCtx.input as { options?: unknown }).options + : undefined; + let inputOptions: ScriptContext['inputOptions']; + if (optionsRaw && typeof optionsRaw === 'object') { + const bag = optionsRaw as Record; + inputOptions = {}; + if ('multi' in bag) inputOptions.multi = bag.multi; + if ('where' in bag) inputOptions.where = bag.where; + } + return { input: inputSnapshot ?? {}, // Preserve `undefined` for `previous` on insert events so hooks can @@ -587,6 +629,10 @@ function buildSandboxContext( // sites; widening the accessor to it is a separate capability call and is // deliberately not taken here — the ruling names hook bodies. title, + // [#11552] Hook face only, both of them: an action is never one of N + // dispatches for one write, and its params bag has no caller options. + dispatch, + inputOptions, crypto: globalThis.crypto, }; } diff --git a/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts b/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts new file mode 100644 index 0000000000..6239db5d70 --- /dev/null +++ b/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11552] The per-row dispatch signal, and D2's `input.options` visibility, + * OBSERVED FROM INSIDE A SHIPPED BODY — the conformance face of the maintainer + * ruling that closed ADR-0058 Addendum II's declared≠observable gap for + * body-only hooks. + * + * ## What was measured broken (and is pinned fixed here) + * + * D3 names three routes for row-specific work — throw, `ctx.api` per row, or + * caller-side pagination — and routes 1 and 2 both require the handler to KNOW + * it is on the per-row predicate path. The signal existed on the engine context + * (`dispatch`, #6966; `input.options`, D2) and was dropped at the sandbox + * boundary: `unwrapProxyToPlain` materialises only what `installFlatInput`'s + * `ownKeys` enumerates (payload fields), and `dispatch` was never marshalled. + * So the natural guard — `ctx.dispatch?.mode === 'per-row'` — lowered cleanly, + * passed in-process handler tests, and evaluated `false` on EVERY production + * dispatch: the inert-guard shape, shipped. + * + * ## Why this harness and not a unit mock + * + * The drop happened between two real components whose composition no unit + * mock exercises: objectql's flat-input proxy (its `ownKeys`/descriptor + * hiding) and the QuickJS marshalling. So this drives the REAL `ObjectQL` + + * REAL `SqlDriver` (better-sqlite3) + REAL `QuickJSScriptRunner` behind + * `hookBodyRunnerFactory` — the same wiring `AppPlugin` performs — and every + * assertion lands on what a body OBSERVED, reported out through the `log` + * capability. It mirrors the tripwire test on + * `hotcrm@claude/issue-1265-batch-scoped-payload` + * (`#1265 — the shipped hook body cannot tell it is on a per-row predicate + * dispatch`), which asserts the four broken facts and is written to go red as + * this lands; this file is the framework-side twin asserting the fixed ones. + * + * ## The contract pinned, member by member + * + * - `ctx.dispatch` = frozen `{ mode, index }` — `'per-row'` + row index on + * the predicate path, `'record'` on single-record writes. NOT `scope`: + * shared-identity scratch cannot survive a JSON copy into an isolated heap, + * so marshalling it would ship a silently-inert write channel (see + * `ScriptContext.dispatch`). + * - `ctx.input.options` = frozen, NON-ENUMERABLE `{ multi?, where? }` — the + * projection D2 declares `before*`-visible, not the whole caller bag (the + * host-error-allowlist reasoning in `quickjs-runner.ts`: everything + * marshalled becomes readable by untrusted code). + * - Enumeration stays flat-only: `Object.keys(ctx.input)` lists payload + * fields, exactly as the #7254 witness pins for bodies — so the payload + * diff idiom cannot pick up a phantom `options` field. + * - `ctx.input.id` stays ABSENT on the body face (not part of the ruling); + * the row id a per-row body needs is `ctx.previous.id`, bound since #5574. + * - The write-back channel still works and still cannot carry `options`: + * payload writes land on the batch payload; the caller's live bag is never + * overwritten by a JSON copy (non-enumerable ⇒ excluded from the post-run + * `JSON.stringify` dump `applyMutationsToInput` consumes). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { hookBodyRunnerFactory } from './body-runner.js'; +import { QuickJSScriptRunner } from './quickjs-runner.js'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from '../expected-read-refusal-noise.js'; + +const ARTICLE = { + name: 'probe_article', + fields: { + title: { type: 'text' }, + status: { type: 'text' }, + published_at: { type: 'text' }, + }, +}; + +/** + * Reports what the body can OBSERVE, then attempts the mutations the contract + * forbids, then reports what it observes AFTER the attempts — so the frozen + * halves are asserted from inside the VM rather than inferred. + */ +const PROBE_SOURCE = ` + const o = { + event: ctx.event, + dispatchType: typeof ctx.dispatch, + dispatchMode: ctx.dispatch ? ctx.dispatch.mode : null, + dispatchIndex: ctx.dispatch ? ctx.dispatch.index : null, + dispatchScopeType: ctx.dispatch ? typeof ctx.dispatch.scope : null, + inputKeys: Object.keys(ctx.input).sort(), + inputIdType: typeof ctx.input.id, + optionsType: typeof ctx.input.options, + optionsMulti: ctx.input.options ? ctx.input.options.multi : null, + optionsWhere: ctx.input.options ? ctx.input.options.where : null, + previousId: ctx.previous ? typeof ctx.previous.id : null, + }; + try { ctx.dispatch.mode = 'record'; } catch (e) { /* frozen */ } + try { ctx.input.options.multi = false; } catch (e) { /* frozen */ } + try { ctx.input.options = { multi: false } } catch (e) { /* non-writable */ } + o.postDispatchMode = ctx.dispatch ? ctx.dispatch.mode : null; + o.postOptionsMulti = ctx.input.options ? ctx.input.options.multi : null; + ctx.log.info('probe', o); +`; + +const ABSENT_TENANCY_TABLE = 'sys_organization'; + +describe('#11552 — a shipped body observes the per-row dispatch signal and the D2 options projection', () => { + let engine: ObjectQL | null = null; + let dir: string | null = null; + let noise: ExpectedReadRefusalCapture | null = null; + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = null; + if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + }); + + it('per-row: mode/index/options visible, frozen, and invisible to enumeration; single-record: mode is record', async () => { + dir = mkdtempSync(join(tmpdir(), 'os-11552-')); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); + await driver.initObjects([ARTICLE]); + engine = new ObjectQL(); + noise.captureEngine(engine); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(ARTICLE as any); + + const seen: any[] = []; + const logger = { + debug: () => {}, + info: (_msg: string, meta?: any) => { seen.push(meta); }, + warn: () => {}, + error: () => {}, + }; + engine.setDefaultBodyRunner( + hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'probe', logger }), + ); + bindHooksToEngine(engine, [{ + name: 'probe_perrow_signal', + object: 'probe_article', + events: ['beforeInsert', 'beforeUpdate'], + body: { language: 'js', source: PROBE_SOURCE, capabilities: ['log'] }, + } as any], { packageId: 'probe' }); + + await engine.insert('probe_article', { title: 'a', status: 'draft', published_at: 'x' }); + await engine.insert('probe_article', { title: 'b', status: 'draft', published_at: 'y' }); + await engine.insert('probe_article', { title: 'c', status: 'live', published_at: 'z' }); + const inserts = seen.splice(0); + expect(inserts.length).toBe(3); + for (const o of inserts) { + // An insert is the caller's whole write: the marker says so. + expect(o.dispatchMode).toBe('record'); + expect(o.dispatchIndex).toBe(0); + } + + // ── The predicate path (multi: true + where) — one write, two matched rows. + const callerOptions = { multi: true, where: { status: 'draft' } }; + await engine.update('probe_article', { title: 'renamed' }, callerOptions as any); + const perRow = seen.splice(0); + expect(perRow.length).toBe(2); + + for (const o of perRow) { + expect(o.event).toBe('beforeUpdate'); + // Route 1/2's precondition — the signal, now observable (was + // `dispatchType: 'undefined'` before #11552, measured on this exact + // harness). + expect(o.dispatchType).toBe('object'); + expect(o.dispatchMode).toBe('per-row'); + // `scope` deliberately does not cross — see the module doc. + expect(o.dispatchScopeType).toBe('undefined'); + // D2's projection, under D2's own spelling. + expect(o.optionsType).toBe('object'); + expect(o.optionsMulti).toBe(true); + expect(o.optionsWhere).toEqual({ status: 'draft' }); + // Enumeration is STILL flat-only — no phantom `options` in a payload + // diff, exactly the #7254 witness contract. + expect(o.inputKeys).toEqual(['title']); + // `input.id` stays absent (not part of the ruling); the row id channel + // on the per-row path is `previous.id`. + expect(o.inputIdType).toBe('undefined'); + expect(o.previousId).toBe('string'); + // Frozen: the body's own mutation attempts changed nothing it can read. + expect(o.postDispatchMode).toBe('per-row'); + expect(o.postOptionsMulti).toBe(true); + } + expect(perRow.map((o) => o.dispatchIndex).sort()).toEqual([0, 1]); + + // The caller's live bag was not clobbered by any write-back of the graft + // (non-enumerable ⇒ excluded from the mutatedInput dump), nor by the + // body's frozen-write attempts. `toMatchObject`, not `toEqual`: the + // engine's post-`before*` driver merge is allowed to ADD keys, never to + // flip these. + expect(callerOptions).toMatchObject({ multi: true, where: { status: 'draft' } }); + + // The payload write channel itself still works under the graft: both + // matched rows took the batch payload. + const renamed = (await engine.find('probe_article', { where: { title: 'renamed' } })) as any[]; + expect(renamed.length).toBe(2); + + // ── The single-record path: same hook, by-id write. + const live = ((await engine.find('probe_article', { where: { status: 'live' } })) as any[])[0]; + await engine.update('probe_article', { id: live.id, title: 'single' }); + const single = seen.splice(0); + expect(single.length).toBe(1); + expect(single[0].dispatchMode).toBe('record'); + expect(single[0].dispatchIndex).toBe(0); + // Whatever options bag a by-id write carries, it must not read as a + // predicate write from inside a body. + expect(single[0].optionsMulti).not.toBe(true); + + // [#10629] Withheld-noise pin, same as the sibling real-SQLite harness. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + }, 30000); +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 8e57fb5b35..6ecceb683b 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -761,6 +761,62 @@ export class QuickJSScriptRunner implements ScriptRunner { vm.setProp(vm.global, '__ctx', ctxObj); ctxObj.dispose(); + // [#11552] Per-row dispatch signal + D2 options visibility. Grafted AFTER + // `__ctx` is bound, via evalCode, because both need property semantics the + // JSON marshalling above cannot express: + // + // - `ctx.dispatch` must be FROZEN — the marker is the engine's verdict + // (`hook.zod.ts` #6966: produced where the dispatch ladder decides, + // never re-derived), so a body must not be able to drift its own copy; + // - `input.options` must be NON-ENUMERABLE — invisible to + // `Object.keys(ctx.input)` (the flat-record enumeration contract the + // #7254 witness pins) and, load-bearingly, to `readCtxInputJson`'s + // `JSON.stringify` dump, so the write-back path (`applyMutationsToInput`) + // can never overwrite the engine's live options bag with a JSON copy. + // + // The snippet interpolates no caller data (values cross via setGlobalJson), + // so a failure here means the VM is broken — fatal, like the tx sugar. + if (ctx.dispatch !== undefined || ctx.inputOptions !== undefined) { + setGlobalJson(vm, '__dispatch', ctx.dispatch); + setGlobalJson(vm, '__inputOptions', ctx.inputOptions); + const graft = vm.evalCode( + `(function () { + var d = globalThis.__dispatch; + var o = globalThis.__inputOptions; + delete globalThis.__dispatch; + delete globalThis.__inputOptions; + var deepFreeze = function (v) { + if (v && typeof v === 'object') { + Object.getOwnPropertyNames(v).forEach(function (k) { deepFreeze(v[k]); }); + Object.freeze(v); + } + return v; + }; + if (d !== null && d !== undefined) { + Object.defineProperty(__ctx, 'dispatch', { + value: deepFreeze(d), enumerable: true, writable: false, configurable: false, + }); + } + if (o !== null && o !== undefined) { + deepFreeze(o); + [globalThis.__input, __ctx.input].forEach(function (t) { + if (t && typeof t === 'object') { + Object.defineProperty(t, 'options', { + value: o, enumerable: false, writable: false, configurable: false, + }); + } + }); + } + })();`, + ); + if (graft.error) { + const msg = vm.dump(graft.error); + graft.error.dispose(); + throw new SandboxError(`failed to install ctx.dispatch / ctx.input.options: ${formatErr(msg)}`); + } + graft.value.dispose(); + } + // VM-side sugar: `ctx.api.transaction(async () => { … })`. Begin runs // OUTSIDE the try so a begin failure (e.g. missing capability) propagates // without attempting a rollback there is no transaction for. The body's diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index 8bc5c2d643..f926d0a46b 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -202,6 +202,51 @@ export interface ScriptContext { * "one key, two realities" defect #5613 exists to close. */ session?: ScriptSession; + /** + * The engine's dispatch marker, marshalled for the HOOK face (#11552) — how + * this hook call relates to the caller's write. `'per-row'` means one of N + * dispatches for one predicate (`multi: true`) write; `'record'` means the + * call stands for the caller's whole write. Mirrors + * `HookContextSchema.dispatch` (#6966) MINUS `scope`, and the omission is + * deliberate rather than an oversight: `scope`'s whole contract is SHARED + * OBJECT IDENTITY across every dispatch of one write, and a JSON copy into + * the VM heap cannot keep it — a body writing to the copy would see its + * stash silently never arrive, which is exactly the inert-guard family this + * key exists to close (ADR-0058 Addendum II D3: without the marker, a shipped + * body could not scope a guard to the batch path at all). + * + * Read-only in effect AND in the VM: `installCtx` grafts it frozen, so a body + * assigning `ctx.dispatch.mode` gets no local drift either. Absent on the + * action face and on hook reads (`beforeFind`/`afterFind`) — read it as + * `ctx.dispatch?.mode === 'per-row'`, the same back-compatible direction the + * spec prescribes for the engine face. + */ + dispatch?: { mode: 'record' | 'per-row'; index: number }; + /** + * The caller's options bag, PROJECTED to the two members ADR-0058's D2 + * declares visible to the `before*` phase — `multi` and `where` — for the + * HOOK face (#11552). `installCtx` grafts it onto the VM's `ctx.input` as a + * NON-ENUMERABLE, frozen `options` key, which buys three things at once: + * + * - the declared spelling works (`ctx.input.options.multi` / `.where`), + * closing the D2 declared≠observable drift for shipped bodies; + * - enumeration stays clean — `Object.keys(ctx.input)` still lists record + * fields only, the same contract `installFlatInput`'s `ownKeys` trap keeps + * on the in-process face (and the #7254 witness pins for bodies); + * - the write-back channel cannot carry it: `readCtxInputJson` reads the + * post-run `ctx.input` via `JSON.stringify`, which walks enumerable keys + * only, so `applyMutationsToInput` can never smuggle a JSON-mangled copy + * of the bag back over the engine's live `input.options`. + * + * A PROJECTION, not the whole bag, on the same reasoning as the host-error + * allowlist below (`SANDBOX_ERROR_PASSTHROUGH`): everything marshalled here + * becomes readable by untrusted sandboxed code, and the raw bag can carry + * the caller's execution context and driver-facing state. `multi` and + * `where` are what D2 declares, what the platform's own guards read, and + * what crosses; widening the projection is a declared decision, never a + * consumer-side accretion (PD #12). + */ + inputOptions?: { multi?: unknown; where?: unknown }; /** * The lifecycle event name the hook is firing for (e.g. `beforeInsert`, * `afterUpdate`). Required for hooks that subscribe to multiple events diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index b8fd2fb2cb..ed8c299d12 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -405,10 +405,18 @@ export const HookContextSchema = lazySchema(() => z.object({ * and the envelope spelling `input.data.` is a **TypeError** — which * ABORTS the caller's write on a hook whose `onError` is `abort` — which is * this schema's DEFAULT, and what shipped showcase body hooks declare - * explicitly. `id`, `options` and `ast` are + * explicitly. `id` and `ast` are * absent for the same reason; on `find` and `delete`, whose envelopes carry * no `data`, the whole snapshot is `{}`. A body that needs the row reads * `ctx.previous` — the pre-image, `id` included, bound on update and delete. + * `input.options` is the one wrapper key grafted BACK onto the body face + * (#11552, closing the D2 declared≠observable drift): a body reads + * `ctx.input.options.multi` / `.where` — the PROJECTION D2 names, not the + * whole caller bag — as a frozen, NON-ENUMERABLE property, so enumeration + * stays flat-only exactly as above and the post-run write-back (which walks + * enumerable keys) can never carry a copy of the bag back to the engine. + * The body face also carries the #6966 dispatch marker — see `dispatch` + * below. * Writes the script makes to `ctx.input` are copied back onto the live proxy * after it returns (`applyMutationsToInput`), so `input.field = value` still * lands in `data` through the same `set` trap. @@ -579,6 +587,21 @@ export const HookContextSchema = lazySchema(() => z.object({ * "not a per-row dispatch", which is the back-compatible direction. * * Reads (`beforeFind`/`afterFind`) carry no marker: a read has no fan-out. + * + * ## The sandboxed `body` face carries `{ mode, index }` — and not `scope` + * + * Since #11552 a declarative `body` (L2 sandboxed JS) observes the marker + * too, as a FROZEN `ctx.dispatch = { mode, index }` copy — before that the + * sandbox marshalling dropped it entirely, so ADR-0058 Addendum II D3's + * routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) were not + * expressible from a shipped body: the guard `ctx.dispatch?.mode === + * 'per-row'` lowered cleanly and evaluated `false` on every production + * dispatch. `scope` is deliberately NOT marshalled: its whole contract is + * shared object identity across dispatches, which a JSON copy into an + * isolated VM heap cannot keep — a body stashing on the copy would watch it + * silently never arrive, the same inert-guard shape this key closes. A body + * needing cross-dispatch state does batch-scoped work at `index === 0` or + * keeps state in the record itself via `ctx.api`. */ dispatch: z.object({ mode: z.enum(['record', 'per-row']).describe("'record' = this call is the caller's whole write; 'per-row' = one of N dispatches for one write"), From bf9ac67f7c58ec0f301e7040af3c6dfd10dabc1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:48:00 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- .../src/sandbox/perrow-dispatch-signal.integration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts b/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts index 6239db5d70..02a8bba425 100644 --- a/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts +++ b/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts @@ -126,7 +126,9 @@ describe('#11552 — a shipped body observes the per-row dispatch signal and the noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); - engine.registry.registerObject(ARTICLE as any); + // `packageId` is a required parameter (`registerObject(schema, packageId, …)`) + // — the sibling harness's 1-arg spelling is frozen TEST_DEBT, not a template. + engine.registry.registerObject(ARTICLE as any, 'probe'); const seen: any[] = []; const logger = {