feat(runtime): marshal the per-row dispatch signal and the D2 options projection into the hook body sandbox context - #12217
Conversation
…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
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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
|
CI follow-up (
Re-verified with the gate's own program (closure rebuilt with the lint.yml parity command first):
Generated by Claude Code Generated by Claude Code |
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.optionsvisibility 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:
installFlatInput'sownKeyshiding of the wrapper keys (packages/objectql/src/hook-wrappers.ts) is documented in-place as an ergonomic flat-record enumeration contract ("user code that doesObject.keys(input)…only sees actual record fields") — not a security boundary.buildSandboxContext's key list evolved by removing phantom keys no producer ever wrote (doc/previousDoc同源:另外三处 hook context 上的doc/previousDoc防御性 alias 读也没有生产者(#5671 全仓 grep 的剩余部分) #5906,session.user沙箱两个写入方的?? …session?.user兜底链是死支 —— 两种 session 形状都不声明也不生产user键(observation) #6316,logger[finding] Hook body ctx.log output is unobservable — body-runner wires log: engineCtx?.logger with no fallback, so the declared ['log'] capability can silently produce nothing #7448) under PD Add comprehensive test suite for Zod schema validation #12 — removals of never-produced aliases, not a ruled minimal-surface decision aboutdispatch/options.id/options/astis recorded inhook.zod.ts'sinputTSDoc (HookContextSchema.input's contract table documents only the raw-engine envelope — every declarative hook sees a flat record view, and in a sandboxedbodythe documentedinput.dataspelling is a TypeError #7254) with convergence marked "deliberately not decided" — an open question, which the [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552 ruling now decides for theoptionshalf.quickjs-runner.ts(what crosses into untrusted code is explicit and minimal). This PR follows that posture rather than crossing it — see the projection note below.What changed
ctx.dispatch(option 1) —buildSandboxContextmarshals the engine's #6966 dispatch marker as{ mode, index };installCtxgrafts it frozen onto the VM context.scopedeliberately 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 tomulti/where(the two members D2 names), grafted onto the VM'sctx.inputas a non-enumerable, frozen property. Three properties bought at once:ctx.input.options.multi/.where;Object.keys(ctx.input)lists payload fields — the HookContextSchema.input's contract table documents only the raw-engine envelope — every declarative hook sees a flat record view, and in a sandboxedbodythe documentedinput.dataspelling is a TypeError #7254 witness contract holds, re-proven below);applyMutationsToInputcan never overwrite the engine's live options bag with a JSON copy.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/whereis 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.idstays absent — not part of the ruling; the per-row row id channel isctx.previous.id, stated as such in the docs.Spec side —
hook.zod.ts'sinputTSDoc body-face row (which assertedoptionsabsent) and thedispatchTSDoc now state the body-face visibility, making declared = observable in both directions. Documentation-only: no.describe()strings changed;check:generatedconfirms 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 aspackages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts— realObjectQL+ realSqlDriver(better-sqlite3) + realQuickJSScriptRunnerbehindhookBodyRunnerFactory, one predicatemulti: trueupdate, 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,previousKeyscarrying 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 losesctx.dispatch(expected 'undefined' to be 'object'), and the bare-object edge shows the whole bag where the projection hidescontextpost-fix (expected 'object' to be 'undefined'). Restore proven by cleangit status+ anchor back (4/1/2 hits). No build step is involved in these legs — runtime's vitest reads in-packagesrc/directly; the dist-consuming leg (showcase, below) was run only afterpnpm --filter @objectstack/runtime build, with the graft's anchor confirmed present indist/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 --noEmitecho confirmed)pnpm --filter @objectstack/spec exec vitest run … hook.test.ts hook-body.test.ts bulk-write-hook-conformance.test.ts— 107 passedpnpm --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.ts— 6 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 sandboxedbodythe documentedinput.dataspelling is a TypeError #7254 WITNESS (payload-only enumeration survives the graft), against rebuilt runtime distnode 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-bytessrc/sandbox/**only, run here in full; package-widetsc --noEmitcovers 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