Skip to content

Commit e04b7f6

Browse files
os-trumpclaude
andauthored
fix(cli): let the dev artifact door own stack-declared security metadata on a host config (#14631)
* fix(cli): let the dev artifact door own stack-declared security metadata on a host config `os dev` over a HOST config composed two writers over one stack: the `new AppPlugin(config)` wrap over the config module (default `'app-plugin'` registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide the door once, before the wrap, and declare `securityMetadataRegistrar: 'artifact-door'` on the wrap exactly when that door composes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(cli): compose the dev artifact door only when its artifact is on disk Contract review found that gating on resolution alone regressed the very hole the change removes: `resolveDefaultArtifactPath` returns a named path verbatim without an existence check, and the door tolerates ENOENT by starting empty, so `os dev --artifact ./typo.json` over a host config left all four security collections with no registrar at all. Gate the door on `fs.existsSync` and name the missing path in the warning; drop the consequence text from the `kernel.use` catch, which cannot fire for an already-constructed plugin on an idle kernel; correct the shipped wording — the two copies differ by ADR-0010 provenance and freshness, not by parsing, since `defineStack()` is strict by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * docs(cli): reflow the pin docblock so the bullet keeps its measurement Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * docs(cli): record the endpoint trade the artifact-existence gate makes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * docs(cli): retire the last "unparsed" claim from the pin's inline comment The docblock, the changeset and the serve.ts comment already say what was measured; this one inline comment above the unconditional-shape assertion still carried the retired wording. The second writer's copy is strict-parsed like the door's — it lacks the ADR-0010 provenance stamp and never refreshes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c25c053 commit e04b7f6

3 files changed

Lines changed: 367 additions & 31 deletions

File tree

.changeset/olive-eyes-hug.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os dev` over a host config now has ONE registrar for stack-declared security metadata
6+
7+
A HOST config — one whose `plugins[]` holds instantiated plugins — skips
8+
`createStandaloneStack`, so the composition that already declares
9+
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
10+
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
11+
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
12+
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
13+
supervisor had just produced. Both writers registered `positions`, `permissions`,
14+
`capabilities` and `sharingRules` into the metadata service, from two sources of one
15+
stack.
16+
17+
The two copies did not differ by parsing — `defineStack()` is strict by default and
18+
runs the same schema parse the artifact door runs, so both carry the schema defaults.
19+
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
20+
on every recompile while the module copy never refreshes. Measured on a real `os dev`
21+
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
22+
replaced it on the first artifact reload, so which copy a consumer read changed
23+
mid-run, with no restart and no signal.
24+
25+
The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
26+
that wrap exactly when it composes the HMR door over a compiled artifact that is
27+
present on disk, so the door is the single registrar on this boot shape too. Nothing
28+
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
29+
has not been compiled or was named but is missing, and every production boot keep the
30+
default `'app-plugin'` registrar and their only writer.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
5+
* ADR-0057 security collections — the artifact door — and only when that door
6+
* actually composes.**
7+
*
8+
* ## The two writers this refuses, as measured
9+
*
10+
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
11+
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
12+
* composition that already declares `securityMetadataRegistrar:
13+
* 'artifact-door'` — never runs. Two other writers then reach the metadata
14+
* service over the SAME stack:
15+
*
16+
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
17+
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
18+
* `permissions` / `capabilities` / `sharingRules`.
19+
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
20+
* COMPILED TWIN of that same module, which the `os dev` supervisor
21+
* produced moments earlier. It strict-parses, forward-converts and
22+
* ADR-0010-stamps, and reaches all four collections too.
23+
*
24+
* Both were measured on a real `os dev` boot of a host config (`examples/
25+
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
26+
* stack declares all four collections):
27+
*
28+
* → Compiling objectstack.config.ts → dist/objectstack.json...
29+
* INFO [MetadataPlugin] Loading metadata from local artifact file
30+
* {"path":".../examples/app-showcase/dist/objectstack.json"}
31+
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
32+
* INFO Registered stack-declared security metadata
33+
* {"appId":"com.example.showcase","count":23}
34+
*
35+
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
36+
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
37+
* re-ingests on every artifact reload while the module copy never refreshes,
38+
* **the winner changes mid-run**. Measured on an instrumented host config
39+
* whose compiled twin carried a distinguishing label: the cold-boot registry
40+
* held the module's labels and no `_packageVersion`, and 37s later — after one
41+
* artifact reload, no restart — the same four items held the artifact's labels
42+
* and `_packageVersion: '1.0.0'`.
43+
*
44+
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
45+
* a config boot `defineStack()` is strict by default and runs the same
46+
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
47+
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
48+
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
49+
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
50+
* `position`), and — the half that bites — it never refreshes, while the
51+
* door's copy reloads on every recompile. A consumer therefore reads one of
52+
* two copies of an authorization input depending on when it asked. That is
53+
* why this is `security`-labelled, and why the fix is the ownership one
54+
* rather than "make the two shapes match".
55+
*
56+
* ## What is pinned, and why the guard is on SOURCE
57+
*
58+
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
59+
* a kernel, a database and an HTTP server; there is no seam to call. The
60+
* repo's answer for exactly this shape is a source pin
61+
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
62+
* — assert the STRUCTURE that makes the composition correct, and pair it with
63+
* a behavioural assertion that the words the structure uses still mean
64+
* something. Both halves are here: without the second, renaming the option on
65+
* `AppPlugin` would leave this file green over a dead string.
66+
*
67+
* The structural invariant has two directions and both matter:
68+
*
69+
* • the wrap declares `'artifact-door'` when the door composes, and
70+
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
71+
* door does not — because a host config with no compiled artifact, and
72+
* every non-dev host boot, would otherwise lose its ONLY registrar.
73+
* Measured: `os serve` over the same host config has a metadata service
74+
* and the wrap is its only writer (`Registered stack-declared security
75+
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
76+
*
77+
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
78+
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
79+
* returns an explicitly named path VERBATIM with no existence check
80+
* (`packages/runtime/src/default-host.ts`) — only the conventional
81+
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
82+
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
83+
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
84+
* `{ optional: true }` and answers ENOENT with an `info` line, registering
85+
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
86+
* a writer that never writes, and all four collections would end the boot with
87+
* ZERO registrars — green and quiet, and strictly worse than the divergence
88+
* this composition removes. So the gate is EXISTENCE, not resolution.
89+
*
90+
* That is why the door instance is constructed next to the wrap and only
91+
* `kernel.use`d at its ordering-constrained site: ONE value decides both
92+
* facts. Two independent expressions would be free to drift, and the drift is
93+
* invisible — a boot with no registrar looks exactly like a boot with one.
94+
*/
95+
96+
import { describe, it, expect } from 'vitest';
97+
import fs from 'node:fs';
98+
import path from 'node:path';
99+
import { fileURLToPath } from 'node:url';
100+
import { AppPlugin } from '@objectstack/runtime';
101+
102+
const HERE = path.dirname(fileURLToPath(import.meta.url));
103+
const SERVE_TS = path.join(HERE, 'serve.ts');
104+
const source = fs.readFileSync(SERVE_TS, 'utf8');
105+
106+
/** Occurrences of `needle` in the source, as a plain substring count. */
107+
function count(needle: string): number {
108+
return source.split(needle).length - 1;
109+
}
110+
111+
describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
112+
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
113+
expect(source, 'the door decision must be a single named value').toContain(
114+
'let devArtifactDoor: any;',
115+
);
116+
// The gate is the same one the composition has always used.
117+
expect(source).toContain(
118+
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
119+
);
120+
// Exactly one MetadataPlugin is constructed in this file, and it is
121+
// that value — a second construction site is a second decision.
122+
expect(count('new MetadataPlugin(')).toBe(1);
123+
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
124+
// The path still comes from the supervisor's own channel, never from
125+
// `<cwd>/dist/objectstack.json` by accident.
126+
expect(source).toContain(
127+
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
128+
);
129+
});
130+
131+
it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
132+
// The regression this closes: `resolveDefaultArtifactPath` returns a
133+
// NAMED path verbatim without stat'ing it, and the door tolerates
134+
// ENOENT by starting empty — so gating on resolution alone hands the
135+
// four collections to a writer that never writes.
136+
expect(source, 'the door must be gated on the artifact being on disk').toContain(
137+
'if (!fs.existsSync(hmrArtifactPath)) {',
138+
);
139+
// The gate must sit BEFORE the construction, not after it: a door
140+
// constructed and then discarded would still have set the wrap's
141+
// option under any future refactor that reads "was one built?".
142+
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
143+
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
144+
expect(gateAt).toBeGreaterThan(-1);
145+
expect(buildAt).toBeGreaterThan(-1);
146+
expect(gateAt).toBeLessThan(buildAt);
147+
// A missing artifact is not a silent downgrade: the warning names the
148+
// path, and says the wrap keeps the collections.
149+
expect(source).toContain(
150+
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
151+
);
152+
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
153+
});
154+
155+
it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
156+
expect(source).toContain(
157+
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
158+
);
159+
// ⛔ The unconditional shape is the defect: it is what put a SECOND
160+
// writer on every `os dev` boot of a host config — a copy that lacks
161+
// the ADR-0010 provenance stamp and never refreshes, alongside the
162+
// door's, which reloads on every recompile.
163+
expect(
164+
source,
165+
'the wrap must never be constructed without the registrar decision',
166+
).not.toContain('new AppPlugin(config)]');
167+
});
168+
169+
it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
170+
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
171+
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
172+
// The `kernel.use` still sits AFTER the HonoServer composition —
173+
// MetadataPlugin.start() mounts its route on the `http-server`
174+
// service. Positions, not line numbers: the file moves.
175+
expect(source.indexOf('await kernel.use(serverPlugin);'))
176+
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
177+
// ...and the wrap is constructed BEFORE it, which is the whole reason
178+
// the decision had to be hoisted.
179+
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
180+
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
181+
});
182+
183+
it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
184+
// An earlier draft warned there that the four collections had gone
185+
// unregistered. `Kernel.use` only validates the plugin and registers
186+
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
187+
// in `bootstrap` — so for a MetadataPlugin already constructed above,
188+
// on a still-`idle` kernel, that catch does not fire. The real
189+
// lost-door case is the missing artifact, and it warns where the path
190+
// can be named; see the existence test above.
191+
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
192+
expect(useAt).toBeGreaterThan(-1);
193+
const catchWindow = source.slice(useAt, useAt + 600);
194+
expect(catchWindow).not.toContain('The app wrap deferred');
195+
expect(catchWindow).not.toContain('NOT registered on this boot');
196+
});
197+
198+
it('behavioural: the option the source passes is the one AppPlugin reads', () => {
199+
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
200+
// The exact two literals the composition above can pass.
201+
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
202+
expect(
203+
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
204+
.securityMetadataRegistrar,
205+
).toBe('artifact-door');
206+
});
207+
});

0 commit comments

Comments
 (0)