Skip to content

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

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22837-lowered-param-rebind
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22837-lowered-param-rebind

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22837

Clause-②: yes (narrowing)

What changes

objectstack build lowers a hook handler's statements into body.source, and the runtime runs a body as (async (ctx) => { SOURCE }), so ctx is the only name a body is given. The lowering dropped the handler's parameter list. A handler named async (hookCtx) => … or async ({ input }) => … lowered at exit 0 and threw ReferenceError on every run.

  • The rebind. planHookParameterRebind in packages/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 that detectFreeIdentifiers already 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 named ctx, 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.
  • The residue (A), refused as forbidden-token, naming the parameter. Covered: more than one parameter, a default value, a rest parameter. Also covered: a pattern that binds the name ctx, and a body that declares ctx (or the parameter's own name) with let / const / class / function where the prologue would read it. Last, a destructured log / crypto / title / api taken apart further than a plain name, which the capability inference cannot follow. The handler is bundled, as .sudo( is.
  • Capability inference follows the rebound names. CAPABILITY_PATTERNS spells ctx.log. and ctx.crypto.randomUUID. reboundCapabilities re-spells each with the receiver renamed, and nothing else, for an identifier parameter, an object-rest element, and a destructured log / crypto / title. A renamed handler infers exactly what the same handler named ctx infers; seven pairs are pinned.
  • The enumeration pin that closes the family. It lives in packages/cli/test/lowered-body-face-enumeration.test.ts. It derives the in-process face from HookContextSchema's source (packages/spec/src/data/hook.zod.ts, one level into each z.object, tombstones excluded). It derives the body face from buildSandboxContext'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 through extractHookBody, or recorded in DELIBERATE_EXCEPTIONS with its reason. Stale exceptions red too. Both reads are declared in scripts/cross-package-test-inputs.mjs and turbo.json. Neither package is edited.
  • What the pin found. It found six absent members. Two were already refused (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, and ctx.api.transaction(fn) is exempt. One is a recorded exception: id, which none of the five hook-context assembly sites in objectql's engine.ts sets, so it is absent on both faces.
  • Parameter-list destructuring now meets the member refusals. async ({ input, submitted }) => … and async ({ dispatch: { scope } }) => … used to lower, because the scan read a body that had lost its parameter list. The scan now reads the rebound source.
  • The face split. LoweringFace is hook | action. An action body runs as (async (input, ctx) => …), a different binding list. lowerCallables and os lint's hook-body-lowering rule pass the face of the slot, so an action target lowers exactly as before. The published extractHookBody keeps 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.
  • The door, the docs, the changeset. In lowered-body-door.dogfood.test.ts, lbd_param moves from documenting the 500 to proving the rebind, with a destructured cell beside it and the residue (lbd_two) bundled like lbd_stash. content/docs/automation/hook-bodies.mdx states the rebind and the new refusals. The changeset is BREAKING (narrowing), with before and now per door.

Premise, re-measured on origin/main 1eff3224d before any code

  • peelToBlockBody keeps the block and drops the header. Through esbuild (bundle-require, as loadConfig loads a config), async (hookCtx: HookCtx) => { hookCtx.input.note = 'a' } became body.source = hookCtx.input.note = "a"; with nothing binding hookCtx.
  • quickjs-runner.ts's L2 wrapper is (async (ctx) => { SOURCE }) for hook and job origins, and (async (input, ctx) => …) for actions.
  • lbd_param asserted SandboxError / 500.

Mechanism assumptions (order §2), measured

  1. Holds, all three.
  2. These spellings reach peelToBlockBody after esbuild: async (x) => {}. Bare async x => is printed with parentheses, and a TypeScript annotation is stripped. Also async function (x), a named function, a method shorthand, a destructuring and a nested pattern, and async () => {}. All rebind or refuse as above. The regex peel itself mis-peels some bodies. That is pre-existing and unchanged here; see Acceptance notes.
  3. The pin derives, can fail, and the cross-package reads are declared (check:cross-package-test-inputs green). Ablations are below.
  4. The in-process face is HookContextSchema (the declared contract). The producer side is cross-checked by hand: all five HookContext literals in objectql's engine.ts set provenance, transaction and ql, and none sets id.

Measured at the doors

door base 1eff3224d this PR
bootStack(config, { artifact }), lbd_param (hookCtx) SandboxError: … ReferenceError: 'hookCtx' is not defined, REST 500, nothing stored both boots store named:alpha; REST 201 on both, the same stored note
same, lbd_destructure (({ input })) (new cell) under the ablation below: ReferenceError: 'input' is not defined, 500 both boots store the same row, 201
same, lbd_two ((ctx, suffix)) lowered bundled, no body; in-process stores two:alpha
objectstack build, a two-parameter hook lowered at exit 0 bundled, exit 0, with a warning naming its two parameters, ctx and extra; a renamed hook in the same app ships var hookCtx = ctx;
objectstack build --strict-body, same exit 0 exit 1, naming hook 'two_params' (measured on a scratch app built through bin/run-dev.js)
os lint, same silent hook-body/bundled-fallback warning at hooks[i].handler
os lint, an (input, ctx) action target silent silent (action face)

The example corpus, lowered with base and with this head (lowerCallables over loadConfig of each examples/*/objectstack.config.ts): app-crm, app-multi-package and app-showcase are byte-identical. app-todo's task_logic moves from lowered to bundled, refused for ctx.ql (it reads the kernel logger off ctx.ql). Its lowered body was already a silent no-op: it reads ctx.input.data, which the body face does not carry, so if (!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 full unit tier, run on e006f1a65. The only delta to a27cba278 renames a private option and deletes two comment lines, and the targeted re-run below covers it. The integration tier is declared to CI: the diff touches no spawn entry or driver boot path.
  • Targeted re-run on 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-typecheck holds its ledger unchanged.

Ablations (committed first, mutated through scripts/ablation-replace.mjs, each restored to HEAD's blob with an empty git diff HEAD)

Each subject resolves from source (relative imports to packages/cli/src, and the pin reads the two source files directly), so no dist/ is in the path.

  1. rx: SUBMITTED_RX, was replaced with a never-matching rx. The enumeration pin went red on exactly ['submitted'].
  2. A fake member ablationOnlyMember was added to HookContextSchema'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.
  3. The rebind was disabled (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, and lbd_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 except pnpm lint (below), plus the ones the diff adds, such as check:adr-0087-registration, check:changeset-no-major and check: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)".

  • Two gates first answered PREREQUISITE NOT MET (exit 3) because eight packages had never been built in this worktree: check:skill-examples (client-react) and check: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-registration reads one declared-breaking changeset: [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).
  • check:cross-package-test-inputs reads: "30 package(s) read outside themselves, all declared".
  • pnpm --filter @objectstack/cli typecheck and pnpm --filter @objectstack/dogfood typecheck exit 0 on a27cba278. --listFilesOnly shows each new or edited test inside a program: the two src/ tests in tsconfig.json, the two test/ files in tsconfig.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 json reports 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.mjs enables no type-aware linting (no parserOptions.project anywhere, as its own header states), so no verdict on an untouched file can move with this diff. The repo-wide pnpm lint run belongs to CI.

Deviations from the order and the ruling text

  • var, not the ruling's literal const. A parameter is a mutable, function-scoped binding. const would TypeError on a handler that reassigns its parameter, which runs fine in-process (pinned in "lets the body reassign its parameter"). A let or const would also SyntaxError against a body's own var of the same name. var is the one spelling with a parameter's semantics.
  • Three member refusals the card did not name (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 in detect-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.
  • Files beyond the claim's named surface: 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. No packages/runtime or packages/spec file is edited.
  • A measured widening, for the seat's Clause-② call. The span fix makes one shape lower that was refused: a method shorthand with exactly one nested function closing over a method local. --strict-body and os lint therefore accept that shape where they refused it. The line above is the claim's no (narrowing), copied as ordered; by scripts/pm/clause2-line.mjs's arms the honest reading may be yes (narrowing).
  • Overlap with fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849 (finding: objectstack build --strict-body miscounts — it prints "N callable(s) lack a metadata body" with one more than the callables it lists, because --json issues carries an aggregate pseudo-entry beside the real ones #22839) on packages/cli/src/utils/lower-callables.ts. Both PRs add a parameter on the same four lines: tryExtractBody's signature, the hook call site, lowerActionCallable's tryExtract type, and the action call site. That PR adds ref, this one adds face. The resolution is mechanical (tryExtractBody(fn, originLabel, ref, face)). Nothing here touches the counting or bodylessCallables code.

Acceptance notes

  • Out of scope, reported for the seat: the regex peel mis-peels some bodies, at exit 0. Measured through a real objectstack build of a scratch app on this branch; the peel is unchanged from base. Two cases ship a body.source that is not valid JS (SyntaxError under new Function). The first is a } inside a string, template or regex literal: ctx.input.note = 'a}b' ships as ctx.input.note = "a. The second is a function or method form with a statement-level parenthesised inner arrow, which ships as return (a + "!"; …});. The build's only signal is hook-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 as unparseable (bundled, the safe direction). This is the same family. The TypeScript node this PR already reads holds the right body span.
  • The action face has the same parameter question: a target written (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 literal ctx.input.FIELD. A rebound body writes hookCtx.input.FIELD, which it does not follow, exactly as it does not follow an in-body const c = ctx. This is a coverage gap, not a regression.
  • The ctx.input envelope split (input.data exists in-process and not in the body) is a documented, deliberately undecided divergence on HookContextSchema.input. The pin treats input as opaque.

Generated by Claude Code

…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>
… the parameter rebind

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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 23 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/src/hook-body.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx (via lowerCallables (symbol, a top-level function), api.read (literal, a string literal in reboundCapabilities), crypto.uuid (literal, a string literal in reboundCapabilities))
  • content/docs/automation/hooks.mdx (via api.read (literal, a string literal in reboundCapabilities))
  • content/docs/automation/jobs.mdx (via api.read (literal, a string literal in reboundCapabilities))

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

  • content/docs/releases/v17/17-4.mdx (via extractHookBody (symbol, a top-level function))

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 changed file(s) yielded no anchor (packages/cli/src/hook-body.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 6364375b51f6a301681185a78839115a9ccbd19f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5ce5a51ba0fc673cf22f534e022ccc3f0342c08b — the merge of head 2970d13e0714ea871960257d0d5ef2ce97ea5c63 into base 6364375b51f6a301681185a78839115a9ccbd19f, 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 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

⚠️ 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 6364375b51f6a301681185a78839115a9ccbd19f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a27cba27810eb9775731c52c8048fe94b10ba0ba
Local-runs: none

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 origin/main (extract-hook-body.ts, detect-free-identifiers.ts, lower-callables.ts, lint/hook-body-lowering.ts, body-runner.ts, quickjs-runner.ts, hook.zod.ts, objectql engine.ts and hook-wrappers.ts, hook-bodies.mdx, examples/app-todo, scripts/cross-package-test-inputs.mjs, turbo.json, scripts/pm/clause2-line.mjs), plus the lower-callables.ts patch of PR #22849 for the overlap. Nothing was built, run or ablated.

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 in_progress at read time, with no conclusion: Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Test Core (1/6) through (6/6), Type Check workspace. Those nine are not green; they are unanswered. The derived test families (the two new cli pins, the eight targeted cli files, the dogfood door) and the repo gates the dev ran locally (check:adr-0087-registration, check:cross-package-test-inputs, check:changeset-no-major, lint) have as yet no CI conclusion on this head. The Dogfood Regression Gate shards, which are complete and green, are the only CI reading of the door.

① Derived judgments

  1. The rebind on the hook face: right. planHookParameterRebind reads the parameter off the same parseFunction node the free-identifier pass uses, so the two cannot disagree on what the parameter binds; the prologue var PARAM = ctx; (or var PATTERN = ctx;, the pattern's text verbatim, pattern-internal defaults included) is emitted as the body's first statement. Checked against the two contracts it relies on: the runtime's L2 wrapper binds exactly ctx for hooks (quickjs-runner.ts:328) and (input, ctx) for actions (:333); objectql calls an in-process hook handler with exactly one argument (hook-wrappers.ts:370 and :376, engine.ts:4651 and :5110). So the handler's one parameter IS the context, and var is the right spelling: a parameter is a mutable, function-scoped binding, a const would TypeError on a handler that reassigns it (pinned, "lets the body reassign its parameter") and a let or const would SyntaxError against a body var of the same name. The ruling's literal const was illustrative; var is what "the author's code then runs as written" requires. A ctx-named or parameterless handler takes NO_REBIND, source is then block.source unchanged and reboundCapabilities adds nothing: byte-identical, and pinned by equality with the action face over six shapes. The action face is untouched: no prologue, the three new patterns carry face: 'hook' and are skipped, and the capability and forbidden scans run over block.source exactly as on the base. lowerCallables and the os lint rule pass the face of the slot, so the os validate runs none of the flow authoring lints, so a build-failing flow passes validate #3782 parity holds by construction.

  2. The residue refused as forbidden-token, naming the parameter: each right. Several parameters (the second has nothing to bind to; a (ctx, extra) handler is refused even with extra unused, which is correct because the in-process call passes one argument). A default (not reproducible as a plain binding). A rest parameter. A pattern binding the name ctx (the prologue would read its own left-hand side). A body declaring ctx or the parameter's own name with let/const/class/function at the body's top level, or function in a nested block: each a real divergence, not an over-refusal. The TDZ case (const ctx = hookCtx.input after var hookCtx = ctx;) is a ReferenceError; a top-level function hookCtx(){} is initialised at entry in-process but overwritten by the prologue's assignment once lowered; the nested-block function hookCtx(){} is the Annex B case the docblock names, where in-process the parameter name blocks the var-hoist and lowered the prologue's var is exactly the binding the block function clobbers. var of the parameter's name is correctly not refused (same binding). A capability-bearing member (api, log, crypto, title) destructured further than one plain name is refused because reboundCapabilities could not follow it and the sandbox would refuse the body at run time; the plain-name control lowers. One further narrowing the changeset does not name: on the hook face a handler whose source parseFunction cannot resolve to one node is now refused unparseable, where the base tolerated unparsed and lowered the body with no free-identifier check. I found no handler shape peelToBlockBody accepts that the three wraps plus the span check reject (arrow, anonymous and named function expression, method shorthand, new Function output all resolve), so this is the safe direction on a theoretical set. Right.

  3. The three new member refusals and the one exception: each a real absence, and the exception's reason is true. Read independently from the two sources. HookContextSchema (hook.zod.ts:494 on) declares id, object, event, input, result, previous, submitted, dispatch (mode, index, scope), session, provenance, referentialFieldClear, transaction, ql, api, user. buildSandboxContext (body-runner.ts:1061 on) returns input, previous, user, session, event, object, result, api, log, title, dispatch as { mode, index }, inputOptions, a conditional referentialFieldClear, crypto. Absent from the body: dispatch.scope, id, provenance, ql, submitted, transaction, exactly the pin's six. All five hook-context literals in objectql's engine.ts (:12923, :13217, :14276, :15589, :18205) set provenance, transaction and ql; none sets id, so id is undefined on both faces and the exception's reason holds, as does its second half (refusing id would refuse every row.id read off a root-bound record). The exemption of ctx.api.transaction(fn) is a published body route already (hook-bodies.mdx:136 on main; the body's api passes the engine's scoped api through), and hookContextMemberRx cannot reach it anyway: api after a dot is not a root receiver, and the call-exempt lookahead covers api.transaction( off a root alias. A field named provenance, ql or transaction under ctx.input or ctx.previous is not refused (pinned). row.ql off a root-bound record is over-refused, the safe direction the file already accepts for submitted.

  4. Parameter-list destructuring meeting the member refusals: right for what it claims, with a bounded residual. var { input, submitted } = ctx; meets SUBMITTED_RX's destructured limb, and var { dispatch: { scope } } = ctx; meets DISPATCH_SCOPE_RX's fourth limb (dispatch\s*:\s*\{[^{}]*\bscope), both pinned. The residual: every destructured limb is written \{[^{}]*\bMEMBER\b[^{}]*\}, so a member destructured beside a nested pattern (async ({ submitted, previous: { status }, input })) or itself destructured one level (async ({ provenance: { flowRunId } }), the natural spelling for an object member) escapes it, lowers, and reads undefined or TypeErrors at run time: the family's failure. The body spelling has the same limit on main, so the changeset's sentence "now meets the refusal the body spelling meets" is literally true, and the PR's docblock scopes the three members to "the spellings SUBMITTED_RX reads". Not blocking. The handler's parameter node is already in hand (bindingNames, the binding elements' property names), so an exact check of the pattern's top-level keys against the absent set would close the parameter-list half for nothing; recommended on the corrected head or on the family's route (finding: objectstack build ships an invalid lowered hook body when the handler has a } in a string or a statement-level inner arrow — the peel counts braces through strings and exits 0 #22860's card), the seat's call.

  5. The enumeration pin: derived, able to fail, and declared. inProcessFace walks the HookContextSchema initialiser to the root z.object literal through lazySchema and the builder chain, one level into a member whose root is itself z.object, dropping retiredKey tombstones; bodyFace finds function buildSandboxContext, takes its last return literal, resolves a shorthand through the function's locals (so dispatch yields { mode, index } out of the conditional), reads engineCtx?.MEMBER as passthrough, and a spread as a conditional member. I walked both readers against the sources by hand and they produce the six paths the "classifies today" snapshot lists. The anti-vacuity test requires known members on both faces and a non-empty absent set, so an empty derivation cannot pass; the stale-exception test reds an exception the derivation does not need; the dev's ablations a and b are the two failure modes that matter and both are reported red on exactly one path. The snapshot test is a hand list, but it sits beside the derived checks as a visible-diff pin, not in place of them. The two reads are declared where AGENTS.md requires: @objectstack/cli globs in scripts/cross-package-test-inputs.mjs and @objectstack/cli#test inputs in turbo.json, single files, not trees. The stated blind spots (api's own members, depth beyond one level, the action face) are honest and the first is pinned elsewhere.

  6. The parseFunction span check: a real defect on the base, correctly repaired, and an accept-set change in both directions. A method shorthand in the first wrap parses as a call followed by a block; with exactly one function nested in that block, that nested function was the only function-like node and was returned as the handler. The span comparison (getStart equals the prefix length, end equals prefix plus source) rejects it and the object-literal wrap reaches the method; the CONTROL test shows arrow and function expression were never affected. The rebind depends on it (without it the nested arrow's parameter would be rebound). Its effect on the public doors: a method local closed over by the nested function is no longer reported free, so that handler lowers where --strict-body exited 1 and os lint warned (a widening); a module-scope helper called outside the nested function is now reported, so that handler is refused where it lowered and threw (a narrowing). Judged in ②.

  7. The door and the corpus: right. lbd_param moves from documenting the 500 to proving the rebind (never deleted), lbd_destructure is added beside it, lbd_two rides with lbd_stash as a refused handler kept out of the booted artifact. The corpus claim is verified on main: examples/app-todo/src/objects/task.hook.ts:54 names its parameter ctx, reads ctx.input.data at :55 (absent on the body face, so the lowered body returned at if (!data) return;) and ctx.ql at :100; it now bundles, where it runs in-process again. No example file changes; the other three examples have no non-ctx handler parameter and no root read of the three members (git grep on main). That move is stated in the PR body and the dev report, not in the changeset or the docs; acceptable, since the example is not a published surface and nothing in it moves, though a line in the changeset would have cost nothing.

  8. Public surface: unchanged, as claimed. extractHookBody's signature and refusal kinds are the same; extractLoweredBody and LoweringFace are exported from the utils module only, not from the published @objectstack/cli/hook-body subpath (hook-body.ts changes its docblock only). No packages/runtime or packages/spec edit. Every one of the 14 files is inside the claim as widened by edit before the ACCEPT. The overlap with PR fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849 on lower-callables.ts is the same four lines adding ref there and face here; the two do not conflict in meaning, and the resolution tryExtractBody(fn, originLabel, ref, face) is mechanical.

  9. The hook-bodies.mdx edit: every sentence true against the diff. The new bullet (the three members, undefined for provenance, live host objects for ql and transaction, ctx.api.object(name) and ctx.api.transaction(fn) not affected, the derivation sentence); "these refuse the body, not the handler" with the three doors; the new paragraph on the parameter (the two emitted spellings, byte-identical for ctx or none, the residue list, the action's (input, ctx) left alone). All hold.

② Semver level

  • Changeset @objectstack/cli: minor, declared BREAKING with the bang: the right level. The residue's refusals and the three member refusals narrow what objectstack build lowers at three public doors (the build's warning and bundled fallback, --strict-body exit 1, os lint hook-body/bundled-fallback); the family's convention is minor-with-BREAKING and check:changeset-no-major (inside Check Changeset, green) accepts it. Each BREAKING bullet is true against the diff, including the sixth (an (input, ctx) target passed to the published extractHookBody is refused for its second parameter while objectstack build lowers it on the action face). The fifth bullet records "It now lowers" for the method-shorthand shape: the changeset itself states a widening.
  • The ADR-0087 marker not-required (no-migration-prescription): the reasoning holds (a build-time verdict about handler code; no authorable key, export, spelling or stored shape moves; a refused handler keeps running bundled). The registration gate's signal is read from the arm (clause-②-narrowing), which the correction below does not change. Its CI conclusion sits under Lint & Repo Gates, in progress at read time.
  • The Clause-② line: the citation does not cover the shape, and the line must be yes (narrowing). The seat kept no (narrowing) on content/docs/automation/hook-bodies.mdx:169 as published text that already denied the refusal. Judged against the shape (a method shorthand with exactly one nested function, closing over a method local, refused as free-identifiers on the base): (a) line 169 is one bullet, "references to identifiers from value-only top-level imports", in the list "The CLI builder rejects any source that uses". It names one rejection cause; it says nothing about method shorthands, nested functions or a handler's own locals, so it neither promises nor denies anything about this shape. (b) Reading that list as exhaustive, which is what the seat's inference needs, is not available: the same document at :276 names "a module-scope identifier" as a refusal cause beyond line 169's imports, and the base refuses a module-scope const helper that is no import. Silence at 169 is therefore not a promise of acceptance. (c) The change is measured at two public doors: --strict-body moves from exit 1 to exit 0 and os lint from a warning to silence for a shape that was refused, while the same fix also narrows (a module-scope helper outside the nested function is now refused). That is the definition of the yes (narrowing) arm in scripts/pm/clause2-line.mjs: "a diff that widens one surface and narrows another. Both facts are true and both are read." The dev's recommendation (option A) is right. The correction is to the PR body and .changeset/22837-lowered-hook-parameter-rebind.md line 13, both to Clause-②: yes (narrowing). The level stays minor (both arms take at least minor) and the ADR-0087 disposition is unchanged (the arm is still narrowing).

③ Boundary flags

Dev deviations, each answered:

  1. var not the ruling's const: accepted, see ① item 1; it is the only spelling with a parameter's semantics.
  2. Three member refusals the card did not name: they are the triage ruling's own refuse-or-exception rule applied to what the pin derived; each verified absent (① item 3). Accepted.
  3. parseFunction span check under the bounded exemption: the defect is real, the rebind depends on it, the fix is one span comparison, no other open claim holds the file (fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849 edits lower-callables.ts only; cli: the 9 packages/cli-owned rule ids and the action-governance boot-log lines print one verdict sentence each (≤ 200 chars), with the reasoning behind os explain rule <id> (#22161, the domain:cli half) #22841 is held). Accepted as a fix; its Clause-② consequence is ruled in ②.
  4. Files beyond the claim's first surface: all named in the claim as widened before the ACCEPT; no runtime or spec edit. Accepted.
  5. Clause-②: answered in ②, yes (narrowing); the seat corrects the claim, the PR body and the changeset, as it undertook in 6110191438.
  6. Overlap with fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849: read both patches; no meaning conflict; whichever lands second merges main. Not a finding.
  7. origin/main merged into the branch before the final gate run: fine.
  8. pnpm lint as a proven narrowing rather than a run: the three pieces of evidence are coherent, but the repo-wide verdict is CI's, and Lint & Repo Gates has no conclusion yet on this head. Pending, not green.

The one open_questions entry (keep no (narrowing) or correct to yes (narrowing)): A, correct it, see ②.

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: claude/issue-22837-lowered-param-rebind
Reviewed-by: session_01B5CHJNXuuqzChM4w6hkTN4

VERDICT: FAIL — one blocking item: the Clause-② line in the PR body and the changeset reads no (narrowing) and must read yes (narrowing), because the cited hook-bodies.mdx:169 does not cover the method-shorthand shape the span fix now lowers. Every derived judgment in ①, the semver level and the ADR-0087 disposition hold; the corrected head needs that line and nothing else from this record, with the ① item 4 residual as a recommendation.


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2970d13e0714ea871960257d0d5ef2ce97ea5c63
Local-runs: none

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 6364375b51, the earlier record 6110348222 on a27cba278 and the docs-drift comment), the head commit (a merge of origin/main 6364375b51 into the branch, parents 564a7d250 and 6364375b51), the compare a27cba278...2970d13e0 (11 commits: nine from main, the one-line changeset fix, the merge), and the check-runs on the head. For context only, read on 6364375b51: the merged lower-callables.ts, engine.ts's hook-context literals, hook.zod.ts, hooks.mdx, hook.mdx, the examples, #22849's changeset and lower-callables.test.ts, and scripts/pm/clause2-line.mjs; plus what main moved between the two merge-bases (d48931871 to 6364375b51) in the files the rebind and the pin depend on. Nothing was built, run, tested or ablated; the head's file content was read with git show, which is the diff applied to its base, and the record template was printed as ordered.

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 (check:adr-0087-registration, check:cross-package-test-inputs, check:changeset-no-major, check:empty-changeset), was still in_progress at the first reading and completed success at 15:51:05 UTC; at the second reading no run on the head was without a conclusion. The nine runs that had no conclusion on the earlier head (Test Core 1 to 6, Temporal Conformance, Type Check workspace, Lint & Repo Gates) are all green on this one.

① Derived judgments

Section ① of record 6110348222 (nine items) was judged on a27cba278. On this head the branch's own 14 files are the same blobs except .changeset/22837-lowered-hook-parameter-rebind.md (one line) and packages/cli/src/utils/lower-callables.ts (the merge resolution); every other movement between the two heads is main's. So the question is whether main or the merge moved any ground those items stood on.

  1. The merge resolution in lower-callables.ts: carries both PRs' meanings, nothing else. The net diff against 6364375b51 is +13 -6: the import (extractLoweredBody, LoweringFace), tryExtractBody's signature (fn, originLabel, ref, face) with main's comment and this PR's face comment both kept, the extractLoweredBody(fn, originLabel, face) call, the hook call site passing 'hook', lowerActionCallable's tryExtract type carrying face with main's noteUnlowered parameter after it, and the action call site passing 'action'. Every line naming noteUnlowered, bodylessCallables, BodylessCallable, loweredRefs, unlowered, FUNCTIONS_ENTRY_NO_BODY_REASON or AUTHORED_BODY_BESIDE_FUNCTION_REASON hashes identically on main and on the head. No semantic interaction: face is consumed only by extractLoweredBody, and the counting path never sees it. A hook-face refusal, whether a residue refusal, one of the three member refusals, the new hook-face unparseable, or a non-HookBodyExtractionError throw filed as kind: 'unknown', lands in the one catch, pushes one bodyExtractionWarnings entry and calls noteUnlowered(ref, warning) once; noteUnlowered is first-writer-wins per ref, tryExtractBody's success path adds the ref to loweredRefs, and bodylessCallables is the unlowered map minus loweredRefs. So each refused callable is exactly one row, in walk order, and in the ADR-0130 D4 double walk the second refusal is a no-op on the map (the second warning is fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849's documented warnings behaviour, not a row). That is the population fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849's lower-callables.test.ts pins ("one refused handler, one row, the refusal itself", "four refused handlers, four rows", the D4 case), and this PR's own test "records the refusal and bundles the handler, unchanged, through lowerCallables" exercises the merged function. One theoretical shape is new since the faces split: a single function object bound both as a hook handler and as an action target is now tried on two faces, and if the hook face refuses it while the action face lowers it, fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849's "lowered at any slot is not a row" rule keeps it out of --strict-body's list. The two in-process calling conventions already differ, so no author writes that handler; noted, not a finding.

  2. The rebind, the residue, the three member refusals, the parameter-list destructuring, the span check, the face split and the public surface (items 1, 2, 3, 4, 6 and 8 of the earlier record): unchanged and still right. extract-hook-body.ts, detect-free-identifiers.ts, lint/hook-body-lowering.ts, hook-body.ts and the two new cli tests are the same blobs as on a27cba278, and main touched none of them, nor quickjs-runner.ts, body-runner.ts or hook-wrappers.ts, between the merge-bases. Re-read on this head: the prologue is var plus the parameter's text verbatim; NO_REBIND for a parameter named ctx or none, giving block.source unchanged; the action face skips the three face: 'hook' patterns and gets no prologue; each refusal names the parameter under forbidden-token; parseFunction returns only the node that starts at the wrap prefix and ends at the source's end. The published extractHookBody keeps its signature and is the hook face.

  3. The enumeration pin's two sources, on the merged base. main edited packages/spec/src/data/hook.zod.ts (+21 -3) between the merge-bases, one of the pin's two declared reads. The edit is HookSchema.onError's docblock and describe text (objectql: an afterInsert hook with onError: 'abort' (the default) that throws rejects the write, but the row stays stored — HookSchema.onError says abort rolls the transaction back, and a plain write opens none #22782) and touches nothing in HookContextSchema, so inProcessFace() derives the same members and the "classifies today" snapshot (dispatch.scope, id, provenance, ql, submitted, transaction; one exception, five refusals) still holds; body-runner.ts did not move. The declared reads are present on this head, single files, in scripts/cross-package-test-inputs.mjs (@objectstack/cli globs) and turbo.json (@objectstack/cli#test inputs), and main touched neither file. This merge is the case the declaration exists for: a spec-only move re-hashes the cli test task, and Test Core is green on this head with that move inside it.

  4. The exception id's premise, on the merged base. main's engine.ts change (+72 -7, fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's after-hook rollback unit) adds hunks at 4677, 13990, 15165, 18144 and 19451; none is a hook-context literal. The five assembly sites (now at 12973, 13267, 14325, 15647 and 18267) each still set provenance, transaction and ql, and none sets a top-level id (the id in the update site is inside input). The exception's reason text cites 1eff3224d7; the fact is the same on 6364375b51.

  5. The door and the corpus. lowered-body-door.dogfood.test.ts is unchanged from a27cba278; the Dogfood Regression Gate shards are green on this head, which is CI's reading of lbd_param and lbd_destructure storing the same row on both boots and lbd_two riding with lbd_stash, never deleted. main's example edits are two flow files (app-crm convert-lead.flow.ts, app-showcase flows/index.ts), not hooks. The only hook handlers under examples/*/src still name their parameter ctx (app-crm opportunity.hook.ts:14, app-todo task.hook.ts:54); the ctx.ql reads elsewhere in the examples are plugin and seed code the lowering never sees. So app-todo's task_logic still bundles for ctx.ql and the other three examples are still byte-identical, as the PR body states.

  6. hook-bodies.mdx. Not touched by main between the merge-bases; the diff's three edits (the new members bullet, "these refuse the body, not the handler" with the three doors, the parameter paragraph) read true against the code on this head. main's edits to hooks.mdx and hook.mdx (objectql: an afterInsert hook with onError: 'abort' (the default) that throws rejects the write, but the row stays stored — HookSchema.onError says abort rolls the transaction back, and a plain write opens none #22782's onError rows) say nothing about the lowering, the parameter or the three members, so nothing there is falsified.

  7. The earlier record's non-blocking recommendation (an exact check of a destructuring parameter's top-level keys against the absent set, so a member destructured beside a nested pattern, or one level into itself, is refused as its plain-name spelling is) was deliberately not taken this round, as the patch order said. The residual is as item 4 of the earlier record describes: bounded, the same limit the body spelling has on main, and the changeset's sentence remains literally true. Not blocking; it stays with the seat for the family's route.

② Semver level

  • The one blocking item is met. The PR body's third line reads Clause-②: yes (narrowing); .changeset/22837-lowered-hook-parameter-rebind.md line 7, the file's only Clause-② line, reads Clause-②: yes (narrowing) (commit 564a7d250, +1 -1, the only change to the file since the earlier head); the claim comment 6109397230 on the card reads Clause-②: yes (narrowing), with the correction recorded and the seat answer 6110191438 marked superseded. All three agree, and the arm is the one scripts/pm/clause2-line.mjs defines for a diff that widens one surface and narrows another: the span fix lowers the method-shorthand-with-one-nested-function shape at --strict-body and os lint where the base refused it (the changeset's fifth BREAKING bullet states "It now lowers"), while the residue, the three members and the parameter-list destructuring narrow.
  • Level and disposition unchanged and consistent. "@objectstack/cli": minor, the bang in the title, and the adr-0087 marker not-required (no-migration-prescription) are the same bytes as on the earlier head. Both arms take at least minor; the arm word is still narrowing, so check:adr-0087-registration reads [BREAKING+bang+clause-②-narrowing] as before, and Check Changeset is green on this head with the corrected body. The marker's reasoning holds: a build-time verdict on handler code, nothing authorable, exported or stored moves, and a refused handler keeps running bundled.
  • The changeset's sentences against the merged diff: each BREAKING bullet still true, including the sixth (an (input, ctx) target through the published extractHookBody is refused for its second parameter while objectstack build lowers it on the action face; pinned in both directions). "Unchanged" holds: the published signature and refusal kinds are the same, and no packages/spec or packages/runtime file is edited. main carries fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only #22849's own changeset (no (narrowing), minor, bang) beside this one; the two publish different things (--strict-body's list versus what the extractor lowers) and do not overlap.

③ Boundary flags

Round 1 (report 6110732640) raises no open_questions and three deviations, each answered:

  1. The earlier record named changeset line 13 while the Clause-② line sits on line 7: a slip in that record's line number; the line changed is the right one and the only one. Fine.
  2. The cli unit tier run as two vitest shards rather than one invocation: a local-run detail; CI's Test Core, six shards, is the gate reading, and it is green.
  3. check:nul-bytes run beyond the round's list: fine.

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 (pnpm lint as a proven narrowing rather than a run) is now answered by CI: Lint & Repo Gates completed success on this head at 15:51:05 UTC, read before signing.

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); os lint's write-set extractor not following a rebound receiver (a pre-existing coverage gap). Round 1 adds none, as it says.

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: claude/issue-22837-lowered-param-rebind
Reviewed-by: session_01B5CHJNXuuqzChM4w6hkTN4

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 lower-callables.ts carries #22849's counting logic byte for byte beside this PR's face plumbing with no semantic interaction; and every derived judgment of the earlier record still holds on the merged base. Every check-run on the head had a conclusion at the final reading (15:51:59 UTC), all success or skipped.


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 size/xl tests tooling

Projects

None yet

2 participants