Repository navigation
fix(cli)!: objectstack build refuses to lower a hook body reaching ctx.dispatch.scope or ctx.submitted (#22810) - #22834
Conversation
…x.dispatch.scope or ctx.submitted
The sandboxed body face carries ctx.dispatch as { mode, index } and no
ctx.submitted, by declared contract, so a lowered handler writing scope
TypeErrors on its first run and one reading submitted sees undefined,
while the in-process handler works. extractHookBody now refuses both
members as forbidden tokens, the way it refuses .sudo( and .create(, so
the build bundles the handler instead and --strict-body fails on it.
Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4
Co-authored-by: Claude <noreply@anthropic.com>
…scope, which the build now refuses lbd_stash no longer lowers, so its divergence cannot reach the artifact door; it is re-pointed at the refusal (bundled, no body; in-process it still stashes). The divergence pin moves to lbd_param, a handler whose parameter is not named ctx: the lowering drops the parameter list, so the body throws a ReferenceError the in-process handler never meets. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…ed at lowering Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…R-0087 disposition reads Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 28 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 a1e15044bb0aa86f5479755bbbb914e6afd177a2 && git checkout a1e15044bb0aa86f5479755bbbb914e6afd177a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin efcbac73cc58510278b970949ac3b65cb9712815 405c2c3c21092a6a631e139a9dea7544f96ba061 && git checkout -B drift-repro efcbac73cc58510278b970949ac3b65cb9712815 && git merge --no-ff 405c2c3c21092a6a631e139a9dea7544f96ba061
node scripts/docs-audit/affected-docs.mjs --json efcbac73cc58510278b970949ac3b65cb9712815 |
Contract reviewServed-tier: Inputs read: card #22810 (body and all four comments: triage ruling 6107382836, filing-seat note 6107432274, claim 6108435063, os-dev-report 6109013472), PR #22834 (body, 7-file list, net diff against base ① Derived judgments1. The accept-set change — right.
2. Regex reach — right, with one unstated gap of the Reflect.get kind. I traced both patterns by hand against the spellings the PR claims.
3. The declared contract — the omission is declared, so this is a narrowing to it, not a new rule. 4. Public surface. No export moves. 5. The dogfood pin — moved, not deleted, and still load-bearing.
6. The two docs pages — every changed sentence checked, all true against the diff and
7. New pins in ② Semver level
③ Boundary flagsDev flags (the report's
Out-of-scope findings, each placed:
Flags raised by this review:
Check-runs on the head at the last read (2026-10-11T12:43Z), collapsed to the latest run per name, 35 names: 32 Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22810
Clause-②: no (narrowing)
What changes
extractHookBody(packages/cli/src/utils/extract-hook-body.ts) now refuses a hook handler that reachesctx.dispatch.scopeor readsctx.submitted. Both are newFORBIDDEN_PATTERNSentries beside.sudo(and.create(, of kindforbidden-token. The refusal names the member and the remedy. Thehook-body-loweringlint rule calls the same function (judge()inpackages/cli/src/lint/hook-body-lowering.ts, read at the call site), so one edit coversobjectstack buildandos lint.The premise was verified on
origin/mainb7cd1af9dfbefore any code was written:buildSandboxContext(packages/runtime/src/sandbox/body-runner.ts) copiesctx.dispatchas{ mode, index }("scopeis deliberately not copied") and assembles nosubmitted.HookContextSchema(packages/spec/src/data/hook.zod.ts) says the body face carries{ mode, index }"and notscope", and thatsubmittedis "NOT marshalled into the sandboxedbodyface".So direction (A) holds. There is no runtime change and no spec change.
What "refused" means on each door (measured)
I built a scratch app with 7 hooks through the real CLI (
packages/cli/bin/run-dev.js build, from source). I then booted it withbootStack(config, { artifact })in two ways: on the build's owndist/, with its runtime module beside it, and on the dogfood stand-in artifact, which has no runtime module.b7cd1af9dfobjectstack buildobjectstack build --strict-bodyfetch(control onlyhook 'zz_scope',hook 'zz_scope_alias'andhook 'zz_sub'os linthook-body/bundled-fallbackwarning athooks[i].handler, the rule.sudo(lands on; exit 0SandboxError: hook 'zz_scope' threw: TypeError: cannot set property 'stashed' of undefined, REST500 INTERNAL_ERROR, nothing stored. submitted:typeof ctx.submittedis'undefined'201, the row is stored and stashed. submitted:'object'. Both match the source boothook refused: its handler names no function of its own package); the insert answers201without the hook201, stashed;'object'Mechanism assumption 4 was measured before its refusal was pinned: a lowered body reads
ctx.submittedasundefinedon an update, both on the stand-in artifact and on the real build's artifact. In-process it reads'object'.The last two rows follow the precedent's documented fallback:
.sudo(,.create(andfetch(behave the same way. A deployment that must be body-only builds with--strict-body.content/docs/automation/hook-bodies.mdxnow says so.Reach (mechanism assumption 3)
The precedents are receiver-loose, because
sudonames nothing except the host method.scopeandsubmittedare also ordinary field names: platform objects declare ascopefield, and reading it throughctx.input.scopeis working, lowerable code. The reach is therefore receiver-loose on ROOT identifiers (ctxor any local alias of it), and never on a member reached through a record image:dispatch.scopeis refused when the body namesdispatchand reachesscope. The spellings covered arectx.dispatch.scope(with or without?.),ctx.dispatch['scope'],const d = ctx.dispatch; d.scope,const { dispatch } = ctx; dispatch.scope,const { scope } = ctx.dispatchandconst { dispatch: { scope } } = ctx.submittedis refused forctx.submitted,ctx?.submitted,ctx['submitted'],const c = ctx; c.submitted,const { input, submitted } = ctxandconst { submitted: s } = ctx.ctx.dispatch.modeandctx.dispatch.index, andctx.input.scope/ctx.previous.submitted, also beside adispatchread..submittedoff a root-bound row (const row = ctx.input; row.submitted), a field of that name destructured from a record, andx.submitted/x.scopeinside a string literal.ctx.dispatch[k]);Reflect.get(ctx.dispatch, 'scope');(ctx).submitted);A probe of 29 spellings matched this list exactly. The regex stays linear: a 680 KB body extracts in 480 ms, including the TypeScript parse.
The dogfood pin: moved, not deleted
packages/qa/dogfood/test/lowered-body-door.dogfood.test.tspinnedlbd_stash, which writesctx.dispatch.scope, as its divergence. Against this change the base version of the file goes red on exactly the three cases the filing seat predicted: the anti-vacuity case and both divergence cases. I ran it once and then deleted the copy.lbd_param, a handler whose parameter is namedhookCtxrather thanctx. The lowering ships the body and drops the parameter list, so the body'shookCtxis unbound. Lowered, it throwsReferenceErrorand REST answers500 INTERNAL_ERROR. In-process it answers201. This is the next real lowered-only divergence measured, and it is a defect in its own right (see the findings below).lbd_stashis re-pointed at the refusal. The lowering bundles it and ships no body for it, while the other three hooks still lower. In-process it still stashes, through the handle and over REST.lbd_stashstays out of the booted artifact. The stand-in writes no runtime module, so a bundled handler would have nothing to resolve against there. A real build ships it in the.mjs.Tests
All runs below are at
405c2c3c2unless noted otherwise.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 281 files and 4185 tests passed (VERDICT command-exit 0). The integration tier is declared to CI: this diff touches no spawn entry and no integration-tier file.pnpm --filter @objectstack/cli typecheck(tsc --noEmitpluscheck:test-typecheck): exit 0.pnpm --filter @objectstack/dogfood typecheck: exit 0.--listFilesconfirms that both changed test files are in the type-check programs.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/lowered-body-door.dogfood.test.ts: 11 passed, at9c47337a2. The file is unchanged since.packages/cli/test/extract-hook-body.test.ts: the scope write, direct and aliased; the submitted read, direct and destructured; the controls formode/indexand for fields namedscope/submitted; andlowerCallableskeeping the very function bundled, which still stashes on a host-shaped context.packages/cli/src/lint/hook-body-lowering.test.ts: both refusals land onhook-body/bundled-fallbackat their paths, the mode/index control stays silent, and lint agrees with what the build records.node scripts/ablation-replace.mjsin wrap mode with the anchor counted, the blob changed and then restored to the HEAD blob with an emptygit diff HEAD, on committed9c47337a2. The subject is reached by relative source import, so nodist/leg was needed.rx: DISPATCH_SCOPE_RXreplaced with a never-matching regex: 5 cli tests and 1 dogfood test went red.rx: SUBMITTED_RXreplaced the same way: 4 cli tests went red.node scripts/pm/dispatch-gates.mjs --commandsderived 94 commands from the actual diff. That set is a superset of the 58 in the dispatch order apart frompnpm lint. All 94 were run and reconciled with--ran: 94 run, 0 NOT MEASURED, every one exit 0. Two of them first exited 3 (PREREQUISITE NOT MET, because unrelated packages had nodist/):check:skill-examplesandcheck:dual-build-cjs-loads. Both were re-run green after building those packages. The gates that readdist/were re-run after@objectstack/cliwas rebuilt at HEAD.pnpm lint:eslint --no-inline-config --format jsonover the 7 changed paths reports 4 files linted and 3 ignored as "no matching configuration": the changeset and the two.mdxfiles.eslint.config.mjsenables no type-aware linting (noparserOptions.project). Its only cross-file reads are two baseline JSON files this diff does not touch. So the diff cannot move any untouched file's verdict.Acceptance notes
origin/mainmoved 3 commits past the base (efcbac73c), and none of them touch these surfaces. I did not merge; the queue rebuilds on merge.content/docs/automation/hook-bodies.mdxand thectx.submittedline incontent/docs/protocol/objectql/security.mdx.FROM → TOlabel line reads tocheck:adr-0087-registrationas a code-rewrite prescription, which contradicts the only disposition that fits (not-required (no-migration-prescription): no metadata, export or stored shape moves). This was measured: the gate refused the labelled draft and passed the prose form.hook-bodies.mdx.Out-of-scope findings (handed to the seat, not filed here)
ctx(async (hookCtx) => …), or that destructures it (async ({ input }) => …), lowers at exit 0. Its body then throwsReferenceErroron every run, and REST answers500 INTERNAL_ERRORat the artifact door, both with the runtime module served and without it. In-process it answers201.extractHookBodypeels the body, anddetectFreeIdentifierscounts the parameters as bound. It is the same family as this card, and it is now the dogfood divergence pin.objectstack build --strict-bodycounts one more callable than it found. With 4 refused handlers it prints5 callable(s) lack a metadata body; on base, 1 refused handler printed2. Inpackages/cli/src/commands/compile.ts,issues.lengthcounts the extraction warnings plus an aggregate row. The--jsonissueslist carries that aggregate row as if it were a callable.Generated by Claude Code