Repository navigation
fix(cli)!: objectstack build rebinds a hook handler's parameter to the sandbox's ctx, and refuses the parameter lists it cannot rebind (#22837) - #22859
Conversation
…dbox's ctx WIP: extractLoweredBody(fn, label, face) with the hook face re-emitting the handler's parameter as var PARAM = ctx; ahead of the body, residue refusals (several parameters, a default, a rest parameter, a ctx-shadowing declaration), capability inference through the rebound names, three hook-context member refusals (provenance, ql, transaction), and parseFunction's span check. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…umeration Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
… the parameter rebind Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
… it does Also drops two inline lint directives naming a type-aware rule this repo's config never enables (and lints with --no-inline-config). Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 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 — 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 5ce5a51ba0fc673cf22f534e022ccc3f0342c08b && git checkout 5ce5a51ba0fc673cf22f534e022ccc3f0342c08b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6364375b51f6a301681185a78839115a9ccbd19f 2970d13e0714ea871960257d0d5ef2ce97ea5c63 && git checkout -B drift-repro 6364375b51f6a301681185a78839115a9ccbd19f && git merge --no-ff 2970d13e0714ea871960257d0d5ef2ce97ea5c63
node scripts/docs-audit/affected-docs.mjs --json 6364375b51f6a301681185a78839115a9ccbd19f
|
Contract reviewServed-tier: Inputs read: card #22837 (body and all four comments: the triage direction 6109239514, the claim 6109397230, the dev report 6110163358, the seat answer 6110191438), PR #22859 (body, 14-file list, net diff), the check-runs on the head, and for context only the base sources on Check-runs on the head, latest per name (33 runs). Completed success: Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check consumer gates, Type Check debt ledger, Type Check source gates, filter. Completed skipped: Console Pin Gate, Packed-tarball smoke (opt-in). Still ① Derived judgments
② Semver level
③ Boundary flagsDev deviations, each answered:
The one Out-of-scope findings:
My own, for the seat: the nested-pattern residual in ① item 4 (non-blocking, a small exact check on the parameter node would close it); and the nine check-runs without a conclusion at read time, which this record does not call green. Implemented-by: VERDICT: FAIL — one blocking item: the Generated by Claude Code |
The span fix lowers one method-shorthand shape the base refused, so the diff widens one surface while it narrows others; both arms are read. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Resolves the four lower-callables.ts hunks PR #22849 shares with this branch by carrying both parameters, ref then face: tryExtractBody's signature, the hook call site, lowerActionCallable's tryExtract type, and the action call site. The bodylessCallables / noteUnlowered logic is main's, byte for byte. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review on the corrected head. Inputs read: card #22837 (body and all six comments: the triage direction 6109239514, the claim 6109397230 as edited, the dev reports 6110163358 and 6110732640, the seat answer 6110191438, superseded), PR #22859 (body, the 14-file list, the net diff against Check-runs on the head, latest per name (35 runs), read twice: at 15:48 UTC and again at 15:51:59 UTC before signing. Completed success: Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core and its six shards, The card this PR closes must claim this branch, Type Check consumer gates, Type Check debt ledger, Type Check source gates, Type Check workspace, TypeScript Type Check, filter. Completed skipped: Console Pin Gate, Packed-tarball smoke (opt-in). Lint & Repo Gates, which carries the repo-wide lint and the repo gates the dev ran locally ( ① Derived judgmentsSection ① of record 6110348222 (nine items) was judged on
② Semver level
③ Boundary flagsRound 1 (report 6110732640) raises no
Round 0's deviations stand as answered in record 6110348222 (items 1 to 8 there). Item 5 (Clause-②) is now closed by the correction judged in ②, and item 6 (the #22849 overlap) by the merge resolution judged in ① item 1. Item 8 ( Out-of-scope findings carried from the earlier record, unchanged: the regex body-peel (#22860); the action face's parameter question (a card for the seat); My own, for the seat: the nested-pattern residual (① item 7), non-blocking; and the theoretical two-face shared function (① item 1), not a finding. Implemented-by: VERDICT: PASS — the earlier record's one blocking item is met in the PR body, the changeset and the claim, with the level and the ADR-0087 disposition unchanged and consistent; the merge resolution in Generated by Claude Code |
Fixes #22837
Clause-②: yes (narrowing)
What changes
objectstack buildlowers a hook handler's statements intobody.source, and the runtime runs a body as(async (ctx) => { SOURCE }), soctxis the only name a body is given. The lowering dropped the handler's parameter list. A handler namedasync (hookCtx) => …orasync ({ input }) => …lowered at exit 0 and threwReferenceErroron every run.planHookParameterRebindinpackages/cli/src/utils/extract-hook-body.ts. On the hook face the handler's one parameter is re-emitted as the body's first statement:var hookCtx = ctx;,var { input, previous } = ctx;. The parameter is read off the TypeScript parse thatdetectFreeIdentifiersalready performs, never off the regex peel, so the free-identifier verdict and the rebind agree on what the parameter binds. Nothing is emitted for a parameter namedctx, or for no parameter. Those lower byte-identically: pinned by requiring the hook face's output to equal the unchanged action face's over six shapes.forbidden-token, naming the parameter. Covered: more than one parameter, a default value, a rest parameter. Also covered: a pattern that binds the namectx, and a body that declaresctx(or the parameter's own name) withlet/const/class/functionwhere the prologue would read it. Last, a destructuredlog/crypto/title/apitaken apart further than a plain name, which the capability inference cannot follow. The handler is bundled, as.sudo(is.CAPABILITY_PATTERNSspellsctx.log.andctx.crypto.randomUUID.reboundCapabilitiesre-spells each with the receiver renamed, and nothing else, for an identifier parameter, an object-rest element, and a destructuredlog/crypto/title. A renamed handler infers exactly what the same handler namedctxinfers; seven pairs are pinned.packages/cli/test/lowered-body-face-enumeration.test.ts. It derives the in-process face fromHookContextSchema's source (packages/spec/src/data/hook.zod.ts, one level into eachz.object, tombstones excluded). It derives the body face frombuildSandboxContext's source (packages/runtime/src/sandbox/body-runner.ts, one level into an object literal or a passthrough). Every member absent from the body must be refused, which is measured behaviourally throughextractHookBody, or recorded inDELIBERATE_EXCEPTIONSwith its reason. Stale exceptions red too. Both reads are declared inscripts/cross-package-test-inputs.mjsandturbo.json. Neither package is edited.dispatch.scope,submitted). Three were unrefused, and this PR now refuses them as the family's own rule prescribes (the VM lacks them):ctx.provenance,ctx.ql,ctx.transaction. They are hook face only, andctx.api.transaction(fn)is exempt. One is a recorded exception:id, which none of the five hook-context assembly sites in objectql'sengine.tssets, so it is absent on both faces.async ({ input, submitted }) => …andasync ({ dispatch: { scope } }) => …used to lower, because the scan read a body that had lost its parameter list. The scan now reads the rebound source.LoweringFaceishook | action. An action body runs as(async (input, ctx) => …), a different binding list.lowerCallablesandos lint'shook-body-loweringrule pass the face of the slot, so an action target lowers exactly as before. The publishedextractHookBodykeeps its signature and is the hook face.parseFunction's span check (detect-free-identifiers.ts, an in-place fix: see Deviations). A method shorthand holding exactly one nested function returned the NESTED function as the handler. Measured on the base: the nested arrow's parameter was read as the handler's, a method local it closed over was refused as free, and a module-scope helper outside it lowered unreported. The parse now accepts only the node that spans the whole source.lowered-body-door.dogfood.test.ts,lbd_parammoves from documenting the500to proving the rebind, with a destructured cell beside it and the residue (lbd_two) bundled likelbd_stash.content/docs/automation/hook-bodies.mdxstates the rebind and the new refusals. The changeset is BREAKING (narrowing), with before and now per door.Premise, re-measured on
origin/main1eff3224dbefore any codepeelToBlockBodykeeps the block and drops the header. Through esbuild (bundle-require, asloadConfigloads a config),async (hookCtx: HookCtx) => { hookCtx.input.note = 'a' }becamebody.source=hookCtx.input.note = "a";with nothing bindinghookCtx.quickjs-runner.ts's L2 wrapper is(async (ctx) => { SOURCE })for hook and job origins, and(async (input, ctx) => …)for actions.lbd_paramassertedSandboxError/500.Mechanism assumptions (order §2), measured
peelToBlockBodyafter esbuild:async (x) => {}. Bareasync x =>is printed with parentheses, and a TypeScript annotation is stripped. Alsoasync function (x), a named function, a method shorthand, a destructuring and a nested pattern, andasync () => {}. All rebind or refuse as above. The regex peel itself mis-peels some bodies. That is pre-existing and unchanged here; see Acceptance notes.check:cross-package-test-inputsgreen). Ablations are below.HookContextSchema(the declared contract). The producer side is cross-checked by hand: all fiveHookContextliterals in objectql'sengine.tssetprovenance,transactionandql, and none setsid.Measured at the doors
1eff3224dbootStack(config, { artifact }),lbd_param(hookCtx)SandboxError: … ReferenceError: 'hookCtx' is not defined, REST500, nothing storednamed:alpha; REST201on both, the same stored notelbd_destructure(({ input }))ReferenceError: 'input' is not defined,500201lbd_two((ctx, suffix))two:alphaobjectstack build, a two-parameter hookctxandextra; a renamed hook in the same app shipsvar hookCtx = ctx;objectstack build --strict-body, samehook 'two_params'(measured on a scratch app built throughbin/run-dev.js)os lint, samehook-body/bundled-fallbackwarning athooks[i].handleros lint, an(input, ctx)action targetThe example corpus, lowered with base and with this head (
lowerCallablesoverloadConfigof eachexamples/*/objectstack.config.ts):app-crm,app-multi-packageandapp-showcaseare byte-identical.app-todo'stask_logicmoves from lowered to bundled, refused forctx.ql(it reads the kernel logger offctx.ql). Its lowered body was already a silent no-op: it readsctx.input.data, which the body face does not carry, soif (!data) return;returned before the completion stamp. Bundled, it runs in-process again wherever the runtime module is served.Tests
pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 283 files, 4231 tests passed. This was the fullunittier, run one006f1a65. The only delta toa27cba278renames a private option and deletes two comment lines, and the targeted re-run below covers it. Theintegrationtier is declared to CI: the diff touches no spawn entry or driver boot path.a27cba278: eight cli files, 179 tests passed (the rebind, enumeration, extractor, lowering, free-identifier, lint rule, refusal-kind and published-subpath pins).lowered-body-door.dogfood.test.ts: 13 passed.pnpm --filter @objectstack/cli typecheck(tsc plus the test layer): green;check:test-typecheckholds its ledger unchanged.Ablations (committed first, mutated through
scripts/ablation-replace.mjs, each restored to HEAD's blob with an emptygit diff HEAD)Each subject resolves from source (relative imports to
packages/cli/src, and the pin reads the two source files directly), so nodist/is in the path.rx: SUBMITTED_RX,was replaced with a never-matchingrx. The enumeration pin went red on exactly['submitted'].ablationOnlyMemberwas added toHookContextSchema's source (packages/spec, mutated for the run and restored, never committed). The pin went red on exactly['ablationOnlyMember']. A first attempt used an anchor its own replacement contained, and the tool refused it before the test ran (anchor count 1 to 1). That run is void and was redone with a disjoint anchor.const rebind = NO_REBIND;). The door went red on 6 of 13 tests, with the card's failure exactly:ReferenceError: 'hookCtx' is not defined,expected 500 to be 201,ReferenceError: 'input' is not defined, andlbd_two ships no body.Gates
All on
a27cba278.node scripts/pm/dispatch-gates.mjs --commands, re-derived from this diff with no paths, gives 113 families: every family derived at dispatch exceptpnpm lint(below), plus the ones the diff adds, such ascheck:adr-0087-registration,check:changeset-no-majorandcheck:empty-changeset. All 113 exited 0. Reconciled with--ran: "113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED (a DERIVED zero — all 113 recorded an exit code and none of them is 3)".PREREQUISITE NOT MET(exit 3) because eight packages had never been built in this worktree:check:skill-examples(client-react) andcheck:dual-build-cjs-loads. I built those packages (57 of 57 turbo tasks were cache hits) and re-ran both: exit 0.check:adr-0087-registrationreads one declared-breaking changeset:[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).check:cross-package-test-inputsreads: "30 package(s) read outside themselves, all declared".pnpm --filter @objectstack/cli typecheckandpnpm --filter @objectstack/dogfood typecheckexit 0 ona27cba278.--listFilesOnlyshows each new or edited test inside a program: the twosrc/tests intsconfig.json, the twotest/files intsconfig.test.json, and the door in dogfood's.Lint. I ran a proven narrowing rather than
pnpm lint, with three pieces of evidence. First, the population: eslint's own config over this branch's 14 files, of which--format jsonreports 11 linted and 3 with no matching configuration (the changeset, the.mdx,turbo.json). Second, the result: 0 errors and 0 warnings. Third, invariance:eslint.config.mjsenables no type-aware linting (noparserOptions.projectanywhere, as its own header states), so no verdict on an untouched file can move with this diff. The repo-widepnpm lintrun belongs to CI.Deviations from the order and the ruling text
var, not the ruling's literalconst. A parameter is a mutable, function-scoped binding.constwould TypeError on a handler that reassigns its parameter, which runs fine in-process (pinned in "lets the body reassign its parameter"). Aletorconstwould also SyntaxError against a body's ownvarof the same name.varis the one spelling with a parameter's semantics.provenance,ql,transaction). They are the enumeration pin's classification under the ruling's either/or: none of the three has a reason to be a deliberate exception.parseFunction's span check is an in-place fix indetect-free-identifiers.ts, under the bounded exemption. It is the same family; the rebind depends on it, because without it a method shorthand with one inner arrow gets that arrow's parameter rebound; the fix is mechanical (one span comparison); no other claim holds the file; and the gates are the same.detect-free-identifiers.ts(+ test),lint/hook-body-lowering.ts(+ test),hook-body.ts(docblock only),scripts/cross-package-test-inputs.mjs,turbo.json. Each is required by the face split, the span fix or the pin's declared reads. Nopackages/runtimeorpackages/specfile is edited.--strict-bodyandos linttherefore accept that shape where they refused it. The line above is the claim'sno (narrowing), copied as ordered; byscripts/pm/clause2-line.mjs's arms the honest reading may beyes (narrowing).objectstack build --strict-bodymiscounts — it prints "N callable(s) lack a metadata body" with one more than the callables it lists, because--jsonissuescarries an aggregate pseudo-entry beside the real ones #22839) onpackages/cli/src/utils/lower-callables.ts. Both PRs add a parameter on the same four lines:tryExtractBody's signature, the hook call site,lowerActionCallable'stryExtracttype, and the action call site. That PR addsref, this one addsface. The resolution is mechanical (tryExtractBody(fn, originLabel, ref, face)). Nothing here touches the counting orbodylessCallablescode.Acceptance notes
objectstack buildof a scratch app on this branch; the peel is unchanged from base. Two cases ship abody.sourcethat is not valid JS (SyntaxErrorundernew Function). The first is a}inside a string, template or regex literal:ctx.input.note = 'a}b'ships asctx.input.note = "a. The second is a function or method form with a statement-level parenthesised inner arrow, which ships asreturn (a + "!"; …});. The build's only signal ishook-body-source-unparseable, which tells the author to fix a syntax error their handler does not have. A third case, a{inside a string, is refused asunparseable(bundled, the safe direction). This is the same family. The TypeScript node this PR already reads holds the right body span.(params, context)loses both names. No example or package ships an inline action target, the in-process action calling convention was not measured here, and the action face's lowering is unchanged by this PR.os lint's write-set extractor keys on the literalctx.input.FIELD. A rebound body writeshookCtx.input.FIELD, which it does not follow, exactly as it does not follow an in-bodyconst c = ctx. This is a coverage gap, not a regression.ctx.inputenvelope split (input.dataexists in-process and not in the body) is a documented, deliberately undecided divergence onHookContextSchema.input. The pin treatsinputas opaque.Generated by Claude Code