Skip to content

feat(runtime): marshal the per-row dispatch signal and the D2 options projection into the hook body sandbox context - #12217

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-11552-sandbox-perrow-signal
Aug 25, 2026
Merged

feat(runtime): marshal the per-row dispatch signal and the D2 options projection into the hook body sandbox context#12217
os-litant merged 2 commits into
mainfrom
claude/issue-11552-sandbox-perrow-signal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #11552

Maintainer ruling (issue comment 5405062624, decision-inbox batch 5, 2026-08-25): options 1 + 2 together — marshal the per-row dispatch signal into the hook body sandbox context, and surface the input.options visibility D2 already declares. Option 3 (advisory lint) is explicitly not part of the ruling and is not in this PR. D3's contract statement is untouched.

Ruled pre-step — provenance of the sandbox key pruning (verdict: not a ruled security boundary)

Checked before widening, as the ruling requires:

What changed

ctx.dispatch (option 1)buildSandboxContext marshals the engine's #6966 dispatch marker as { mode, index }; installCtx grafts it frozen onto the VM context. scope deliberately does not cross: its whole contract is shared object identity across dispatches, which a JSON copy into an isolated heap cannot keep — marshalling it would ship a silently-inert write channel, the very family this card closes. An unrecognised marker shape is left absent, never guessed at.

ctx.input.options (option 2) — the caller's bag, projected to multi / where (the two members D2 names), grafted onto the VM's ctx.input as a non-enumerable, frozen property. Three properties bought at once:

The projection (rather than the whole bag) follows the host-error-allowlist reasoning already in this file: everything marshalled becomes readable by untrusted sandboxed code, and the raw bag can carry the caller's execution context and driver-facing state. multi/where is exactly what D2 declares and what the platform's own guards read. Widening it later is a declared decision, not a consumer-side accretion. input.id stays absent — not part of the ruling; the per-row row id channel is ctx.previous.id, stated as such in the docs.

Spec sidehook.zod.ts's input TSDoc body-face row (which asserted options absent) and the dispatch TSDoc now state the body-face visibility, making declared = observable in both directions. Documentation-only: no .describe() strings changed; check:generated confirms all 14 artifacts current.

Acceptance signal

The hotcrm tripwire (hotcrm@claude/issue-1265-batch-scoped-payload, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch) is written to go red as this lands. This session has no hotcrm checkout, so the same assertions are replicated in-repo as packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, one predicate multi: true update, observations reported from inside the body.

Premise re-measured on BASE (22c42c9) first, on that exact harness — all four issue facts reproduced verbatim: hasDispatch: "undefined", inputKeys: ["title"], inputId: undefined, inputOptions: undefined, previousKeys carrying the pre-image.

Reverse verification from the committed state: sources reverted to BASE (git restore --source), revert proven on disk (the change anchor greps to 0 in all three files), suite re-run — 3 assertions red: the integration probe loses the signal (expected null to be 'record'), the unit probe loses ctx.dispatch (expected 'undefined' to be 'object'), and the bare-object edge shows the whole bag where the projection hides context post-fix (expected 'object' to be 'undefined'). Restore proven by clean git status + anchor back (4/1/2 hits). No build step is involved in these legs — runtime's vitest reads in-package src/ directly; the dist-consuming leg (showcase, below) was run only after pnpm --filter @objectstack/runtime build, with the graft's anchor confirmed present in dist/index.js (4 hits) before the run.

