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
6 changes: 6 additions & 0 deletions .changeset/perrow-sandbox-signal.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions packages/runtime/src/sandbox/body-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
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<string, unknown>, ...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', () => {
Expand Down
46 changes: 46 additions & 0 deletions packages/runtime/src/sandbox/body-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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
Expand Down Expand Up @@ -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,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// 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();
// `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 = {
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);
});
Loading
Loading