Skip to content

feat(verify): bootStack boots the compiled artifact, so a handle test runs each hook's lowered body in the sandbox - #22808

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22301-item7-lowered-body-door
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22301-item7-lowered-body-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #22301
Clause-②: yes (widening)

Item 7 of the card: there was no lowered-body door. The handle booted the source configuration, so the production body-only path, and its refusal envelope, were not what a handle test ran. Item 1's remaining divergence (the MCP and pinyin host defaults) is not in this PR, and the card stays open for it.

What changes

  • bootStack(config, { artifact }) (BootOptions.artifact, packages/verify/src/harness.ts) names the compiled objectstack.json. It is a path, resolved against hostRoot (so 'dist/objectstack.json' names the app's own build), or an http(s):// URL.
    • The artifact is read by the runtime's own artifact loader (loadArtifactBundle, @objectstack/runtime, unwrapEnvelope: true, as createStandaloneStack reads it). It is mounted as the app (new AppPlugin(bundle)) in the slot the configuration takes on a source boot.
    • Every door on the stack (hooks.run, actions.run, api, …) then runs what the build shipped.
  • The rest of the stack is still composed from config, by the one composition rule: its requires, its own plugins, its declared default profile, its datasources. objectstack serve CONFIG composes the same way for a configuration that mounts no plugin instance: the configuration module composes, and the compiled artifact is the app.
  • Refused before anything boots, each with an ADR-0112 code and status. No error code is added.
    • An artifact that cannot be loaded: RESOURCE_NOT_FOUND / 404. The boot never falls back to the source.
    • An artifact that names another app than config: RESOURCE_CONFLICT / 409.
    • A configuration carrying onEnable, which no artifact carries: INVALID_REQUEST / 400. See Acceptance notes.
    • An option that names no artifact: INVALID_REQUEST / 400.
  • Door rosters: handle.ts's header names the door, as a boot option rather than a method; bootStack's docblock names the two apps it can mount. packages/verify/README.md adds a section and the option to the options list.
  • .changeset/22301-verify-lowered-body-door.md: @objectstack/verify minor, Clause-②: yes (widening). No other published package moved. The exported types are unchanged (one optional BootOptions member), so the dogfood fake VerifyStack (rls-runner.test.ts) does not move.

The three mechanism assumptions, measured

Each was measured on this branch over origin/main a8f24b092c.

  1. The package graph. @objectstack/cli depends on @objectstack/verify (packages/cli/package.json), and verify does not depend on cli. So verify cannot import lowerCallables without a cycle.
    • (a) taken. The handle accepts an already-lowered artifact. The caller produces it with the real build: an app runs objectstack build, which its own toolchain already carries. The handle reads it with the runtime's loader. No new package edge: verify already depends on @objectstack/runtime, which exports loadArtifactBundle and isHttpUrl.
    • (b) not needed. Moving the lowering (lower-callables.ts → extract-hook-body.ts → detect-free-identifiers.ts, which imports ts-morph) into a package both depend on would let the handle lower a configuration in memory. That is a second route to an artifact the build did not write: no docs collection, no runtime module, no --strict-body. That is the hand-assembled artifact ruling 6070767186 (A) rules out. So no four-axis package-graph decision is owed.
    • (c) a later convenience. os verify / os test could build the artifact and pass it here. That needs no new edge, since cli already depends on verify. It is not built, because nothing measured pulls it.
  2. What runs a lowered body at boot. AppPlugin → bindAppArtifactHandlers (packages/runtime/src/app-artifact-handlers.ts) binds hooks with bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), …). bindHooksToEngine's resolveHandler (packages/objectql/src/hook-binder.ts) takes a hook's body ahead of its handler. bootStack already went through that path for whatever bundle it mounted. So the door is "mount the artifact as the app", plus documentation and pins. No runtime, objectql or cli file is touched.
  3. The refusal envelope, measured on both boots of one app (pinned below):
    • A body that crashes. A handler writing to ctx.dispatch.scope, the cross-phase scratch HookContextSchema.dispatch names, lowers cleanly. The sandbox hands a body ctx.dispatch as { mode, index } without scope, so the body throws TypeError: cannot set property 'stashed' of undefined.
      • The handle rejects with SandboxError (hook 'lbd_stash' threw: TypeError: …, no code), and no row is stored.
      • REST POST /api/v1/data/OBJECT answers 500 { code: 'INTERNAL_ERROR' }, where the source boot answers 2xx.
    • A declared refusal. A handler that throws { code: 'VALIDATION_FAILED', status: 400 }:
      • in-process, the handle rejects with the handler's own Error;
      • lowered, it rejects with a SandboxError carrying the same code and status out of the VM;
      • REST answers both paths with the same 400 body, byte-equal (pinned with toEqual).
    • The lowering-time refusal (a forbidden token such as .sudo(, a free identifier) is not reachable from a local artifact. The build bundles that callable into objectstack-runtime.*.mjs, which loadArtifactBundle merges for a local file, so it runs in-process as under objectstack start. objectstack build --strict-body refuses it at build time. The README and the changeset say so; it is not pinned.

Pins

  • packages/verify/src/harness.artifact-door.test.ts (6 cases):
    • The door: the artifact's body runs in place of the configuration's in-process handler, through a relative path under hostRoot.
    • The control: a source boot runs the handler.
    • The four refusals, each asserted on code + status.
    • The artifact's body is written by the test in the authored form, which objectstack build ships unchanged, because verify cannot reach the lowering.
  • packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts (8 cases). Here the artifact is written by buildShapedArtifact, which runs the build's real pipeline (normalizeStackInput → lowerCallables → ObjectStackDefinitionSchema, reaching cli's source by relative path, the route that file already documents).
    • An anti-vacuity case asserts every hook reached the artifact as a body the real lowering extracted.
    • The divergence: in-process passes; lowered refuses (handle and REST).
    • The envelope: in-process error vs SandboxError with the declared code and status, and REST wire parity.
    • The control: a lowerable derivation stores the same value on both boots.

