Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/olive-eyes-hug.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/cli': patch
---

`os dev` over a host config now has ONE registrar for stack-declared security metadata

A HOST config — one whose `plugins[]` holds instantiated plugins — skips
`createStandaloneStack`, so the composition that already declares
`securityMetadataRegistrar: 'artifact-door'` never runs. `os serve` then wrapped the
config module in `new AppPlugin(config)` under the default `'app-plugin'` registrar,
and under `os dev` it ALSO composed the dev-only HMR `MetadataPlugin` over
`dist/objectstack.json` — the compiled twin of that same module, which the `os dev`
supervisor had just produced. Both writers registered `positions`, `permissions`,
`capabilities` and `sharingRules` into the metadata service, from two sources of one
stack.

The two copies did not differ by parsing — `defineStack()` is strict by default and
runs the same schema parse the artifact door runs, so both carry the schema defaults.
They differed by ADR-0010 provenance, and by freshness: the door re-ingests its copy
on every recompile while the module copy never refreshes. Measured on a real `os dev`
boot, the wrap registered last, so its copy won the cold boot — and the door's copy
replaced it on the first artifact reload, so which copy a consumer read changed
mid-run, with no restart and no signal.

The `os dev` composition now declares `securityMetadataRegistrar: 'artifact-door'` on
that wrap exactly when it composes the HMR door over a compiled artifact that is
present on disk, so the door is the single registrar on this boot shape too. Nothing
changes when no door composes — `os serve`, `os migrate`, a host config whose artifact
has not been compiled or was named but is missing, and every production boot keep the
default `'app-plugin'` registrar and their only writer.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four
* ADR-0057 security collections — the artifact door — and only when that door
* actually composes.**
*
* ## The two writers this refuses, as measured
*
* `shouldBootWithLibrary()` returns `false` for a host config (one whose
* `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the
* composition that already declares `securityMetadataRegistrar:
* 'artifact-door'` — never runs. Two other writers then reach the metadata
* service over the SAME stack:
*
* 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default
* `'app-plugin'` registrar its ADR-0057 block registers `positions` /
* `permissions` / `capabilities` / `sharingRules`.
* 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the
* COMPILED TWIN of that same module, which the `os dev` supervisor
* produced moments earlier. It strict-parses, forward-converts and
* ADR-0010-stamps, and reaches all four collections too.
*
* Both were measured on a real `os dev` boot of a host config (`examples/
* app-showcase`, whose `plugins[]` holds four connector plugins and whose
* stack declares all four collections):
*
* → Compiling objectstack.config.ts → dist/objectstack.json...
* INFO [MetadataPlugin] Loading metadata from local artifact file
* {"path":".../examples/app-showcase/dist/objectstack.json"}
* INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
* INFO Registered stack-declared security metadata
* {"appId":"com.example.showcase","count":23}
*
* — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a
* `Map.set`, so **the wrap's copy wins the cold boot**; and because the door
* re-ingests on every artifact reload while the module copy never refreshes,
* **the winner changes mid-run**. Measured on an instrumented host config
* whose compiled twin carried a distinguishing label: the cold-boot registry
* held the module's labels and no `_packageVersion`, and 37s later — after one
* artifact reload, no restart — the same four items held the artifact's labels
* and `_packageVersion: '1.0.0'`.
*
* ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On
* a config boot `defineStack()` is strict by default and runs the same
* `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/
* stack.zod.ts`), so the wrap's copy already carries the schema defaults and
* the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp
* (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on
* `position`), and — the half that bites — it never refreshes, while the
* door's copy reloads on every recompile. A consumer therefore reads one of
* two copies of an authorization input depending on when it asked. That is
* why this is `security`-labelled, and why the fix is the ownership one
* rather than "make the two shapes match".
*
* ## What is pinned, and why the guard is on SOURCE
*
* The decision lives inside `Serve.run()`, ~900 lines into a method that boots
* a kernel, a database and an HTTP server; there is no seam to call. The
* repo's answer for exactly this shape is a source pin
* (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`)
* — assert the STRUCTURE that makes the composition correct, and pair it with
* a behavioural assertion that the words the structure uses still mean
* something. Both halves are here: without the second, renaming the option on
* `AppPlugin` would leave this file green over a dead string.
*
* The structural invariant has two directions and both matter:
*
* • the wrap declares `'artifact-door'` when the door composes, and
* • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the
* door does not — because a host config with no compiled artifact, and
* every non-dev host boot, would otherwise lose its ONLY registrar.
* Measured: `os serve` over the same host config has a metadata service
* and the wrap is its only writer (`Registered stack-declared security
* metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot).
*
* ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the
* supervisor always writes its channel, and `resolveDefaultArtifactPath`
* returns an explicitly named path VERBATIM with no existence check
* (`packages/runtime/src/default-host.ts`) — only the conventional
* `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a
* path that is not on disk (`os dev --artifact ./typo.json`, a stale
* `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is
* `{ optional: true }` and answers ENOENT with an `info` line, registering
* nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to
* a writer that never writes, and all four collections would end the boot with
* ZERO registrars — green and quiet, and strictly worse than the divergence
* this composition removes. So the gate is EXISTENCE, not resolution.
*
* That is why the door instance is constructed next to the wrap and only
* `kernel.use`d at its ordering-constrained site: ONE value decides both
* facts. Two independent expressions would be free to drift, and the drift is
* invisible — a boot with no registrar looks exactly like a boot with one.
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AppPlugin } from '@objectstack/runtime';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVE_TS = path.join(HERE, 'serve.ts');
const source = fs.readFileSync(SERVE_TS, 'utf8');

/** Occurrences of `needle` in the source, as a plain substring count. */
function count(needle: string): number {
return source.split(needle).length - 1;
}

describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => {
it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => {
expect(source, 'the door decision must be a single named value').toContain(
'let devArtifactDoor: any;',
);
// The gate is the same one the composition has always used.
expect(source).toContain(
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
);
// Exactly one MetadataPlugin is constructed in this file, and it is
// that value — a second construction site is a second decision.
expect(count('new MetadataPlugin(')).toBe(1);
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
// The path still comes from the supervisor's own channel, never from
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());',
);
});

it('the door is composed only when its artifact EXISTS, not merely resolves', () => {
// The regression this closes: `resolveDefaultArtifactPath` returns a
// NAMED path verbatim without stat'ing it, and the door tolerates
// ENOENT by starting empty — so gating on resolution alone hands the
// four collections to a writer that never writes.
expect(source, 'the door must be gated on the artifact being on disk').toContain(
'if (!fs.existsSync(hmrArtifactPath)) {',
);
// The gate must sit BEFORE the construction, not after it: a door
// constructed and then discarded would still have set the wrap's
// option under any future refactor that reads "was one built?".
const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {');
const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({');
expect(gateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(-1);
expect(gateAt).toBeLessThan(buildAt);
// A missing artifact is not a silent downgrade: the warning names the
// path, and says the wrap keeps the collections.
expect(source).toContain(
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
);
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
});

it('the host-config wrap declares `artifact-door` exactly when that door exists', () => {
expect(source).toContain(
"devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},",
);
// ⛔ The unconditional shape is the defect: it is what put a SECOND
// writer on every `os dev` boot of a host config — a copy that lacks
// the ADR-0010 provenance stamp and never refreshes, alongside the
// door's, which reloads on every recompile.
expect(
source,
'the wrap must never be constructed without the registrar decision',
).not.toContain('new AppPlugin(config)]');
});

it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => {
expect(count('await kernel.use(devArtifactDoor);')).toBe(1);
expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);');
// The `kernel.use` still sits AFTER the HonoServer composition —
// MetadataPlugin.start() mounts its route on the `http-server`
// service. Positions, not line numbers: the file moves.
expect(source.indexOf('await kernel.use(serverPlugin);'))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
// ...and the wrap is constructed BEFORE it, which is the whole reason
// the decision had to be hoisted.
expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"))
.toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);'));
});

it('the `kernel.use` catch does not claim a consequence it cannot have', () => {
// An earlier draft warned there that the four collections had gone
// unregistered. `Kernel.use` only validates the plugin and registers
// it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later,
// in `bootstrap` — so for a MetadataPlugin already constructed above,
// on a still-`idle` kernel, that catch does not fire. The real
// lost-door case is the missing artifact, and it warns where the path
// can be named; see the existence test above.
const useAt = source.indexOf('await kernel.use(devArtifactDoor);');
expect(useAt).toBeGreaterThan(-1);
const catchWindow = source.slice(useAt, useAt + 600);
expect(catchWindow).not.toContain('The app wrap deferred');
expect(catchWindow).not.toContain('NOT registered on this boot');
});

it('behavioural: the option the source passes is the one AppPlugin reads', () => {
const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } };
// The exact two literals the composition above can pass.
expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin');
expect(
new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' })
.securityMetadataRegistrar,
).toBe('artifact-door');
});
});
Loading
Loading