Skip to content

Commit 27f0d83

Browse files
feat(verify): bootStack boots the compiled artifact, so a handle test runs each hook's lowered body in the sandbox (#22808)
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](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60bcfe7 commit 27f0d83

6 files changed

Lines changed: 565 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/verify': minor
3+
---
4+
5+
feat(verify): `bootStack(config, { artifact })` boots the compiled artifact as the app, so a test runs each hook's and action's lowered `body` in the sandbox, as a deployment does
6+
7+
Clause-②: yes (widening)
8+
9+
`objectstack build` lowers each inline hook `handler` and action `target` it can into a metadata-only `body`, and a deployment runs that body in the runtime's QuickJS sandbox, against the sandbox's `ctx`. `bootStack` mounted the source configuration only, so a handle test ran the function in-process, and a handler that refuses once lowered passed every test.
10+
11+
- **`BootOptions.artifact`** names the compiled `objectstack.json`: 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`, with the options every artifact boot reads it with) and mounted as the app, in place of `config`. `AppPlugin` binds each hook's `body` ahead of its `handler`, through the same sandbox runner every artifact boot uses, so every door (`hooks.run`, `actions.run`, `api`, …) runs what the build shipped.
12+
- **A refused body answers as the body-only path answers.** The handle rejects with the sandbox's `SandboxError`, which carries a `code` and `status` the body declared on its throw. A body that crashes (a `TypeError`) carries none, and REST answers it `500` `INTERNAL_ERROR`. For example, a handler that writes to `ctx.dispatch.scope` passes in-process and refuses every insert once lowered, because the sandbox hands a body `ctx.dispatch` without `scope`.
13+
- **The rest of the stack is still composed from `config`**: its `requires`, its own `plugins`, its declared default profile, its datasources. `objectstack serve <config>` composes the same way. So `config` is the configuration the artifact was built from.
14+
- **A local artifact gets its `objectstack-runtime.*.mjs` merged**, so a callable the build could not lower runs in-process from it, as under `objectstack start`. `objectstack build --strict-body` refuses such a callable at build time, and its artifact is body-only.
15+
- **Refused before anything boots**, each with its `code` and `status`: an artifact that cannot be loaded (`RESOURCE_NOT_FOUND`, 404; the boot never falls back to the source), an artifact built from another app (`RESOURCE_CONFLICT`, 409), and a configuration carrying `onEnable` (`INVALID_REQUEST`, 400). No artifact carries `onEnable`; for a configuration that mounts no plugin instance of its own, `objectstack serve <config>` grafts it from the configuration module, a `@objectstack/cli` rule this package does not re-derive. No error code is added.
16+
- The handle lowers nothing: build the artifact first. A stale artifact boots stale bodies.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22301 item 7 — a hook handler that passes an in-process test and refuses
5+
* the write once the build lowers it, and the door that lets an app's test see
6+
* it: `bootStack(config, { artifact })` (`@objectstack/verify`), which mounts
7+
* the compiled artifact as the app.
8+
*
9+
* The artifact is written by `buildShapedArtifact`, which runs the build's own
10+
* pipeline (`normalizeStackInput` → `lowerCallables` →
11+
* `ObjectStackDefinitionSchema`, reaching the lowering in `@objectstack/cli`'s
12+
* source). So each hook reaches the artifact boot as the `body` the real
13+
* lowering extracted from its handler, never a body written here. Every
14+
* handler lowers (asserted below), so the real build emits no runtime module
15+
* for this app either: the artifact is body-only.
16+
*
17+
* Each hook is driven on both boots, the source configuration (the handler,
18+
* in-process) and its artifact (the body, in the runtime's QuickJS sandbox):
19+
*
20+
* - `lbd_stash` writes to `ctx.dispatch.scope`, the cross-phase scratch the
21+
* hook contract names (`HookContextSchema.dispatch`). The sandbox hands a
22+
* body `ctx.dispatch` as `{ mode, index }`, without `scope`, so the body
23+
* throws a TypeError and the write is refused. The handler passes. This is
24+
* the divergence.
25+
* - `lbd_refuse` refuses a short name with a declared `VALIDATION_FAILED` /
26+
* 400. Both boots refuse. In-process the caller gets the handler's own
27+
* error; on the body-only path it gets a `SandboxError` that carries the
28+
* declared `code` and `status` out of the VM. This is the refusal envelope.
29+
* - `lbd_derive` derives a field. Both boots store the same value. This is
30+
* the control.
31+
*
32+
* Measured before the door (`origin/main` a8f24b092c): `bootStack` always
33+
* mounted the source configuration, so the first hook passed every handle
34+
* test while the artifact it ships refuses every insert.
35+
*/
36+
37+
import { mkdtempSync, rmSync } from 'node:fs';
38+
import { tmpdir } from 'node:os';
39+
import { join } from 'node:path';
40+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
41+
import { bootStack, type VerifyStack } from '@objectstack/verify';
42+
import { defineStack } from '@objectstack/spec';
43+
import { writeBuildShapedArtifact, type BuildShapedArtifact } from './build-shaped-artifact.js';
44+
45+
type HookCtx = { input: Record<string, unknown>; dispatch: { scope: Record<string, unknown> } };
46+
47+
const text = (name: string, label: string) => ({ name, type: 'text', label });
48+
const object = (name: string) => ({
49+
name,
50+
label: name,
51+
pluralLabel: name,
52+
sharingModel: 'public_read_write',
53+
fields: { name: { ...text('name', 'Name'), required: true }, note: text('note', 'Note') },
54+
});
55+
56+
/** The configuration, BUILT AGAIN on each call: each boot, and the build, gets its own. */
57+
const buildApp = (): Record<string, unknown> =>
58+
defineStack({
59+
manifest: { id: 'com.example.lowered-body-door', name: 'Lowered Body Door', namespace: 'lbd', version: '1.0.0', type: 'app' },
60+
objects: [object('lbd_stash_note'), object('lbd_refuse_note'), object('lbd_derive_note')],
61+
hooks: [
62+
{
63+
name: 'lbd_stash',
64+
object: 'lbd_stash_note',
65+
events: ['beforeInsert'],
66+
handler: async (ctx: HookCtx) => {
67+
ctx.dispatch.scope.stashed = ctx.input.name;
68+
ctx.input.note = 'stashed';
69+
},
70+
},
71+
{
72+
name: 'lbd_refuse',
73+
object: 'lbd_refuse_note',
74+
events: ['beforeInsert'],
75+
handler: async (ctx: HookCtx) => {
76+
if (String(ctx.input.name ?? '').length < 3) {
77+
throw Object.assign(new Error('the name is too short'), { code: 'VALIDATION_FAILED', status: 400 });
78+
}
79+
},
80+
},
81+
{
82+
name: 'lbd_derive',
83+
object: 'lbd_derive_note',
84+
events: ['beforeInsert'],
85+
handler: async (ctx: HookCtx) => {
86+
ctx.input.note = 'derived:' + String(ctx.input.name);
87+
},
88+
},
89+
],
90+
} as never) as unknown as Record<string, unknown>;
91+
92+
let tempDir: string;
93+
let built: BuildShapedArtifact;
94+
let source: VerifyStack;
95+
let lowered: VerifyStack;
96+
let sourceToken: string;
97+
let loweredToken: string;
98+
99+
beforeAll(async () => {
100+
tempDir = mkdtempSync(join(tmpdir(), 'dogfood-lowered-body-door-'));
101+
const artifactPath = join(tempDir, 'objectstack.json');
102+
built = writeBuildShapedArtifact(buildApp(), artifactPath);
103+
source = await bootStack(buildApp());
104+
lowered = await bootStack(buildApp(), { artifact: artifactPath });
105+
sourceToken = await source.signIn();
106+
loweredToken = await lowered.signIn();
107+
}, 240_000);
108+
109+
afterAll(async () => {
110+
await source?.stop();
111+
await lowered?.stop();
112+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
113+
});
114+
115+
/** What a call rejected with, or a failure naming what it resolved with. */
116+
const rejection = async (call: Promise<unknown>): Promise<Record<string, unknown>> => {
117+
try {
118+
const value = await call;
119+
throw new Error(`expected a refusal, got ${JSON.stringify(value)}`);
120+
} catch (e) {
121+
return e as Record<string, unknown>;
122+
}
123+
};
124+
125+
const insert = (stack: VerifyStack, object: string, name: string) =>
126+
stack.hooks.run(object, 'insert', { name }, { system: true });
127+
128+
describe('the artifact under test is what the build lowers', () => {
129+
it('every hook reaches the artifact as a body the real lowering extracted from its handler', () => {
130+
const hooks = (built.artifact.hooks ?? []) as Array<Record<string, unknown>>;
131+
expect(hooks.map((h) => h.name).sort()).toEqual(['lbd_derive', 'lbd_refuse', 'lbd_stash']);
132+
for (const hook of hooks) {
133+
expect((hook.body as { source?: unknown } | undefined)?.source, `${String(hook.name)} lowered`).toEqual(
134+
expect.any(String),
135+
);
136+
}
137+
expect(built.refs).toEqual(['lbd_derive', 'lbd_refuse', 'lbd_stash']);
138+
});
139+
});
140+
141+
describe('the divergence: a handler that passes in-process and refuses once lowered', () => {
142+
it('in-process, the handler stashes on `ctx.dispatch.scope` and the insert is stored', async () => {
143+
const row = await insert(source, 'lbd_stash_note', 'alpha');
144+
expect(row.note).toBe('stashed');
145+
});
146+
147+
it('lowered, the body meets a `ctx.dispatch` without `scope`: a TypeError, and the insert is refused', async () => {
148+
const e = await rejection(insert(lowered, 'lbd_stash_note', 'alpha'));
149+
expect(e.name).toBe('SandboxError');
150+
expect(String(e.message)).toMatch(/^hook 'lbd_stash' threw: TypeError/);
151+
expect(e.code).toBeUndefined();
152+
expect(await lowered.rows('lbd_stash_note')).toEqual([]);
153+
});
154+
155+
it('over REST: created in-process, a 500 once lowered', async () => {
156+
const ok = await source.apiAs(sourceToken, 'POST', '/data/lbd_stash_note', { name: 'beta' });
157+
expect(ok.status).toBeLessThan(300);
158+
const refused = await lowered.apiAs(loweredToken, 'POST', '/data/lbd_stash_note', { name: 'beta' });
159+
expect(refused.status).toBe(500);
160+
expect(await refused.json()).toMatchObject({ code: 'INTERNAL_ERROR' });
161+
});
162+
});
163+
164+
describe('the refusal envelope: a declared refusal, as each path answers it', () => {
165+
it("in-process, the caller gets the handler's own error", async () => {
166+
const e = await rejection(insert(source, 'lbd_refuse_note', 'ab'));
167+
expect(e).toMatchObject({ name: 'Error', code: 'VALIDATION_FAILED', status: 400 });
168+
});
169+
170+
it('lowered, a SandboxError carries the declared code and status out of the VM', async () => {
171+
const e = await rejection(insert(lowered, 'lbd_refuse_note', 'ab'));
172+
expect(e).toMatchObject({ name: 'SandboxError', code: 'VALIDATION_FAILED', status: 400 });
173+
expect(await lowered.rows('lbd_refuse_note')).toEqual([]);
174+
});
175+
176+
it('over REST, both paths answer the declared envelope, the same body on the wire', async () => {
177+
const bodies: unknown[] = [];
178+
for (const [stack, token] of [[source, sourceToken], [lowered, loweredToken]] as const) {
179+
const res = await stack.apiAs(token, 'POST', '/data/lbd_refuse_note', { name: 'ab' });
180+
expect(res.status).toBe(400);
181+
const body = await res.json();
182+
expect(body).toMatchObject({ code: 'VALIDATION_FAILED' });
183+
bodies.push(body);
184+
}
185+
expect(bodies[1]).toEqual(bodies[0]);
186+
});
187+
});
188+
189+
describe('control: a lowerable hook behaves identically both ways', () => {
190+
it('both boots store the derived value', async () => {
191+
expect((await insert(source, 'lbd_derive_note', 'gamma')).note).toBe('derived:gamma');
192+
expect((await insert(lowered, 'lbd_derive_note', 'gamma')).note).toBe('derived:gamma');
193+
});
194+
});

‎packages/verify/README.md‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,44 @@ await stack.stop();
195195
one module, under vitest `isolate: false`, and never `stop()` a stack other
196196
files still use.
197197

198+
### What the build ships: boot the compiled artifact
199+
200+
`objectstack build` lowers each inline hook `handler` and action `target` it
201+
can into a metadata-only `body`, and a deployment runs that body in the
202+
runtime's QuickJS sandbox, against the sandbox's `ctx`. A source boot runs the
203+
function in-process instead. To test what ships, boot the artifact:
204+
205+
```ts
206+
// After `objectstack build` (the handle lowers nothing; build first).
207+
const shipped = await bootStack(myApp, { artifact: 'dist/objectstack.json' });
208+
209+
// Every door now runs the lowered body. A handler that passes in-process can
210+
// refuse here: a body meets the sandbox's `ctx`, which has no
211+
// `ctx.dispatch.scope`, for one.
212+
await expect(shipped.hooks.run('crm_lead', 'insert', { last_name: 'Ada' }, { system: true }))
213+
.rejects.toMatchObject({ name: 'SandboxError' });
214+
```
215+
216+
- The artifact is read by the runtime's own artifact loader and mounted as the
217+
app, in place of `config`; the rest of the stack is still composed from
218+
`config` (its `requires`, its `plugins`, its default profile), as
219+
`objectstack serve <config>` composes it. A relative path resolves against
220+
`hostRoot`.
221+
- A refused body answers as the body-only path answers: the handle rejects with
222+
the sandbox's `SandboxError`, which carries a `code` and `status` the body
223+
declared on its throw; a body that crashes (a `TypeError`) has none, and REST
224+
answers it `500` `INTERNAL_ERROR`.
225+
- A callable the build could not lower is bundled into
226+
`objectstack-runtime.*.mjs`, which a local artifact boot merges, so it runs
227+
in-process here as under `objectstack start`. `objectstack build
228+
--strict-body` refuses it at build time instead, and its artifact is
229+
body-only.
230+
- Refused before anything boots: an artifact that cannot be loaded
231+
(`RESOURCE_NOT_FOUND` / 404; the boot never falls back to the source), one
232+
built from another app (`RESOURCE_CONFLICT` / 409), and a configuration
233+
carrying `onEnable`, which no artifact carries (`INVALID_REQUEST` / 400). A
234+
stale artifact boots stale bodies: build before the run.
235+
198236
## Verdicts
199237

200238
**Data fidelity** (`runCrudVerification`):
@@ -258,7 +296,8 @@ for owner-scoped fixtures), `multiTenant` (also what decides the posture
258296
`tenancy()` reports), `automation` (register the automation service so
259297
`flows.*` and `automation.evaluateCondition` have something to drive; an app
260298
that declares `requires: ['automation']` gets it without this), `orgContext`,
261-
`databaseFile`, `extraPlugins`.
299+
`databaseFile`, `extraPlugins`, `artifact` (boot the compiled artifact as the
300+
app, so hooks and actions run their lowered `body`).
262301

263302
## Known limitations
264303

‎packages/verify/src/handle.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@
7070
// metadata → the engine's `SchemaRegistry`.
7171
// tenancy → the `tenancy` service AuthPlugin
7272
// registered at boot.
73+
// the lowered-body door → NOT a method here. [#22301] What a
74+
// deployment runs for a hook or an action is the `body` `objectstack
75+
// build` lowered its function to, in the runtime's QuickJS sandbox. Which
76+
// of the two runs is decided by the app the boot mounts, so the door is a
77+
// boot option, `bootStack(config, { artifact })`: the compiled artifact is
78+
// mounted as the app, and every door above then runs its bodies. The
79+
// handle lowers nothing and runs no sandbox of its own.
80+
// `harness.artifact-door.test.ts` pins the door, and
81+
// `packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts` pins a
82+
// handler the build's real lowering turns into a body that refuses.
7383
//
7484
// ⛔ Design rule (hotcrm#1579 step 5a): if a method needs a semantic the
7585
// kernel does not expose, that is a kernel gap to FILE — never a semantic to

0 commit comments

Comments
 (0)