Ablations

Predictions were written before any run, at 4f8ab6b8dd. Each mutation went through scripts/ablation-replace.mjs (anchor must hit, blob must change, restore proven by blob == HEAD and an empty git diff HEAD).

  • A1, the door: mount config instead of the artifact.
    • verify: predicted 1 failed / 5 passed; observed the same (the door case).
    • dogfood (verify dist-resolved):
      • Mutate leg: verify rebuilt, then ablation-dist-preflight.mjs @objectstack/verify ABLATION_A1_MOUNTS_CONFIG found the marker in dist/. The rebuild exited 1, with its output not captured. The JS bundles carried the marker per the preflight, and they are what the suite reads.
      • Predicted 3 failed / 5 passed; observed the same (lowered TypeError, REST 500, lowered SandboxError envelope).
      • Restore leg: rebuild exit 0, --absent preflight exit 0, tree clean.
  • A2, an unloadable artifact falls back to the source: predicted and observed 1 failed / 5 passed. The first attempt was a no-op: its replacement contained its anchor, and ablation-replace refused it ("anchor count moved 1 -> 1") and restored. It was re-run on a different anchor.
  • A3, the other-app check deleted: predicted and observed 1 failed / 5 passed.
  • A4, the onEnable check deleted: predicted and observed 1 failed / 5 passed.
  • A5, a relative path resolved against process.cwd(): predicted and observed 2 failed / 4 passed.
  • A6 (the runtime marshals dispatch.scope): NOT RUN.
    • The prediction assumed dogfood resolves @objectstack/runtime from source. It does not: it is dist-resolved, and the source paths in the stack traces were source maps. So A6 needs two runtime rebuilds.
    • A1 already proves the divergence pins discriminate, and the TypeError text names the cause.

Tests and gates

