Skip to content

Commit 7f2f081

Browse files
fix(cli,runtime): a host-config os serve composes a MetadataPlugin, so its declared apis are served; a matcher-less composition warns once (#23078)
Fixes #22989 Clause-②: no The A + B slice of #22989, as the maintainer ruled in `6125390666` (「A+B (推荐)」). Slice 1, PR #23046 (`0cbfb7c32c`), made the config door register a stack's `api` items into the metadata service. This slice makes a host-config `os serve` boot serve them, and makes a composition that still cannot serve them say so once. The card's pin is met on this branch's final tree (`66e7f58fd5`): `os serve objectstack.config.ts` of `examples/app-showcase`, production posture, answers `GET /api/v1/apps/showcase/tasks` with no session `401 UNAUTHENTICATED`. It answered `404 ENDPOINT_NOT_FOUND`. ## What changes **A. `packages/cli/src/commands/serve.ts`, the host-config composition.** A host config (its `plugins[]` holds plugin instances) never reaches `createStandaloneStack`, so outside `os dev`'s artifact door its `metadata` slot was the kernel's in-memory core fallback, which has no `matchEndpoint`. When the dev artifact door does not compose, the config is a host config (`isHostConfig`), and `plugins[]` holds no `MetadataPlugin` of its own, the boot now composes: ```ts new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir: hostRoot }) ``` - No `artifactSource`: the door reads none of the stack. The AppPlugin wrap stays the single registrar of the four ADR-0057 collections, and its `securityMetadataRegistrar` stays the default `'app-plugin'`. ⛔ It is never `'artifact-door'` for this door. - `bootstrap: 'lazy'` and `rootDir: hostRoot` are census answers 1 and 2 below. - It is `kernel.use`d at the dev door's site, after the HTTP server plugin, with no catch: a boot that cannot compose its `metadata` occupant fails rather than falling back quietly. - The dev door's decision and construction are unchanged. Its missing-artifact warning and the comment above it are reworded, because both said the dev SSE endpoint is not mounted on that boot. That boot now composes this door, which mounts the endpoint under `NODE_ENV=development` (census 10). The warning now names what is actually lost, the artifact reload: `Dev artifact reload not enabled: no compiled artifact at PATH`. **B. `packages/runtime/src/app-plugin.ts`, inside `registerDeclaredApiEndpoints` only.** When the stack declares at least one `api` item and the `metadata` slot has no `matchEndpoint`, the method logs one `warn` per app per boot, with structured fields `{ appId, count }`. The registration that follows is unchanged. The warning reads, measured on an `OS_MODE=off` boot of a scratch stack with one declared endpoint: ```text WARN [AppPlugin] app 'com.example.fxb' declares 1 api endpoint(s), but this boot's 'metadata' service has no endpoint matcher (matchEndpoint), so every declared path answers 404 ENDPOINT_NOT_FOUND. Compose @objectstack/metadata's MetadataPlugin on this kernel to serve them. {"appId":"com.example.fxb","count":1} ``` The same boot on BASE printed nothing and answered the same 404. No second matcher: the remedy is the composition's. The read is in `start()`, after every `init()` has registered its services and the kernel has injected its fallbacks. ## Census, measured before the composition edit How each item was measured: `examples/app-showcase`, `os serve objectstack.config.ts --no-ui` through the built entry `packages/cli/bin/run.js`, cwd the app, `NODE_ENV` unset, `OS_CLOUD_URL=off`, `OS_DATABASE_URL=file:` a fresh temp sqlite per boot, `OS_AUTH_SECRET` set, `--log-level info` for the census boots. BASE is a second worktree at `0cbfb7c32c`. AFTER is this branch at `88abc71212`, before either merge of `main`. A first sign-up on a production database is the bootstrap platform admin, and its session reads `/meta`. Dev-posture boots sign in as the seeded dev admin instead. 1. **The loader.** Measured with `MetadataPlugin` built from the BASE tree, `rootDir` = the showcase directory, no artifact, `init` + `start` on a capturing context. `eager` (the default) runs `_loadFromFileSystem`, which logged `Metadata loading complete {"totalItems":0,"registeredTypes":28}`. It loads nothing from the showcase, so nothing double-registers there. `lazy` logged `lazy bootstrap — skipping filesystem priming` and registered nothing either. **Chosen: `lazy`**, the artifact door's shape: that door loads its artifact and never runs the priming pass. `eager` with no artifact scans `rootDir/TYPE/` and registers whatever it finds beside the wrap's copy, without the stack's package stamp. The source pin shows the option is load-bearing: a root that holds `object/probe_door.object.json` primes `object/probe_door` under `eager` and nothing under `lazy`. After the change, the showcase boot logs `lazy bootstrap` once and never logs `Loading metadata from file system`. 2. **The repository.** Every mode but `artifact-only` attaches a `FileSystemRepository`. After the change the boot logs `FileSystemRepository attached {"repoRoot":".../examples/app-showcase/.objectstack/metadata","watch":false}`. Attaching writes nothing: no `.objectstack/metadata` exists anywhere in the worktree after the boot, and none in a scripted run over an empty root. **`rootDir` must be `hostRoot`**, the served app's root and the value `createStandaloneStack` hands its own door as `projectRoot`. Without it the plugin roots at `process.cwd()`. Measured from the repository root (`os serve examples/app-showcase/objectstack.config.ts`, cwd the worktree root): `repoRoot` is still the app's directory. 3. **Persistence.** The host-config boot already provisions the `sys_metadata` storage objects through ObjectQL. The primary database holds 108 tables before and after, an identical list that includes `sys_metadata`, `sys_metadata_activation`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history`, all with 0 rows. The door's `Registered system metadata objects` (4 objects) adds no table and no schema-sync step. Under the dev posture `sys_metadata_audit` lives in the telemetry sibling, unchanged. **DatabaseLoader:** no in-tree code calls `setDatabaseDriver` or `setDataEngine` (`git grep` over every non-test `.ts` / `.mjs` outside `metadata-manager.ts`: 0 hits). No `DatabaseLoader configured` line appears on either door, so a cold boot has no `sys_metadata` read-through. Reads go to the manager's registry and its `FilesystemLoader` over `hostRoot/TYPE/`, the same pair the artifact door's manager has. 4. **What `/meta` serves.** `GET /api/v1/meta/TYPE` with an admin session, BASE vs AFTER: status, item count and a hash of the whole body are equal for all 22 types read. No type's answer moves: | type | BASE | AFTER | artifact door (`os serve --dev` over `dist/`) | |---|---|---|---| | app | 200 / 3 | same body | same body | | view | 200 / 36 | same body | same body | | page | 200 / 34 | same body | differs (pre-existing; equal before and after) | | dashboard | 200 / 4 | same body | differs (pre-existing; equal before and after) | | report, action, flow, webhook, hook, mapping, analytics_cube, connector, email_template, doc, book | 200 / 4, 71, 30, 1, 5, 1, 1, 4, 25, 9, 1 | same bodies | same bodies | | object, position, permission, capability, sharing_rule, api, datasource | 200 / 108, 16, 17, 11, 2, 2, 2 | same bodies | same bodies | `/meta` already served these 15 types from the ObjectQL registry. The metadata SERVICE lacking them on the config door (`6125234382`) is still true after the change, and `/meta` does not read it alone. `AppPlugin.registerUnclaimedTopLevel` moves nothing here: the showcase carries no `packages[]`, and the residual rule logged no line. One served face does move, as it should: `GET /api/v1/openapi.json` listed no `/api/v1/apps/showcase/` path on BASE and lists both declared endpoints (`/tasks`, `/inquiries/purge`) after. On BASE, `/meta/api` and `/openapi.json` logged `no endpoint matcher is reachable` and omitted both items. Neither line appears after. At the final tree `66e7f58fd5`, after merging `main`, every body still equals BASE except `email_template`. Its only differing keys are `category` and `isSystem`, which `main` retired in #22990, not this change. 5. **The cluster bridge.** `Runtime` composes `MetadataClusterBridgePlugin` on every boot (`packages/runtime/src/runtime.ts`). Its lane 1 calls `attachClusterPubSub` on the `metadata` service at `kernel:ready`. BASE host-config boot: `WARN MetadataClusterBridgePlugin: metadata service does not expose attachClusterPubSub(); cross-node cache invalidation disabled`, once. After: 0, because the in-process memory driver now takes the `debug` skip. A scripted run of the bridge over a fake cluster service, BASE tree: | occupant | driver | lane 1 | `metadata.changed` subscriptions | published | |---|---|---|---|---| | core fallback | memory | warn | 0 | 0 | | core fallback | redis | warn | 0 | 0 | | MetadataManager | memory | debug skip | 0 | 0 | | MetadataManager | redis | `bridged metadata.changed → cluster.pubsub` | 1 | 1 per `register()`; 0 per `registerInMemory()` | So with a remote driver this composition attaches lane 1, and that is **the first `metadata.changed` bridge on this boot shape, not a second broadcast.** The fallback had no publisher. Lane 2 (`metadata.mutated`, the protocol's persistence fan-out) is a different channel with a different owner, and it is unchanged. The boot's own `registerInMemory` writes publish nothing. 6. **Boot cost.** 7 runs of each, interleaved BASE / AFTER, under the shared verification lock: a shared box, so read the ratio. Command: `node packages/cli/bin/run.js serve objectstack.config.ts --port P --no-ui`, cwd `examples/app-showcase`, env as above, default log level. Median time to the first HTTP answer: 8472 ms vs 8407 ms. Median to the ready banner: 8473 ms vs 8458 ms. Median RSS 1.5 s after ready: 460980 kB vs 459340 kB. Ranges: listen 7538–9696 vs 7566–8691 ms. No measurable cost. 7. **The dev routes.** Production, after: `GET /api/v1/dev/metadata-events` answers 404, and the boot prints `dev metadata-HMR endpoints NOT mounted ... NODE_ENV unset, treated as production`. Pinned in process (below). Development posture, after, with no compiled artifact (`os serve --dev` with no `dist/`, and the supervisor-named missing artifact): 404 before, 200 `text/event-stream` after. That is the one route that moves, and only under `NODE_ENV=development`. It is the same route every other config's dev boot mounts. 8. **The security registrar.** The wrap keeps `'app-plugin'`. `Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}` is logged once before and once after, and the door logs no registration of its own. The `/meta` bodies of `position` (16), `permission` (17), `capability` (11) and `sharing_rule` (2) are equal before and after (item 4). Pinned: the source pin holds the one `'artifact-door'` spelling to the dev door's expression, and the integration boot serves the fixture's position and capability exactly once each. 9. **The order.** From the AFTER boot's kernel log: `Phase 1: Init plugins` at 03.408. `Initializing Metadata Manager` at 03.553, then `MetadataPlugin providing metadata service (primary mode)` at 03.556, so the slot is filled during Phase 1. `Phase 2: Start plugins` at 03.736. `Registered stack-declared api endpoints in metadata registry {"count":2}` at 05.274, inside AppPlugin's `start()`. The slot holds the `MetadataManager` when `registerDeclaredApiEndpoints` runs. That holds by construction too: `init()` registers, `start()` reads, and the kernel injects a core fallback only for a service that no `init()` provided. 10. **Which boots.** The new composition reaches: - (a) a non-dev `os serve` of a host config; - (b) `os start` beside a host config: its `serve` child boots non-dev and loads the host module when the artifact is the config's own compiled output, so it shares this code and changes too. HTTP NOT MEASURED for `os start` itself; - (c) `os serve --dev` of a host config with no compiled artifact (measured 404 → 401); - (d) the dev path with a supervisor-named missing artifact (measured with `OS_INTERNAL_ARTIFACT_PATH` naming an absent `dist/objectstack.json`: 404 → 401); - (e) a host config under `--no-server` (composes; not measured over HTTP, there is none). It does not reach: - the dev artifact door's boots (`os dev`, `os serve --dev` with `dist/` on disk), which stay 401 before and after; - every library boot (a bare config, the artifact fallback), which already composes a `MetadataPlugin`; - an `OS_MODE=off` (or `bootMode: 'off'`) bare config, which is not a host config. That boot keeps the fallback and now prints B's warning (measured: 1 warning, still 404). ## HTTP before and after No session; `GET /api/v1/apps/showcase/tasks` (declared, `authRequired`) and an undeclared path under the same namespace: | boot | BASE declared | AFTER declared | undeclared (both) | |---|---|---|---| | `os serve objectstack.config.ts` (production, host config) | 404 `ENDPOINT_NOT_FOUND` | **401 `UNAUTHENTICATED`** | 404 | | the same, from the repository root | — | 401 | 404 | | `os serve --dev`, no `dist/` | 404 | **401** | 404 | | `os serve --dev`, named artifact missing | 404 | **401** | 404 | | `os serve --dev` over `dist/` (the artifact door) | 401 | 401 | 404 | | `os dev --fresh --no-watch` | 401 | 401 | 404 | | final tree `66e7f58fd5`, production host config | — | 401 | 404 | `POST /api/v1/apps/showcase/inquiries/purge` (declared) moves 404 → 401 on the same three rows. With a session, the declared `GET` answers 200 with records after. The plain artifact-fallback boot of the showcase (no config, `OS_ARTIFACT_PATH`) refuses at BASE: its connector providers are code in `plugins[]`, which a JSON artifact cannot carry. The artifact control for this stack is therefore the dev door. ## Tests At the final tree `66e7f58fd5`, each run went through `scripts/pm/os-verify-lock.sh` and printed `VERDICT command-exit 0`: - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: `Test Files 360 passed (360)`, `Tests 5156 passed | 19 skipped (5175)`. - `pnpm --filter @objectstack/runtime typecheck`: exit 0, `check:test-typecheck: OK`. The new test is in `tsconfig.test.json`'s program (`--listFiles`, 1 hit). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 284 passed (284)`, `Tests 4276 passed (4276)`. - `pnpm --filter @objectstack/cli typecheck`: exit 0, `check:test-typecheck: OK`. The three changed `src/` test files are in `tsconfig.json`'s program, and the `test/` one is in `tsconfig.test.json`'s. - `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/serve-host-config-metadata-door.integration.test.ts test/artifact-flag-precedence.integration.test.ts`: `Test Files 2 passed (2)`, `Tests 14 passed (14)`. The rest of the cli integration tier is declared to CI. - The pins named to stay green are green inside those runs: `serve-host-config-security-registrar.pin.test.ts` and `serve-host-config.test.ts` (cli unit), `artifact-flag-precedence.integration.test.ts` (above), and PR #23046's `app-plugin-config-door-api-items.test.ts` (runtime). New and changed tests: - `packages/runtime/src/app-plugin-api-matcher-absent-warning.test.ts` (B; 3 cases on real kernel boots of `createStandaloneStack`'s plugins over an absent artifact, plus the CLI's wrap). - The in-memory-fallback composition logs exactly one warning, with meta `{ appId, count: 1 }`. - CONTROL: with a `MetadataManager`, no warning, and the declared path matches. - CONTROL: with no `apis`, no warning. - The warning is identified by its named subjects (the app id, the remedy plugin) and its fields, never by its prose. - `packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts` (A; 6 unit cases). - Structure: one named decision; its three-part gate; no `artifactSource` or `artifactWatch`; `lazy`; `rootDir: hostRoot`; the wrap's `'artifact-door'` never keyed on this door; `kernel.use`d after the HTTP server plugin. - Behavioural: under `NODE_ENV=production` the door mounts no dev metadata-events route (CONTROL: under `development` it mounts `GET /api/v1/dev/metadata-events`). - Behavioural: `lazy` is load-bearing. The `eager` CONTROL primes `object/probe_door` from the same root, `lazy` primes nothing, and no `.objectstack/metadata` is written. - `packages/cli/test/serve-host-config-metadata-door.integration.test.ts` (A; 4 integration-tier cases). A host-config fixture (a plugin instance, one object, one `authRequired` api, one position, one capability), booted with `os serve`. - With no `dist/`, the declared path answers `401 UNAUTHENTICATED`, and an undeclared path answers `404 ENDPOINT_NOT_FOUND`. - The position and the capability are each served exactly once. - CONTROL: with the compiled twin on disk, the artifact door answers the same 401. - `serve-host-config-security-registrar.pin.test.ts`: `new MetadataPlugin(` count 1 → 2, with each construction site counted once and the artifact source spelled once. The missing-artifact warning literal follows the reword. - `packages/cli/src/utils/boot-preparation-parity.test.ts`: `isHostConfig` is declared in `SERVE_ONLY` with its reason. The one-shot boot always composes the standalone stack's `MetadataPlugin`. ## Ablations Both ablations ran through `node scripts/ablation-replace.mjs` in wrap mode: the restore is trap-armed and the anchor and replacement are counted on disk. Each restore was proven by blob == HEAD and an empty `git diff HEAD`. Both moved in the expected direction: the positive pin turned red. - **A, the composition line.** - Mutation: anchor `await kernel.use(hostConfigMetadataDoor);` went x1 → x0, and the comment `// ablation-22989: the door is not composed` went x0 → x1. Blob `0fa07d1065` → `f28bcffd47`. - The integration file runs `serve.ts` from source through the tsx entry, so no build sits in this loop. - Result: `Tests 1 failed | 3 passed (4)`. The 401 pin failed with `- "code": "UNAUTHENTICATED", "status": 401` / `+ "code": "ENDPOINT_NOT_FOUND", "status": 404`. The undeclared-404, security-once and artifact-door controls stayed green. Restored to `0fa07d1065`. - A first run of the same mutation against an earlier draft of the file booted the BUILT entry. That run rebuilt the cli, and `node scripts/ablation-dist-preflight.mjs @objectstack/cli 'await kernel.use(hostConfigMetadataDoor)' --absent` reported the marker absent from all 612 built files. It turned the same single leg red (`expected { status: 404, …(1) }`). Its restore leg rebuilt, and the preflight read the marker back in `dist/commands/serve.js` with the tree clean. - **B, the warning guard.** - Mutation: anchor `if (typeof metadata?.matchEndpoint !== 'function') {` went x1 → x0, with the guard made false. Blob `afe89beb7f` → `00b2d3da1f`. The test imports `./app-plugin.js` from source. - Result: `Tests 1 failed | 2 passed (3)`, `expected [] to have a length of 1 but got +0`. Both controls stayed green. Restored to `afe89beb7f`. - **`bootstrap: 'lazy'`** carries its ablation inside the source pin: the `eager` CONTROL leg primes the item that `lazy` does not. ## Gates Derivation: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, at `66e7f58fd5`: 69 commands. The change set is 8 paths vs merge base `0a29bec6a`: committed 8, working tree 0, untracked 0. - The explicit-path derivation over this diff's paths, at `e61879428f`, printed the identical 69. - Added to the 69: the 5 artifact-roster gates whose roster sits under these paths, and `check-startup-registry-verdict` (B adds a `warn` that reads a service in `start()`). Results: - 75 commands, each run with its exit code captured before any pipe, at `66e7f58fd5`. All 75 exit 0. - Reconciliation: `Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`; `a DERIVED zero — all 69 recorded an exit code and none of them is 3`. - `check:dual-build-cjs-loads` needed eight unrelated packages' `dist/` first, rebuilt from the turbo cache. Its first-pass answer at `e61879428f` was exit 3, PREREQUISITE NOT MET: a non-measurement, not a red. - The final derivation flagged a stale tree. `origin/main` gained two commits after this branch's last merge: `packages/plugins/organizations`, `packages/verify` and `scripts/check-verify-stand-in-erasure.mjs`. They are disjoint from this diff and are not merged. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --self-test :: exit 0 node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-keyed-text-bounds.mjs :: exit 0 node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0 node scripts/check-platform-object-tenancy-census.mjs :: exit 0 node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-registry-log-declared.mjs :: exit 0 node scripts/check-registry-log-declared.mjs --self-test :: exit 0 node scripts/check-rest-log-spy-declared.mjs :: exit 0 node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0 node scripts/check-system-context-census.mjs :: exit 0 node scripts/check-system-context-census.mjs --self-test :: exit 0 node scripts/check-undeclared-dep-imports.mjs :: exit 0 node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:objectui-changeset :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:stack-collection-maps :: exit 0 pnpm check:swallow-census-controls :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/check-changeset-fixed.mjs :: exit 0 pnpm check:authz-resolver :: exit 0 pnpm check:error-code-casing :: exit 0 pnpm check:filter-alias-parity :: exit 0 pnpm check:route-ledger-census :: exit 0 node scripts/check-startup-registry-verdict.mjs :: exit 0 ``` ## Lint, a proven narrowing At `66e7f58fd5`, over the 7 changed TypeScript files; the repo-wide `pnpm lint` is CI's. 1. **Population, from eslint's own config.** `eslint --no-inline-config --print-config` resolves a rule set for every changed `.ts` file: 5 rules each, 6 for `app-plugin.ts`. It resolves none for the `.md` changeset. 2. **Count, from `--format json`.** `files=7 errors=0 warnings=0`, exit 0. 3. **Invariance.** `eslint.config.mjs` never enables type-aware linting: no `parserOptions.project` and no `projectService` in any block. Its only file reads are two baseline JSONs at config load (`scripts/slot-lookup-baseline.json`, `scripts/query-options-erasure-baseline.json`), and this diff touches neither. So no untouched file's verdict can move. ## Acceptance notes - **Line 1 is `Fixes #22989`.** The card's pin is met on the final tree: production `os serve` of `examples/app-showcase` answers 401 on the declared path, where it answered 404. - **The claimed file surface was exceeded twice, in `packages/cli` test files only.** - `packages/cli/src/utils/boot-preparation-parity.test.ts` gains one `SERVE_ONLY` entry. `serve.ts` now calls `isHostConfig` from a boot-preparation module, and that pin requires every such call to be either a step or a declared serve-only read. Without the entry the cli unit tier is red (`1 failed | 283 passed`, measured). - The existing `serve-host-config-security-registrar.pin.test.ts` is edited, as described under Tests. - **The dev door's missing-artifact warning is reworded.** The door's decision and construction are unchanged. Its warning and comment said the dev SSE endpoint is not mounted on that boot, and that boot now composes this door, which mounts it under development. - **The integration boots run the source entry, in the development posture.** `check:cli-test-child-env` pins the population of built-entry spawners to a named set of seven files, in a script outside this claim. The production-only facts are pinned in process (the dev route) and were measured over HTTP on the showcase (census 3–8). - **No dogfood pin.** The dogfood harness boots through `@objectstack/verify`'s `bootStack`, which composes no `MetadataPlugin`, so it cannot boot this composition. A showcase dogfood boot is exactly where B's warning now speaks. - **B returns early for a `packages[]` bundle.** It returns before reading `apis`, so a multi-package stack's top-level residual reaches a matcher-less slot without the warning. No in-repo stack declares `apis` inside `packages[]`. Carrier: none. - **`os serve --dev` with no compiled artifact now mounts `GET /api/v1/dev/metadata-events`.** It is inert there: there is no artifact to reload. Every other config's dev boot already mounts it. - **Misleading prose on a matcher-less composition.** There, `GET /api/v1/openapi.json` refuses a stored `api` item for its runtime `_diagnostics` key (`unrecognized_keys`). It then logs that the item "does not satisfy ApiEndpointSchema … fix the declaration rather than the document". Measured on BASE's showcase boot, for both items. This change takes the host config off that path. Its remaining reach (an `OS_MODE=off` boot, lean kernels) is NOT MEASURED. Noted, not filed. Carrier: none. - **`os start` beside a host config shares this code and was not separately measured over HTTP.** --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b37edb commit 7f2f081

8 files changed

Lines changed: 753 additions & 27 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
`os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once
7+
8+
Clause-②: no
9+
10+
A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404.
11+
12+
`os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after:
13+
14+
- `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`).
15+
- `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them.
16+
- No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot.
17+
- The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from.
18+
- The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar.
19+
- The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot.
20+
- With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone.
21+
- Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident).
22+
23+
A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin: **a HOST config's `os serve` boot composes `@objectstack/metadata`'s
5+
* `MetadataPlugin` when no artifact door does, and that door names no
6+
* artifact, primes nothing and registers none of the stack** (#22989, the
7+
* maintainer's option A).
8+
*
9+
* ## Why it exists
10+
*
11+
* A host config (its `plugins[]` holds plugin instances: `examples/app-showcase`
12+
* and every `create-objectstack` scaffold) never reaches
13+
* `createStandaloneStack`, the composition that gives every other config boot
14+
* a `MetadataPlugin`. Outside `os dev`'s artifact door its `metadata` slot fell
15+
* to the kernel's in-memory core fallback, which has no `matchEndpoint`, so
16+
* every declared `apis` endpoint answered `404 ENDPOINT_NOT_FOUND` on
17+
* `os serve` (measured on the showcase: `GET /api/v1/apps/showcase/tasks`
18+
* with no session, 404 before, 401 after). The behavioural pin is
19+
* `test/serve-host-config-metadata-door.integration.test.ts`; this file holds
20+
* the composition's STRUCTURE, which that boot cannot see.
21+
*
22+
* ## What is pinned
23+
*
24+
* • ONE decision, a single named value, gated on the dev artifact door being
25+
* absent, on a host config, and on the composition holding no
26+
* `MetadataPlugin` of its own.
27+
* • No `artifactSource`: the door reads nothing of the stack, so the AppPlugin
28+
* wrap stays the single registrar of the four ADR-0057 security
29+
* collections and keeps the default `'app-plugin'` registrar. ⛔ The wrap's
30+
* `'artifact-door'` declaration is never keyed on this door.
31+
* • `bootstrap: 'lazy'`: the default `eager` mode runs a filesystem priming
32+
* pass when no artifact is named, which would register a second copy of
33+
* anything under `rootDir/<type>/`. The behavioural leg below shows the
34+
* option is load-bearing: the same root primes an item under `eager` and
35+
* none under `lazy`.
36+
* • `rootDir` is `hostRoot`, the served app's root, never the operator's cwd.
37+
* • It is `kernel.use`d after the HTTP server plugin, at the dev door's site.
38+
* • Under production it mounts no dev `GET /api/v1/dev/metadata-events`; the
39+
* integration boots run the source entry, whose `NODE_ENV=development`
40+
* cannot reach that posture, so this leg is held in-process here.
41+
*/
42+
43+
import { describe, it, expect, vi } from 'vitest';
44+
import fs from 'node:fs';
45+
import os from 'node:os';
46+
import path from 'node:path';
47+
import { fileURLToPath } from 'node:url';
48+
import { MetadataManager, MetadataPlugin } from '@objectstack/metadata';
49+
50+
const HERE = path.dirname(fileURLToPath(import.meta.url));
51+
const SERVE_TS = path.join(HERE, 'serve.ts');
52+
const source = fs.readFileSync(SERVE_TS, 'utf8');
53+
54+
/** Occurrences of `needle` in the source, as a plain substring count. */
55+
function count(needle: string): number {
56+
return source.split(needle).length - 1;
57+
}
58+
59+
/** The construction window: from the assignment to the closing `});`. */
60+
function constructionWindow(): string {
61+
const at = source.indexOf('hostConfigMetadataDoor = new MetadataPlugin({');
62+
expect(at).toBeGreaterThan(-1);
63+
return source.slice(at, source.indexOf('});', at) + 3);
64+
}
65+
66+
describe('#22989 A — `os serve` of a HOST config composes an artifact-less MetadataPlugin', () => {
67+
it('the door is ONE named decision, gated on no artifact door, a host config and no MetadataPlugin', () => {
68+
expect(count('let hostConfigMetadataDoor: any;')).toBe(1);
69+
expect(count('hostConfigMetadataDoor = new MetadataPlugin({')).toBe(1);
70+
const gateAt = source.indexOf('let hostConfigMetadataDoor: any;');
71+
const gate = source.slice(gateAt, source.indexOf('hostConfigMetadataDoor = new MetadataPlugin({'));
72+
expect(gate).toContain('!devArtifactDoor');
73+
expect(gate).toContain('isHostConfig(config)');
74+
expect(gate).toContain(
75+
"!plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin')",
76+
);
77+
// Decided after the dev door (it reads that value) and before the wrap.
78+
expect(source.indexOf('let devArtifactDoor: any;')).toBeLessThan(gateAt);
79+
expect(gateAt).toBeLessThan(
80+
source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"),
81+
);
82+
});
83+
84+
it('the door names no artifact, primes nothing, and roots at the served app', () => {
85+
const ctor = constructionWindow();
86+
expect(ctor).not.toContain('artifactSource');
87+
expect(ctor).not.toContain('artifactWatch');
88+
expect(ctor).toContain("config: { bootstrap: 'lazy' },");
89+
expect(ctor).toContain('rootDir: hostRoot,');
90+
expect(ctor).toContain('watch: false,');
91+
});
92+
93+
it('the wrap\'s registrar is never keyed on this door: the four collections keep their one registrar', () => {
94+
// The ONLY spelling of the declaration is the dev door's.
95+
expect(count("securityMetadataRegistrar: 'artifact-door'")).toBe(1);
96+
expect(source).toContain("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},");
97+
expect(source).not.toMatch(/hostConfigMetadataDoor\s*\?/);
98+
});
99+
100+
it('the door is `kernel.use`d once, after the HTTP server plugin', () => {
101+
expect(count('await kernel.use(hostConfigMetadataDoor);')).toBe(1);
102+
expect(source.indexOf('await kernel.use(serverPlugin);'))
103+
.toBeLessThan(source.indexOf('await kernel.use(hostConfigMetadataDoor);'));
104+
});
105+
106+
it('behavioural: under production the door mounts no dev metadata-events route (CONTROL: development does)', async () => {
107+
// The composition's options, on a context with an HTTP server whose raw
108+
// app records every route mounted on it.
109+
const mounted = async (nodeEnv: string) => {
110+
const routes: string[] = [];
111+
const app = {
112+
get: (p: string) => { routes.push(`GET ${p}`); },
113+
post: (p: string) => { routes.push(`POST ${p}`); },
114+
};
115+
const services = new Map<string, unknown>([['http.server', { getRawApp: () => app }]]);
116+
const ctx: any = {
117+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
118+
registerService: (name: string, svc: unknown) => services.set(name, svc),
119+
getService: (name: string) => {
120+
if (!services.has(name)) throw new Error(`no ${name}`);
121+
return services.get(name);
122+
},
123+
hook: vi.fn(),
124+
trigger: vi.fn(),
125+
};
126+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-22989-hmr-'));
127+
const previous = process.env.NODE_ENV;
128+
const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir: root });
129+
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
130+
try {
131+
process.env.NODE_ENV = nodeEnv;
132+
await plugin.init(ctx);
133+
await plugin.start(ctx);
134+
return routes;
135+
} finally {
136+
if (previous === undefined) delete process.env.NODE_ENV;
137+
else process.env.NODE_ENV = previous;
138+
log.mockRestore();
139+
await plugin.destroy();
140+
fs.rmSync(root, { recursive: true, force: true });
141+
}
142+
};
143+
expect(await mounted('production')).toEqual([]);
144+
expect(await mounted('development')).toContain('GET /api/v1/dev/metadata-events');
145+
});
146+
147+
it('behavioural: `bootstrap: \'lazy\'` is load-bearing — the same root primes an item under eager and none under lazy', async () => {
148+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-22989-door-'));
149+
try {
150+
fs.mkdirSync(path.join(root, 'object'), { recursive: true });
151+
fs.writeFileSync(
152+
path.join(root, 'object', 'probe_door.object.json'),
153+
JSON.stringify({ name: 'probe_door', label: 'Probe Door', fields: {} }),
154+
);
155+
const primed = async (options: ConstructorParameters<typeof MetadataPlugin>[0]) => {
156+
const services = new Map<string, unknown>();
157+
const ctx: any = {
158+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
159+
registerService: (name: string, svc: unknown) => services.set(name, svc),
160+
getService: (name: string) => {
161+
if (!services.has(name)) throw new Error(`no ${name}`);
162+
return services.get(name);
163+
},
164+
hook: vi.fn(),
165+
trigger: vi.fn(),
166+
};
167+
const register = vi.spyOn(MetadataManager.prototype, 'register');
168+
const plugin = new MetadataPlugin(options);
169+
try {
170+
await plugin.init(ctx);
171+
await plugin.start(ctx);
172+
return register.mock.calls.map(([type, name]) => `${type}/${name}`);
173+
} finally {
174+
register.mockRestore();
175+
await plugin.destroy();
176+
}
177+
};
178+
// CONTROL: the default mode primes the item this root holds.
179+
expect(await primed({ watch: false, rootDir: root })).toEqual(['object/probe_door']);
180+
// The composition's options: nothing is primed.
181+
expect(await primed({ watch: false, config: { bootstrap: 'lazy' }, rootDir: root })).toEqual([]);
182+
// Attaching the repository writes nothing at boot.
183+
expect(fs.existsSync(path.join(root, '.objectstack', 'metadata'))).toBe(false);
184+
} finally {
185+
fs.rmSync(root, { recursive: true, force: true });
186+
}
187+
});
188+
});