Verification (all at HEAD 9cf3615, clean tree, via the shared verify lock)

  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/sandbox/ — 14 files, 163 passed (includes the new integration + unit tests; VERDICT command-exit 0)
  • pnpm --filter @objectstack/runtime typecheck — green (tsc --noEmit echo confirmed)
  • pnpm --filter @objectstack/spec exec vitest run … hook.test.ts hook-body.test.ts bulk-write-hook-conformance.test.ts107 passed
  • pnpm --filter @objectstack/spec typecheck — green (three tsc programs)
  • pnpm --filter @objectstack/spec build && check:generated — "All 14 generated artifacts are up to date" (no regen needed; TSDoc-only)
  • pnpm --filter @objectstack/example-showcase exec vitest run … hook-body-persisted-writes.test.ts6 passed including THE HookContextSchema.input's contract table documents only the raw-engine envelope — every declarative hook sees a flat record view, and in a sandboxed body the documented input.data spelling is a TypeError #7254 WITNESS (payload-only enumeration survives the graft), against rebuilt runtime dist
  • dispatch-gates recompute (node scripts/pm/dispatch-gates.mjs, change set = exactly this diff): all matched script gates run green — cross-package-test-inputs, test-source-alias, type-source-resolution, published-files, slot-lookup, spec-parsed-alias, merge-driver, objectui-changeset, changeset-gate-self-tests, adr-0087-registration, changeset-no-major, ci-filter-parity, doc-formula-expressions, empty-state, liveness, strictness-ledger, variant-docs, nul-bytes
  • Declared narrowing: runtime's full 189-file suite and repo-wide lint are left to CI (the diff's runtime surface is src/sandbox/** only, run here in full; package-wide tsc --noEmit covers every in-package consumer of the changed types; the new context keys are additive and optional, so no downstream consumer sweep is owed — CI runs the farm regardless)

Clause-② card (hook body contract surface widens): PR stays draft; landing goes through the contract-review chain.

Generated by Claude Code


Generated by Claude Code

…jection 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
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 6 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via ScriptContext (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via HookContextSchema (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 177: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 107 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json eeec62a98d1ffafd0fadf2b6e9d34a6d48e381c7packageMentionDocs.

Which tree this was computed on

This run read content/docs from 88e0b5e0214f6c56a5d9bbadc582904bef752cd7 — the merge of head bf9ac67f7c58ec0f301e7040af3c6dfd10dabc1b into base eeec62a98d1ffafd0fadf2b6e9d34a6d48e381c7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 88e0b5e0214f6c56a5d9bbadc582904bef752cd7 && git checkout 88e0b5e0214f6c56a5d9bbadc582904bef752cd7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eeec62a98d1ffafd0fadf2b6e9d34a6d48e381c7 bf9ac67f7c58ec0f301e7040af3c6dfd10dabc1b && git checkout -B drift-repro eeec62a98d1ffafd0fadf2b6e9d34a6d48e381c7 && git merge --no-ff bf9ac67f7c58ec0f301e7040af3c6dfd10dabc1b

node scripts/docs-audit/affected-docs.mjs --json eeec62a98d1ffafd0fadf2b6e9d34a6d48e381c7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs eeec62a98d1ffafd0fadf2b6e9d34a6d48e381c7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…1552 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

Copy link
Copy Markdown
Collaborator Author

CI follow-up (Type Check · debt ledger, run 32855958824, head 9cf3615): the TEST_DEBT re-measure — which lifts runtime's tsconfig test exclusion — counted 228 raw tsc errors against the frozen 227. The +1 was this PR's conformance harness:

packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts(129,21): error TS2554: Expected 2-5 arguments, but got 1.

registry.registerObject(schema, packageId, …) requires packageId; the 1-arg spelling was copied from a sibling harness whose identical error is frozen debt, not a template. Fixed at the call site in bf9ac67registerObject(ARTICLE as any, 'probe') — no ledger raise, no ignore/exclusion.

Re-verified with the gate's own program (closure rebuilt with the lint.yml parity command first):

  • targeted reproduction: the generated TEST_DEBT-equivalent project for runtime went 228 → 227 with 0 diagnostics in this PR's files;
  • node scripts/check-type-check-coverage.mjs --re-measure verdict, verbatim: "check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 397.1s, 1843 raw tsc error(s) total, none above its recorded number."
  • full sandbox suite re-run at the fixed tree: 14 files / 163 passed.

Generated by Claude Code


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

2 participants