The head is c14e269ed0, which merges origin/main 5fc57b382e.

  • At c14e269ed0:
    • harness.artifact-door.test.ts: 6/6.
    • lowered-body-door.dogfood.test.ts: 8/8.
    • pnpm --filter @objectstack/spec build && … check:generated: all 14 generated artifacts up to date.
  • At 197daa3248 (the second merge brought only spec contracts, docs and governance text):
    • the whole @objectstack/verify suite: 30 files / 240 tests;
    • pnpm --filter @objectstack/verify typecheck: tsc, plus the test layer under tsconfig.test.json;
    • dogfood: lowered-body-door, rls-runner and showcase-declarative-endpoints (the other buildShapedArtifact user): 3 files / 42 tests;
    • pnpm --filter @objectstack/dogfood typecheck: 0 errors, closure built;
    • cli --project unit on the two contracts that read harness.ts (serve-verify-security-parity, serve-audit-registration): 2 files / 20 tests. The cli integration tier is declared to CI; no cli file moved.
  • Gate union at c14e269ed0: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 commands. All 68 exited 0. --ran with exit codes: "68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero …)". Among them:
    • check:dual-build-cjs-loads: 107 require entry points across 66 packages load;
    • check:dts-closure, check:cross-package-test-inputs, check:test-source-alias, check:nul-bytes, check:published-files, check:type-check-coverage / -debt, check:doc-authoring and check:issue-citations (6 citations resolve).
  • Lint, narrowed: eslint --no-inline-config --format json on the 4 changed TS files: 4 files, 0 errors, 0 warnings. Each file is in the config's population (--print-config resolves its rules). The config enables no type-aware linting (no parserOptions.project, per eslint.config.mjs), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

Deviation from the claim's file surface

The divergence, envelope and control pins live in packages/qa/dogfood, not packages/verify. The real lowering is reachable there without a manifest edge (build-shaped-artifact.ts already reaches cli's source). From verify it would be a reverse test-time edge onto the package that depends on it. The verify-local file pins the door and its refusals.

Acceptance notes

  • onEnable is refused, not grafted. objectstack serve CONFIG grafts the configuration module's onEnable onto the artifact's bundle (graftAuthoredRuntimeMembers, packages/cli/src/utils/graft-runtime-hooks.ts). An artifact-only deployment has none. This package cannot import the cli rule, and re-deriving it here would be a second copy. If an app's tests need onEnable under the artifact door, the route is to move that rule into a package both can import. Carrier: none.
  • BootOptions.artifact does not check that the artifact is current against config; a stale build boots stale bodies (stated in the JSDoc, README and changeset).
  • verify mounts the artifact through AppPlugin's own security-metadata registrar, as it does for a source boot. createStandaloneStack composes a MetadataPlugin artifact door as the registrar instead. That is item 1's composition axis, unchanged here.

hotcrm

test/helpers/verify-stack.ts's runShippedHook can move onto bootStack(config, { artifact: 'dist/objectstack.json' }) after objectstack build, once hotcrm takes a release carrying this. The release is the Version Packages PR, a human act.


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/api/error-catalog.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact), RESOURCE_CONFLICT (literal, a string literal in loadCompiledArtifact), RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/api/error-handling-client.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in loadCompiledArtifact))
  • content/docs/api/metadata-api.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact))
  • content/docs/automation/webhooks.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact), RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/data-modeling/import-mappings.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/permissions/authentication.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact))
  • content/docs/permissions/sso.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact), RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/protocol/kernel/error-handling.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/ui/forms.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact))

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

  • content/docs/releases/v15.mdx (via BootOptions (symbol, a top-level interface))
  • content/docs/releases/v17/17-0.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact), RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/releases/v17/17-5.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in loadCompiledArtifact))
  • content/docs/releases/v17/17-6.mdx (via INVALID_REQUEST (literal, a string literal in loadCompiledArtifact), RESOURCE_CONFLICT (literal, a string literal in loadCompiledArtifact))
  • content/docs/releases/v17/17-7.mdx (via RESOURCE_CONFLICT (literal, a string literal in loadCompiledArtifact))

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
  • 2 changed file(s) yielded no anchor (packages/verify/README.md, packages/verify/src/handle.ts) — pages documenting those are invisible to this run
  • 1 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 — 3 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 098481744fd7dd9d0eade7cb61701891c5a890cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from de8cd6a2bc16b32f8b176f8b59de87b7b76f8777 — the merge of head c14e269ed055e8e8ef350e701040cafd14f9cee3 into base 098481744fd7dd9d0eade7cb61701891c5a890cb, 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 de8cd6a2bc16b32f8b176f8b59de87b7b76f8777 && git checkout de8cd6a2bc16b32f8b176f8b59de87b7b76f8777
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 098481744fd7dd9d0eade7cb61701891c5a890cb c14e269ed055e8e8ef350e701040cafd14f9cee3 && git checkout -B drift-repro 098481744fd7dd9d0eade7cb61701891c5a890cb && git merge --no-ff c14e269ed055e8e8ef350e701040cafd14f9cee3