‎packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,16 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac
117117
expect(source).toContain(
118118
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
119119
);
120-
// Exactly one MetadataPlugin is constructed in this file, and it is
121-
// that value — a second construction site is a second decision.
122-
expect(count('new MetadataPlugin(')).toBe(1);
123-
expect(source).toContain('devArtifactDoor = new MetadataPlugin({');
120+
// Exactly one ARTIFACT door is constructed in this file, and it is that
121+
// value — a second artifact-door construction site is a second
122+
// decision. The file's only other MetadataPlugin is the artifact-less
123+
// host-config door (#22989), which exists only when this one does not
124+
// and names no artifact; `serve-host-config-metadata-door.pin.test.ts`
125+
// holds it to that.
126+
expect(count('new MetadataPlugin(')).toBe(2);
127+
expect(count('devArtifactDoor = new MetadataPlugin({')).toBe(1);
128+
expect(count('hostConfigMetadataDoor = new MetadataPlugin({')).toBe(1);
129+
expect(count("artifactSource: { mode: 'local-file', path: hmrArtifactPath },")).toBe(1);
124130
// The path still comes from the supervisor's own channel, never from
125131
// `<cwd>/dist/objectstack.json` by accident.
126132
expect(source).toContain(
@@ -145,9 +151,11 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac
145151
expect(buildAt).toBeGreaterThan(-1);
146152
expect(gateAt).toBeLessThan(buildAt);
147153
// A missing artifact is not a silent downgrade: the warning names the
148-
// path, and says the wrap keeps the collections.
154+
// path, and says the wrap keeps the collections. What it names as lost
155+
// is the artifact RELOAD: since #22989 that boot composes the
156+
// artifact-less host-config door, which mounts the dev SSE endpoint.
149157
expect(source).toContain(
150-
'` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`',
158+
'` ⚠ Dev artifact reload not enabled: no compiled artifact at ${hmrArtifactPath}`',
151159
);
152160
expect(source).toContain('Stack-declared security metadata stays with the app wrap');
153161
});

0 commit comments

Comments
 (0)