node scripts/docs-audit/affected-docs.mjs --json 098481744fd7dd9d0eade7cb61701891c5a890cb

⚠️ 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 098481744fd7dd9d0eade7cb61701891c5a890cb → 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: c14e269ed055e8e8ef350e701040cafd14f9cee3
Local-runs: none

Item 7 of the card, first round. Head resolved from origin/claude/issue-22301-item7-lowered-body-door: four non-merge commits (9dfa059b1f, 61603ff961, 4f8ab6b8dd, 197daa3248, each touching PR paths only) and two merges of origin/main (8c6fdb96d9, c14e269ed0), neither of which changed a PR path; the merge base with origin/main is 5fc57b382e, the net diff is 6 files, +565 / −9. Inputs: the card (body; ruling 6070767186; landing 6106511170; claim 6106527562; report 6107272381), PR #22781 (a8f24b092c) and its PASS record 6106360215 as the precedent, the PR (body, file list, diff), the code at this head the door rests on (load-artifact-bundle.ts, standalone-stack.ts, app-plugin.ts, app-artifact-handlers.ts, objectql's hook-binder.ts resolveHandler, sandbox/body-runner.ts buildSandboxContext, sandbox/quickjs-runner.ts SandboxError, cli's lower-callables.ts, extract-hook-body.ts, graft-runtime-hooks.ts, plugin-detection.ts, merge-boot-config.ts, serve.ts, dogfood's build-shaped-artifact.ts, the three package manifests, the two cross-package gates, turbo.json, the ADR-0112 vocabulary), and the check-runs on this head. Nothing built, run or re-run. The claim's file surface is met except where the report declares the deviation (③); no cli, runtime, objectql or spec file moves.

① Derived judgments

  1. Accept set, new member: BootOptions.artifact, an optional string — a path resolved against hostRoot, or an http(s) URL; the handle's doors are unchanged, so the only accept-set change is on the boot call — RIGHT, pinned (the verify door case and its control; A1, A5).
  2. Ruling A, the path: the artifact is read by loadArtifactBundle(location, { tag, unwrapEnvelope: true }), the same function and option set createStandaloneStack passes (standalone-stack.ts 829–832, its tag aside), and mounted as new AppPlugin(bundle), the class createStandaloneStack mounts (944–957). AppPlugin → bindAppArtifactHandlers binds hooks with hookBodyRunnerFactory(new QuickJSScriptRunner()) (app-artifact-handlers.ts 270), and resolveHandler takes body ahead of handler (hook-binder.ts 369–393). loadCompiledArtifact is a type check, the onEnable check, the loader call, a null check and a manifest-id compare — no line lowers, parses a body or decides what the sandbox does. Not a second boot path; zero re-derived semantics — RIGHT.
  3. "Composition from config, app from artifact" against serve CONFIG: serve takes the artifact path only when shouldBootWithLibrary(config) holds, never for a host configuration (plugin-detection.ts 11–16, 47–56); there createStandaloneStack mounts the artifact's AppPlugin, requires is read off the module (originalConfig, serve.ts 2601–2604) and onEnable / functions are grafted from it. The door matches that for a non-host configuration, the scope the PR body states. Three divergences, none introduced by this diff and none that hides a body's behaviour from a test: (a) a host configuration — serve CONFIG boots the source module and never reads the artifact, while the door mounts the artifact beside the configuration's plugin instances (the start --artifact app slot plus item 1's plugin rule); the JSDoc and the changeset say "composes the same way" without the non-host qualifier the PR body carries — wording gap, not blocking. (b) serve's mergeBootConfig serves the boot result's plugins whole (merge-boot-config.ts 36–42), so a non-host configuration's non-instance plugins entries do not reach its artifact boot, while the door mounts them — item 1's landed rule, a serve-side reading, unmeasured here; noted for the seat (③). (c) createStandaloneStack registers the artifact with securityMetadataRegistrar: 'artifact-door' beside a MetadataPlugin over the same file; the door uses AppPlugin's own registrar as every verify boot does — stated in the PR's Acceptance notes as item 1's axis; RIGHT to leave.
  4. Package graph: packages/verify/package.json untouched; the harness's two new imports (isHttpUrl, loadArtifactBundle) are @objectstack/runtime index exports (line 215), a dependency verify already declares; the verify test imports @objectstack/spec and ./harness.js; nothing under packages/verify names a cli module in import position (cli appears in prose only). No edge verify → cli, src or test — RIGHT. Route (a) over (b): an in-memory lowering in the handle would be the hand-assembled artifact ruling A refuses, and lowerCallables → extract-hook-body.ts → detect-free-identifiers.ts pulls ts-morph; no four-axis decision owed — RIGHT.
  5. The dogfood route: build-shaped-artifact.ts reaches ../../../cli/src/utils/lower-callables.js as source by relative path — pre-existing (commit c39a911; the one in-repo case [finding] @objectstack/cli and @objectstack/plugin-hono-server are the only two published packages with no exports map — every dist/** module is deep-importable, and one already is #12879 decided explicitly, its header says why), carried by a real manifest edge (@objectstack/dogfood devDepends on @objectstack/cli, so turbo ls --affected and the ^build hash both move with cli) and by dogfood's tsconfig.json rootDir at the repo root. check:cross-package-test-inputs rosters *.test.* files only (line 774), so the helper's escape is outside dogfood's declared globs — a pre-existing, graph-covered blind spot, not a new manifest-less edge. check:test-source-alias: the new test's bare imports (@objectstack/verify, @objectstack/spec) are both already in dogfood's unaliased ledger, so the set holds. RIGHT; nothing a gate should have caught.
  6. The four refusals (bootRefusal: an Error carrying code + status, the shape instanceRuleRefusal already uses at the boot level; no statusCode mirror, unlike the handle's callShapeRefusal — a pre-existing boot-vs-handle split, note only): unloadable → RESOURCE_NOT_FOUND / 404 after the loader's own null (ENOENT, malformed JSON and an HTTP error all collapse there; the message points at the loader's line), never the source (A2) — RIGHT; another app's → RESOURCE_CONFLICT / 409 on manifest.id, else id, else name (A3) — RIGHT, one soft edge: a configuration naming no id skips the compare; onEnable → INVALID_REQUEST / 400 (A4) — RIGHT, see 7; empty option → INVALID_REQUEST / 400 — RIGHT. All four codes are in the ADR-0112 vocabulary (errors.zod.ts 88, 95, with 404 / 409 their canonical statuses at 187 / 189; INVALID_REQUEST in the ledger), no code added. Each refusal is thrown before the posture is requested or a kernel exists (harness.ts 893–905), and bootStack's catch releases the claim — RIGHT.
  7. Refusing onEnable instead of grafting: serve CONFIG grafts the module's onEnable and functions (STACK_RUNTIME_MEMBERS) by a cli rule verify cannot import, and an artifact-only deployment runs neither; a copy of the rule here would be a second copy (verify: an in-process handle on the booted stack — run a hook, flow, action or validation rule against the REAL engine and assert, so an app never fakes ctx.api again (epic hotcrm#1579, step 5a) #15951 B′). The refusal is loud, carries the remedy, and is documented in JSDoc, README and changeset — RIGHT. functions is not refused and needs no graft for a build-written artifact: lowerCallables always keeps the functions key, and a local artifact's runtime module is merged by the loader — RIGHT.
  8. Relative-path resolution: isHttpUrl(artifact) ? artifact : resolve(hostRoot, artifact) with hostRoot = opts.hostRoot ?? process.cwd(), the existing hostRoot default — RIGHT, pinned (A5: 2 failed).
  9. The envelope pins, real divergences against the real lowering and the real sandbox: the dogfood artifact is normalizeStackInput → lowerCallables → ObjectStackDefinitionSchema (the build's own functions), every hook carries a body.source the lowering extracted (the anti-vacuity case; built.refs equal the three names), and the handle and REST drive @objectstack/runtime's AppPlugin → QuickJS. ctx.dispatch.scope lowers at exit 0: no FORBIDDEN_PATTERNS entry names dispatch, and ctx is a bound parameter so detectFreeIdentifiers reports none; the sandbox copies only { mode, index } (body-runner.ts 1099–1105: "scope is deliberately not copied"), so the body TypeErrors: SandboxError without code, REST 500 INTERNAL_ERROR, no row (rows → empty), while the source boot stores the row. A declared { code: 'VALIDATION_FAILED', status: 400 } throw comes out of the VM as a SandboxError carrying the same code and status (SANDBOX_ERROR_PASSTHROUGH, readErrorInfo), and /data answers 400 on both boots with the same body — toEqual on parsed JSON, so structurally equal rather than byte-equal: wording. The control (lbd_derive) stores the same value on both boots. All eight ran green in Dogfood Regression Gate on this head. RIGHT.
  10. Stale-artifact hazard: stated where a test author reads it — the BootOptions.artifact JSDoc ("A stale artifact boots stale bodies: build before the run"), the README section and the changeset — RIGHT. Beyond the id there is no freshness check, and there cannot be a sound one without the lowering (ruling A) or a source path the handle is not given; a manifest.version compare would be cheap and weak. Stating it is the right disposition; nothing owed.
  11. Published surface: BootOptions was already exported; one optional member added, no new exported type, VerifyStack unchanged, so rls-runner.test.ts's fake owes nothing — RIGHT (the item-6 reverse check does not apply; no member on the stack type). JSDoc claims against source: loader and options TRUE (2); body before handler TRUE (2); local runtime module merged, remote never TRUE (load-artifact-bundle.ts 130–149); --strict-body TRUE (extract-hook-body.ts header); the refusals TRUE (6); "serve composes the same way" over-stated for a host configuration (3a). README: the new section and the options list are accurate; its example boots one myApp object, and a suite that boots the source AND the artifact of one configuration meets the instance rule (one configuration, one live kernel) and must build the configuration twice, as the dogfood pin does — not said in the new section; wording gap, not blocking.
  12. Ablations: A1 verify 1/5, A1 dogfood 3/5 (exactly the three lowered-only pins), A2 1/5 (second attempt, after the tool refused a no-op), A3 1/5, A4 1/5, A5 2/4 — each as predicted, predictions committed at 4f8ab6b8dd. The A1 dogfood mutate-leg rebuild exited 1 with its output lost; the ablation-dist-preflight marker proved the JS bundles the suite reads carried the mutation, the failure set was exactly the lowered-only three, and the restore leg rebuilt at exit 0 with the marker absent — sufficient. A6 NOT MEASURED: it would characterise the finding (marshal scope and the TypeError goes away), not the door; the cause is read from buildSandboxContext and named by the TypeError text, and A1 shows the pins discriminate on the mounted app — sufficient for the pins' discrimination.
  13. Door rosters: handle.ts's header names the door as a boot option, not a method; bootStack's docblock gains the app-slot section; the README options list carries artifact — RIGHT.
  14. PR-body claims against source: Part of #22301, Clause-②: yes (widening) on line 2, no closing keyword TRUE; "no runtime, objectql or cli file touched" TRUE (file list); "cli depends on verify, verify does not depend on cli" TRUE (manifests); "the lowering-time refusal is not reachable from a local artifact" TRUE (mergeRuntimeModule merges the bundled callable; a remote artifact drops runtimeModule); "the exported types are unchanged" TRUE; head repo equals base repo, not a fork.

② Semver level

  • Packages this diff publishes in: @objectstack/verify only (src/handle.ts, src/harness.ts, README.md, one test). @objectstack/dogfood is private: true; no other package's src moves.
  • .changeset/22301-verify-lowered-body-door.md: '@objectstack/verify': minor, Clause-②: yes (widening) on its own line, and the PR body's second line carries the same — RIGHT: one new optional option on the published boot door and four new refusals of it, nothing removed, renamed or narrowed, no migration owed; yes takes at least minor and no major is introduced. Check Changeset on this head is green.

③ Boundary flags

  • Dev deviations (6107272381), four, each answered. (1) The divergence, envelope and control pins live in packages/qa/dogfood, outside the claim's file surface: the real lowering is reachable there by the sanctioned route (① 5) and from verify only by a reverse test-time edge onto the package that depends on it; the verify-local file pins the door and the four refusals with an authored-form body — accepted. (2) Zero label writes — within the dispatch budget. (3) origin/main moved one commit (098481744f) after the final merge; CI ran on this head and the queue rebuilds — accepted. (4) A2's refused first attempt, A6 unmeasured, A1's lost build output — ① 12.
  • Out-of-scope (a) → the seat files a card. Classification RIGHT: class a, a product defect in @objectstack/cli's build; reach measured on the public door at this head (POST /api/v1/data/lbd_stash_note 500 on the artifact boot, 2xx on the source boot, pinned); producer extractHookBody's allow-list; same family as .sudo( (A hook cannot elevate, so a hook-written computed column cannot be protected by field-level editable: false — the guard and the writer are the same door #14010), .create( (finding: objectstack build lowers a hook body calling ctx.api.object('x').create() although the QuickJS sandbox installs no create leaf — the extractor ledger advertises the verb, the lint rules read it as a live write, and the body throws TypeError: not a function on its first run #16249) and Intl (objectstack build lowers an inline hook handler that references Intl into the QuickJS body — detect-free-identifiers allowlists host globals the sandbox does not provide, so the handler passes validate/typecheck/test/build and throws ReferenceError in production #14301), a member real in-process and absent from the body. The spec already declares the body face without dispatch.scope and submitted (hook.zod.ts 852–856: the body surface is assembled key by key, and a body needing it is the signal to raise the question), so the remedy is the build's (refuse as forbidden-token, fall back to bundling, hard under --strict-body; an os lint verdict), not the sandbox's. Two things the card should carry: the sibling ctx.submitted is unmeasured (a member read on it TypeErrors, the prescribed optional read yields undefined); and the dogfood pin lbd_stash IS this instance, so the fix that refuses it un-lowers the pin's handler and trips the anti-vacuity case — the card's dev moves the divergence pin onto another handler in the same PR.
  • Out-of-scope (b), the onEnable graft, carrier none. Disposition RIGHT: no measured reach (no app's artifact-door test needs onEnable yet), the refusal is loud with the remedy, and the route (move graftAuthoredRuntimeMembers into a package both can import) is named. Escalated to the seat: a PR body does not survive the card's close as a record, so carry (b), and ① 3(b) (serve's mergeBootConfig serving the boot result's plugins whole on a non-host artifact boot — read from source, unmeasured), into the card's close-out comment, or file them when measured.
  • open_questions: none. Wording notes for a later text round, none blocking: ① 3(a), ① 6 (statusCode), ① 9 ("byte-equal"), ① 11 (the instance rule in the README section).
  • Check-runs on this head, all concluded (last completion 2026-10-11T09:02:00Z, read at 09:03Z): 35 runs, 32 success, 3 skipped, no other conclusion. The required contexts are green: Lint & Repo Gates, TypeScript Type Check (and its four Type Check · legs), Test Core (and 6/6 shards; sharded by package over the affected set, so @objectstack/verify's suite with harness.artifact-door.test.ts ran in one of them), Dogfood Regression Gate (and 3/3, which run lowered-body-door.dogfood.test.ts), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset, Check PR Size, Dogfood Verify CLI, Spec property liveness, Part-of PR must not also close its card, the two single-claim guards, The card this PR closes must claim this branch, Flag docs affected by code changes, Check Documentation Links, Auto Label, filter. Skipped by their own filters: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). No red to root-cause; no run was cancelled by a newer push (the head is the branch tip).

Implemented-by: claude/issue-22301-item7-lowered-body-door
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 09:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 09:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 27f0d83 Oct 11, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22301-item7-lowered-body-door branch October 11, 2026 09:39
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants