Skip to content

Commit de24373

Browse files
fix(verify): bootStack composes the artifact-less MetadataPlugin os serve gives a host config, so declared apis endpoints answer under the harness (#23127)
Fixes #23088 Clause-②: no `bootStack` now fills its `metadata` slot the way `objectstack serve` fills it for a host configuration: `@objectstack/metadata`'s `MetadataPlugin` with no artifact, the composition PR #23078 gave `serve`. Before, the slot was the kernel's in-memory fallback, which has no `matchEndpoint`. So a stack's declared `apis` answered `404 ENDPOINT_NOT_FOUND` under the harness and their declared answer under `os serve`, and every such boot logged `AppPlugin`'s no-matcher warning. **Patch round 1 is applied at `0c232d83fb`.** The seat's ruling (comment 6136737187) extended the claimed surface by two files, `packages/verify/vitest.config.ts` and `packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts`, and ruled the open question **A**. Two comment sentences rode the same round. `check:test-source-alias` and the moved precondition are green. The PR stays a draft for the seat. The whole dogfood suite has one red left: a `main`-side interaction of PR #22900 with PR #22993 that reds with or without this PR's composition (measured below). ## What changes - **`packages/verify/src/harness.ts`, `bootStack`'s composition.** - It composes `new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir: hostRoot })`. These are the options `serve.ts` passes to `hostConfigMetadataDoor`, read at `:3335`-`:3347`: no `artifactSource`, no `artifactWatch`, `registerSystemObjects` left at its default. - The slot is right after `HonoServerPlugin` and before `AppPlugin`. That is `serve`'s order: HTTP server, then the door (`:3606`), then the app wrap and the app's plugins. As in `serve`, the `kernel.use` has no catch. - A `MetadataPlugin` the caller hands in `extraPlugins`, or one the app declares in its own `plugins`, takes the composed one's place. The check reads the two keys `serve`'s condition reads (`name === 'com.objectstack.metadata'`, or constructor name `MetadataPlugin`). In that case the door is never registered, so the kernel logs no supersede. - The docblock's composition sentence now names the plugin. Its exception list stays the two it names: the MCP endpoint and pinyin search. - **`packages/verify/package.json`** gains `@objectstack/metadata` (`workspace:*`). `@objectstack/metadata` depends on neither `verify` nor `runtime`, so this adds no cycle (`check:workspace-manifest-cycles` exit 0). `pnpm-lock.yaml` was regenerated by `pnpm install`, as was the merge of `main` that brought PR #22993's `verify` dependency line. - **`packages/verify/src/harness.metadata-door.test.ts`** (new; 2 cases, 2 boots). - The harness's own composition: the slot has `matchEndpoint`. The declared path answers `401 UNAUTHENTICATED` without a session and `200` with one. An undeclared path under the same mount answers `404 ENDPOINT_NOT_FOUND`. No no-matcher warning is logged. The case asserts all of these as one composite. - A caller's `MetadataPlugin` in `extraPlugins`: the `metadata` service is that instance's manager (by identity), no supersede warning names `com.objectstack.metadata`, and the declared path answers `401`. - Warnings are matched by their structured fields (`appId`, `plugin`), never by their prose. - **`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`**: the hand-added artifact-backed `MetadataPlugin` is removed, and its header is corrected. The reason it goes: - The file's own stated intent is "boot the showcase the way a deployment boots it". The showcase is a host configuration, and `os serve` / `os start` of it now compose the artifact-less door (PR #23078). So the harness's own composition is that deployment. The old hybrid (an artifact door, with `AppPlugin` still the registrar) was a boot no production composition runs. - The file now pins `bootStack` of the showcase with no `MetadataPlugin` in `extraPlugins`: 401 without a session and 200 with one, on `GET /api/v1/apps/showcase/tasks`. - The artifact door's `apis:` ingestion keeps its own dogfood proofs: `declarative-endpoint-policy` and `declarative-endpoint-anonymous-guest`, both green. - The stand-in artifact is still written through the build's own schema parse, so section 0 and the E7 publish-gate proof keep their subject. - **`.changeset/23088-verify-bootstack-metadata-plugin.md`**: `@objectstack/verify` `patch`, with `Clause-②: no`. - **`packages/verify/vitest.config.ts`** (patch round 1): the one anchored `@objectstack/metadata` alias that `check:test-source-alias` prescribes, beside the `core` entry. - **`packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts`** (patch round 1, option A): the precondition pins the composed door. The header drops the fallback framing and names the seam pin of the #21876 ordering repair. The federation cases are unchanged. - **`multi-package-artifact.dogfood.test.ts`, `external-validate-sees-runtime-save.dogfood.test.ts`** (patch round 1): one comment sentence each. ## Reach, before On `origin/main` `965a1bfc72`, `bootStack` of `examples/app-showcase` (`hostRoot` = the app, `OS_CLOUD_URL=off`): - `GET /api/v1/apps/showcase/tasks` answered `404 ENDPOINT_NOT_FOUND`, both without a session and with one. `POST …/inquiries/purge` answered the same 404. - The boot logged the warning once, with `{"appId":"com.example.showcase","count":2}`. - The slot was the fallback: constructor `Object`, `matchEndpoint` undefined, `__serviceInfo.status` `degraded`. ## Census, before and after How it was measured: a scratch script booted each app through the BUILT `@objectstack/verify`. BEFORE is the `965a1bfc72` build. AFTER is the build with only the composition commit (`a23a3b8cc6`) on top. Both ran under the shared verification lock. The script and its logs stayed in the scratchpad and are not committed. | item | showcase BEFORE | showcase AFTER | crm BEFORE | crm AFTER | |:--|:--|:--|:--|:--| | `metadata` occupant | fallback (`degraded`, no `matchEndpoint`) | `NodeMetadataManager`, `matchEndpoint` | fallback | `NodeMetadataManager` | | plugins on the kernel | 34 | 35 (+ `com.objectstack.metadata`) | 22 | 23 | | declared `GET …/tasks`, no session | 404 `ENDPOINT_NOT_FOUND` | **401 `UNAUTHENTICATED`** | n/a (no `apis`) | n/a | | the same, admin session | 404 | **200, 10 records** | n/a | n/a | | undeclared path under the mount | 404 | 404 | n/a | n/a | | no-matcher warning | 1 | **0** | 0 | 0 | | `/meta/TYPE`, 22 types: status, count, body hash | baseline | **22 of 22 equal** | baseline | **22 of 22 equal** | | security registrar | `Registered stack-declared security metadata` once, count 23 | same, once, 23 | once, 5 | once, 5 | | `position` / `permission` / `capability` / `sharing_rule` on `/meta` | 16 / 17 / 11 / 2 | equal bodies | — | equal bodies | | registry objects (`registerSystemObjects`) | 104 | 104 (the door logs `Registered system metadata objects` once, and the set does not grow) | 79 | 79 | | `sys_metadata` / `_history` / `_commit` / `_audit` rows after boot | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 | 0 each | 0 each | | `hostRoot/.objectstack/metadata` written | no | no (`FileSystemRepository attached`, writes nothing) | no | no | | `GET /api/v1/discovery`, `services.metadata.status` | `degraded` | **`available`** | `degraded` | **`available`** | | `GET /api/v1/dev/metadata-events` | 404 | **200 `text/event-stream`** (development posture, as under `os dev`) | 404 | **200** | | verify CRUD family summary | 24 objects / 15 verified / 1 needs fixture / 8 skipped / 0 gaps | identical | 6 / 4 / 1 / 1 / 0 | identical | | verify RLS family summary | — | — | 6 objects, 6 probe-blocked | identical | | boot, median of 7 interleaved boots (shared box) | 4750 ms | 4745 ms | — | — | The four security collections keep one registrar, `AppPlugin` (`'app-plugin'`). The door reads none of the stack: `lazy bootstrap` is logged once, and no artifact is named. The CRM row is the CONTROL: a stack with no `apis` gives the same proof answers before and after. Across the whole dogfood suite (next section), the two answers that move, discovery's `metadata` status and the development-only HMR route, are read by no pin. ## The dogfood suite, whole `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2`, under the lock: - **at `0c232d83fb` (patch round 1, after merging `main`):** `Test Files 1 failed | 258 passed | 1 skipped (260)`, `Tests 2121 passed | 18 skipped (2139)`. - The one failed file is `mail-message-pool-read-gate.dogfood.test.ts`. It fails in its `beforeAll` (`fixture permission set mail_pool_22870_read seeded: expected undefined to be truthy`), so its 9 tests are skipped. **That red is not this PR's**, as measured below. - Every file this PR touches is green: `showcase-declarative-endpoints` 17/17, `external-validate-start-ordering` 3/3, `external-validate-sees-runtime-save` 3/3 and `multi-package-artifact` 9/9. The artifact-door proofs are green too: `declarative-endpoint-policy` 8/8 and `declarative-endpoint-anonymous-guest` 6/6. - The no-matcher warning appears 0 times in the whole run's log. - **Earlier, before the ruling:** at `d072256c13` `1 failed | 257 passed | 1 skipped (259)` files, and at `96eaff46d0` `1 failed | 256 passed | 1 skipped (258)` files. Both times the one red was the precondition that patch round 1 moved. **The `mail-message-pool-read-gate` red, measured on this tree with and without the composition.** Both legs ran inside the lock, with a trap-armed restore. - With the door, as built: `Test Files 1 failed (1)`, `Tests 9 skipped (9)`, the same assertion. - With the door removed (blob `704910003254` → `5b13a59a72e7`, verify rebuilt, the marker preflight-absent from all 6 built files): the same `1 failed (1)`, `9 skipped (9)`, the same assertion. - Restore: blob equal to HEAD, rebuilt, the marker present again, and `git status --porcelain` empty. - The file was green in this branch's run at `82ccd6962d`. Between that head and this one, `main` landed PR #22900 ("no seeder or materializer writes a `sys_permission_set` row; the boot seeds no row for a declared set"), and this file's fixture reads such a row. So it is a `main`-side interaction of PR #22900 with PR #22993, which reds `main`'s full battery whatever this PR does. It is reported to the seat, not fixed here. ## Patch round 1: the seat's ruling (comment 6136737187) The seat extended the claimed surface by two files and ruled the open question **A**. Each item is its own commit. 1. **`packages/verify/vitest.config.ts`** (`3867e0a312`): one anchored alias line beside the `core` entry, `{ find: /^@objectstack\/metadata$/, replacement: path.resolve(HERE, '../metadata/src/index.ts') }`. That is exactly the remedy `check:test-source-alias` prescribed, and the gate now prints `check-test-source-alias OK`. 2. **`packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts`** (`8b2647eb19`), option A: - The precondition case now asserts the composed door's identity and its matcher: `kernel.hasPlugin('com.objectstack.metadata')`, the `metadata` service `toBeInstanceOf(MetadataManager)` (`@objectstack/metadata`), and `matchEndpoint` a function. - The header drops the "`objectstack start` shape" and "the verify harness composes no metadata plugin" framing. It names the seam pin that holds the #21876 init-ordering fix: `packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`. - The `describe` title follows the header. The two federation cases are unchanged: the diff has no line inside either of them. - It uses no stand-in plugin and deletes nothing. - The pin bears weight: with the door removed, it reds at `hasPlugin` (`expected false to be true`), and the two federation cases stay green (ablation leg 3 below). 3. **Two comment sentences** (`cf2065001e`): `multi-package-artifact.dogfood.test.ts` about `:163` and `external-validate-sees-runtime-save.dogfood.test.ts` about `:24`. A non-comment-line filter over that commit's diff counts **0** changed lines outside `//` comments. `main` was then merged (`480dcb717e`, and again `0c232d83fb` when it moved during the first lock wait). **The new head is `0c232d83fb`.** All the measurements below are at it. ## Ablations Each leg ran inside `scripts/pm/os-verify-lock.sh` through `node scripts/ablation-replace.mjs`. The anchor was `await kernel.use(new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir: hostRoot }));`, x1 → x0. Each restore was trap-armed and proved by the blob equal to HEAD (`704910003254`) and an empty `git diff HEAD`. - **Leg 1: the verify test, source-resolved.** It ran in wrap mode, with blob `704910003254` → `082b863f6fb3`. Result: `Tests 1 failed | 1 passed (2)`. - The composite case received `matcher: "undefined"`, `anonymous: { status: 404, code: "ENDPOINT_NOT_FOUND" }` and `signedIn: 404`. - It also received one matcher warning, carrying `{"appId":"com.example.mdoor","count":1}`. - The caller's-own-plugin case stayed green. - **Leg 2: the showcase dogfood pin, which resolves `verify` through `dist/`.** The mutation was `void MetadataPlugin; // ablation-23088…`, blob → `8efaf19cc8cf`. Then `pnpm --filter @objectstack/verify build`, and `ablation-dist-preflight.mjs @objectstack/verify 'kernel.use(new MetadataPlugin(' --absent` read the marker as absent from all 6 built files. - Result: `Tests 7 failed | 10 passed (17)`. The 401 cases received `{"code":"ENDPOINT_NOT_FOUND"}` 404, and the run logged the showcase's warning once. - Restore: rebuild, and the preflight found the marker present again in `dist/index.js`, with the tree clean. - A first attempt at this leg never ran. Its plain comment replacement left the import unused, and the DTS build refused that (`TS6133`). The trap restored the file and the rebuilt dist. The successful run above is the second attempt, with the import kept referenced. - **Leg 3 (patch round 1): the moved precondition, which resolves `verify` through `dist/`.** - Mutation, the same anchor: blob `704910003254` → `8efaf19cc8cf`. Then a rebuild, and the marker read absent from all 6 built files. - CONTROL run with the door, as built: `Test Files 1 passed (1)`, `Tests 3 passed (3)`. Without the door: `Tests 1 failed | 2 passed (3)`. The precondition reds at `hasPlugin` (`expected false to be true`), and both federation cases stay green. - Restore: blob equal to HEAD, rebuilt, the marker present again, and `git status --porcelain` empty. ## Tests at `0c232d83fb` All of these ran through `scripts/pm/os-verify-lock.sh`, under one acquisition of slot `dev-23088-test`. Each exit code was captured before any pipe. - Build of the closure: exit 0. - `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2`: `Test Files 37 passed (37)`, `Tests 291 passed (291)`. - `pnpm --filter @objectstack/verify typecheck`: exit 0, `check:test-typecheck: OK`. - `pnpm check:test-source-alias`: exit 0, `check-test-source-alias OK — 74 packages with tests scanned`. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - The whole dogfood suite: exit 1, the one `main`-side red above. - At `82ccd6962d`, before the ruling, `@objectstack/cli` `verify-*` (7 files) gave `Test Files 7 passed (7)`, `Tests 21 passed (21)`. Patch round 1 touches no file those tests load. ## Gates at `0c232d83fb` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths: 80 commands, over a change set of 10 paths vs merge base `f931e765b` (committed 10, working tree 0). Each command ran with its exit code captured before any pipe. - **All 80 exit 0.** - `--ran` printed `Run reconciliation — 80 derived, 80 run, 0 NOT-MEASURED, 0 UNRUN` and `a DERIVED zero — all 80 recorded an exit code and none of them is 3`. - After this run, `origin/main` moved 5 commits to `d206d08cab`. None of them touches this diff's paths, `packages/verify` or `packages/qa/dogfood`. ## Lint, a proven narrowing at `0c232d83fb` The repo-wide `pnpm lint` is CI's. 1. Population, from eslint's own config: `--no-inline-config --print-config` resolves rules for all 7 changed TypeScript files (6 for `harness.ts` and `vitest.config.ts`, 5 for each test file). It resolves none for the changeset, `package.json` or `pnpm-lock.yaml`. 2. Count, from `--format json`: `files=7 errors=0 warnings=0`, exit 0. 3. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project` and no `projectService`: 0 hits). Its only file reads are two baseline JSONs this diff does not touch. ## Acceptance notes - The four security collections, `/meta`, persistence and the object set are unmoved (census). Two answers of a test boot move with the slot: discovery's `metadata` status, and the development-only HMR route. The changeset says so. - **`main`-side red, not this PR's:** `mail-message-pool-read-gate.dogfood.test.ts` (PR #22993) reds after PR #22900 on any tree, measured with and without this PR's composition (above). It is reported to the seat for the main-red convention. - `packages/services/service-datasource/src/plugin.ts` about `:83` says "`objectstack start` composes no metadata plugin". That has been stale for a host config since PR #23078. Noted by the seat, not filed. - 17 dogfood files `chdir` into `examples/app-showcase` and leave `.objectstack/data/showcase_external.db` there (gitignored). This is pre-existing. Noted by the seat, not filed. - No governed surface is touched. The diff is 10 files, +256 / −56. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54f062b commit de24373

10 files changed

Lines changed: 256 additions & 56 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/verify': patch
3+
---
4+
5+
fix(verify): `bootStack` composes the artifact-less `MetadataPlugin` that `objectstack serve` gives a host configuration, so a stack's declared `apis` endpoints answer under the harness as they answer under `serve` (#23088)
6+
7+
Clause-②: no
8+
9+
`bootStack`'s `metadata` slot was the kernel's in-memory fallback, which has no endpoint matcher (`matchEndpoint`). So every `apis` endpoint a stack declared answered `404 ENDPOINT_NOT_FOUND` in a test boot while `objectstack serve` of the same stack served it, and the boot logged `AppPlugin`'s "no endpoint matcher" warning. The harness now composes `@objectstack/metadata`'s `MetadataPlugin` the way `serve` composes it for a host configuration: no artifact, `bootstrap: 'lazy'`, and `rootDir` set to `BootOptions.hostRoot`. It reads none of the stack, so `AppPlugin` stays the single registrar of the stack's metadata.
10+
11+
- A declared endpoint answers as it does under `serve`: `401` without a session where it is `authRequired`, and its declared answer with one. An undeclared path under the same mount keeps the bare `404`.
12+
- A `MetadataPlugin` you pass in `BootOptions.extraPlugins`, or one your app declares in its own `plugins`, takes the composed one's place. Pass your own to boot an artifact-backed one.
13+
- Two other answers of a test boot move with the slot: `GET /api/v1/discovery` reports the `metadata` service as `available` instead of `degraded`, and `GET /api/v1/dev/metadata-events` (the development-only metadata reload stream, mounted because `bootStack` boots in the development posture) answers `200` instead of `404`, as under `objectstack dev`. The `GET /api/v1/meta/TYPE` answers are unchanged, measured over 22 types on the showcase and CRM examples.

‎packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
// an object saved through `PUT /meta/object/:name` is listed with a real
2222
// verdict (it declares a column the remote lacks, and exactly that column is
2323
// reported); an object imported through `…/external/tables/:remote/import` is
24-
// listed beside them. The verify harness composes no metadata plugin, so its
25-
// `metadata` service is the kernel's fallback registered after the
26-
// federation service's `init()`, as on `objectstack start`; the service reads
27-
// it when validation runs ([#21876]), so each row here is a real comparison.
24+
// listed beside them. The verify harness composes `@objectstack/metadata`'s
25+
// artifact-less `MetadataPlugin`, as `objectstack serve` does for a host
26+
// configuration (#23088); the service resolves what it reads when validation
27+
// runs ([#21876]), so each row here is a real comparison.
2828
// The seam pins sit beside the plugin
2929
// (`packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`).
3030
//

‎packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts‎

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// [#21876] On a composition with no metadata plugin — the `objectstack start`
4-
// shape — the federation service compares every federated object.
3+
// [#21876] The federation service compares every federated object, on the
4+
// composition the verify harness boots.
55
//
66
// ## What was broken
77
//
88
// `ExternalDatasourceServicePlugin.init()` read the `metadata` service once and
9-
// kept the answer. `objectstack start` composes no metadata plugin: its
10-
// `metadata` service is the kernel's in-memory fallback, registered after every
11-
// plugin's `init()`, just before the start phase. So the federation service
12-
// kept "no metadata service" for the life of the process. Measured on the
13-
// showcase under `objectstack start`: `POST /datasources/:name/external/validate`
14-
// answered `ok: true` with no rows, and the boot gate logged "all federated
15-
// objects match their remote schema" with `objects: 0` — although two
16-
// federated objects exist, and one of them could have drifted. Under
17-
// `objectstack dev` (which composes the metadata plugin before this one) both
18-
// objects were compared. The service now resolves `metadata` when it is used.
9+
// kept the answer. On a composition with no metadata plugin, that service is
10+
// the kernel's in-memory fallback, registered after every plugin's `init()`,
11+
// just before the start phase. So the federation service kept "no metadata
12+
// service" for the life of the process. Measured on the showcase at the time:
13+
// `POST /datasources/:name/external/validate` answered `ok: true` with no rows,
14+
// and the boot gate logged "all federated objects match their remote schema"
15+
// with `objects: 0` — although two federated objects exist, and one of them
16+
// could have drifted. The service now resolves `metadata` when it is used.
17+
// That ordering fix is pinned at its seam, in
18+
// `packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`.
1919
//
2020
// ## Why this composition
2121
//
22-
// The verify harness composes no metadata plugin either, so its `metadata`
23-
// service is the same kernel fallback, registered at the same moment, as on
24-
// `objectstack start`. The first case asserts that, so a harness that one day
25-
// composes a metadata plugin cannot turn this file into a silent pass. The
26-
// harness does not mount the federation service, so this file mounts it, as
22+
// The verify harness composes `@objectstack/metadata`'s `MetadataPlugin`, the
23+
// artifact-less one `objectstack serve` and `start` give a host configuration
24+
// such as the showcase (#23088). The first case asserts that occupant: the
25+
// plugin is registered, and the `metadata` service is its manager, with the
26+
// endpoint matcher. So a change to what the harness composes cannot pass
27+
// through this file unnoticed. The harness does not mount the federation
28+
// service, so this file mounts it, as
2729
// `objectstack dev` and `start` do. It does not mount the boot gate either
2830
// (`createExternalValidationPlugin`, `@objectstack/runtime`): the gate's input
2931
// is the federation service's whole-farm sweep, `validateAll()`, which the
@@ -41,6 +43,7 @@
4143
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4244
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
4345
import { ExternalDatasourceServicePlugin, resolveSqliteDriver } from '@objectstack/service-datasource';
46+
import { MetadataManager } from '@objectstack/metadata';
4447
import { type VerifyStack } from '@objectstack/verify';
4548
import { bootShowcase } from './showcase-boot.js';
4649
import { mkdtempSync, rmSync } from 'node:fs';
@@ -82,7 +85,7 @@ async function driftRemoteCustomers(): Promise<void> {
8285
}
8386
}
8487

85-
describe('federation on a composition with no metadata plugin (the objectstack start shape)', () => {
88+
describe('federation on the harness composition, which holds the metadata plugin', () => {
8689
let stack: VerifyStack;
8790
let token: string;
8891
let prevCwd: string;
@@ -107,10 +110,14 @@ describe('federation on a composition with no metadata plugin (the objectstack s
107110
if (dir) rmSync(dir, { recursive: true, force: true });
108111
});
109112

110-
it('precondition: the metadata service is the kernel\'s in-memory fallback, as on objectstack start', () => {
111-
const metadata = stack.kernel.getService<{ __serviceInfo?: { status?: string; message?: string } }>('metadata');
112-
expect(metadata.__serviceInfo?.status).toBe('degraded');
113-
expect(metadata.__serviceInfo?.message).toMatch(/^In-memory metadata registry/);
113+
it('precondition: the metadata service is the composed MetadataPlugin\'s manager, with its endpoint matcher', () => {
114+
// The plugin is registered under its own name, and the `metadata` slot is
115+
// its `MetadataManager`, not the kernel's in-memory fallback (a plain
116+
// object with no matcher).
117+
expect(stack.kernel.hasPlugin('com.objectstack.metadata')).toBe(true);
118+
const metadata = stack.kernel.getService<{ matchEndpoint?: unknown }>('metadata');
119+
expect(metadata).toBeInstanceOf(MetadataManager);
120+
expect(typeof metadata.matchEndpoint).toBe('function');
114121
});
115122

116123
it('validate compares each federated object, and reports the drifted column', async () => {

‎packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,9 @@ describe('dogfood: one artifact, two co-owning packages (ADR-0130 D4)', () => {
160160
// #14599 — the METADATA door, which the block above does not reach
161161
// ───────────────────────────────────────────────────────────────────────────
162162
//
163-
// `bootStack` registers `AppPlugin` and no `MetadataPlugin`, so everything
164-
// above is measured on the ObjectQL registry alone. The defect this block pins
163+
// `bootStack` registers `AppPlugin` and a `MetadataPlugin` with no artifact,
164+
// which loads none of the stack (#23088), so everything above is measured on
165+
// the ObjectQL registry alone. The defect this block pins
165166
// lives in the OTHER reader — `MetadataPlugin._parseAndRegisterArtifact`, the
166167
// door a real `objectstack dev` / `objectstack serve` boot loads the artifact
167168
// through — and the two only disagree once both are present. That is why this

‎packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts‎

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
// unit-tested by calling `dispatch()` directly, which is why nobody noticed
1414
// that no route ever mounted the paths it claimed to serve.
1515
//
16-
// So this file stubs NOTHING. It boots the real showcase app, through the real
17-
// artifact ingestion path (`MetadataPlugin` + a `local-file` artifact — the
18-
// same path `objectstack dev` / `serve` take), over the real Hono transport,
19-
// and asks the questions a caller asks:
16+
// So this file stubs NOTHING. It boots the real showcase app as
17+
// `objectstack serve` boots this host configuration — the harness's own
18+
// artifact-less `MetadataPlugin`, with `AppPlugin` registering the declared
19+
// endpoints — over the real Hono transport, and asks the questions a caller asks:
2020
//
2121
// 1. does a declared endpoint answer at all, and with the SAME body the
2222
// built-in route gives for the same operation (#5040 §4's red line);
@@ -53,7 +53,6 @@ import { fileURLToPath } from 'node:url';
5353
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
5454
import { type VerifyStack } from '@objectstack/verify';
5555
import { bootShowcase } from './showcase-boot.js';
56-
import { MetadataPlugin } from '@objectstack/metadata';
5756
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
5857
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
5958
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
@@ -82,26 +81,30 @@ let tempDir: string;
8281
let prevCwd: string;
8382
let stack: VerifyStack;
8483
let adminToken: string;
85-
/** Read back off disk — what `MetadataPlugin` actually ingested, not what we meant to write. */
84+
/** Read back off disk — what the build-shaped writer actually wrote, not what we meant to write. */
8685
let artifactOnDisk: Record<string, unknown>;
8786

8887
/**
8988
* Boot the showcase the way a deployment boots it.
9089
*
91-
* `@objectstack/verify`'s lean harness composes no `MetadataPlugin` — its
92-
* proofs are data/authorization ones that read metadata straight off the
93-
* config. A declared endpoint cannot be reached without one: `matchEndpoint`
94-
* is `IMetadataService`'s member, and `NodeMetadataManager` is its only
95-
* implementation. So this file supplies it through the harness's `extraPlugins`
96-
* seam, pointed at an artifact written from the showcase's own stack
97-
* definition — the SAME `artifactSource: { mode: 'local-file' }` ingestion
98-
* `createStandaloneStack` uses, which is what maps `apis:` → `api` metadata
99-
* items (`ARTIFACT_FIELD_TO_TYPE` in `packages/metadata/src/plugin.ts`).
90+
* A declared endpoint cannot be reached without a `MetadataPlugin`:
91+
* `matchEndpoint` is `IMetadataService`'s member, and `NodeMetadataManager` is
92+
* its only implementation. `@objectstack/verify`'s `bootStack` composes the one
93+
* `objectstack serve` composes for a host configuration such as this one — no
94+
* artifact, so `AppPlugin` registers the showcase's `apis:` as `api` items
95+
* (#23088). This file used to hand in an artifact-backed `MetadataPlugin`
96+
* through `extraPlugins`, because the harness composed none; it no longer does,
97+
* so the endpoints below are the ones a deployment of this configuration
98+
* serves, booted with nothing the harness does not compose itself. The
99+
* artifact door's `apis:` → `api` ingestion (`ARTIFACT_FIELD_TO_TYPE` in
100+
* `packages/metadata/src/plugin.ts`) keeps its own proofs
101+
* (`declarative-endpoint-policy.dogfood.test.ts`,
102+
* `declarative-endpoint-anonymous-guest.dogfood.test.ts`).
100103
*
101-
* Writing the artifact through `ObjectStackDefinitionSchema` — which the
102-
* ingest performs on load — means this boot ALSO re-proves the E7 publish
103-
* gates accept the restored declarations. A declaration that could not publish
104-
* could not be ingested here either.
104+
* The artifact is still written, through the build's own
105+
* `ObjectStackDefinitionSchema` parse, so this file still re-proves the E7
106+
* publish gates accept the restored declarations, and the cases in section 0
107+
* read it back.
105108
*/
106109
beforeAll(async () => {
107110
prevCwd = process.cwd();
@@ -137,13 +140,6 @@ beforeAll(async () => {
137140
new ConnectorRestPlugin(),
138141
new ConnectorOpenApiPlugin(),
139142
new ConnectorMcpPlugin({ declarativeStdio: ['node'] }),
140-
new MetadataPlugin({
141-
rootDir: tempDir,
142-
watch: false,
143-
artifactWatch: false,
144-
registerSystemObjects: false,
145-
artifactSource: { mode: 'local-file', path: artifactPath },
146-
}),
147143
],
148144
});
149145
adminToken = await stack.signIn();
@@ -156,7 +152,7 @@ afterAll(async () => {
156152
});
157153

158154
// ============================================================================
159-
// 0. The artifact this boot ingests is the shape the build writes (commit c39a911ae)
155+
// 0. The showcase's stand-in artifact is the shape the build writes (commit c39a911ae)
160156
// ============================================================================
161157

162158
describe('[#6293] the stand-in artifact carries what a built one carries', () => {

‎packages/verify/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dependencies": {
2929
"@objectstack/core": "workspace:*",
3030
"@objectstack/mcp": "workspace:*",
31+
"@objectstack/metadata": "workspace:*",
3132
"@objectstack/objectql": "workspace:*",
3233
"@objectstack/platform-objects": "workspace:*",
3334
"@objectstack/plugin-approvals": "workspace:*",
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23088: `bootStack` fills its `metadata` slot the way `objectstack serve`
5+
* fills it for a host configuration (#22989): `@objectstack/metadata`'s
6+
* `MetadataPlugin` with no artifact. Before, the slot was the kernel's
7+
* in-memory fallback, which has no `matchEndpoint`, so every `apis` endpoint a
8+
* stack declared answered `404 ENDPOINT_NOT_FOUND` under the harness while
9+
* `serve` served it, and the boot logged `AppPlugin`'s no-matcher warning.
10+
*
11+
* Two boots:
12+
*
13+
* - the harness's own composition (nothing in `extraPlugins`): the declared
14+
* path answers 401 without a session and 200 with one, an undeclared path
15+
* under the same mount keeps the bare 404, and the no-matcher warning is
16+
* not logged;
17+
* - a caller's own `MetadataPlugin` in `extraPlugins`: that instance holds
18+
* the slot, and the composed one is never registered (no supersede).
19+
*
20+
* Warnings are identified by their named subjects and structured fields (the
21+
* app id, the remedy plugin, the superseded plugin's name), never by prose.
22+
*/
23+
24+
import { afterEach, describe, expect, it, vi } from 'vitest';
25+
import { MetadataPlugin } from '@objectstack/metadata';
26+
import { defineStack } from '@objectstack/spec';
27+
import { bootStack, type VerifyStack } from './harness.js';
28+
29+
const BOOT_TIMEOUT = 120_000;
30+
const APP_ID = 'com.example.mdoor';
31+
const FEED = '/apps/mdoor/notes';
32+
33+
/** A fresh configuration per boot: the instance rule keys on identity. */
34+
const stackWithApi = () =>
35+
defineStack({
36+
manifest: { id: APP_ID, name: 'Metadata Door', version: '1.0.0', type: 'app', namespace: 'mdoor' },
37+
objects: [
38+
{
39+
name: 'mdoor_note',
40+
label: 'Note',
41+
pluralLabel: 'Notes',
42+
sharingModel: 'public_read_write',
43+
fields: { title: { name: 'title', type: 'text', label: 'Title' } },
44+
},
45+
],
46+
apis: [
47+
{
48+
name: 'mdoor_note_feed',
49+
path: `/api/v1${FEED}`,
50+
method: 'GET',
51+
summary: 'Note feed',
52+
type: 'object_operation',
53+
objectParams: { object: 'mdoor_note', operation: 'find' },
54+
authRequired: true,
55+
},
56+
],
57+
} as never) as unknown as Record<string, unknown>;
58+
59+
/** Every structured kernel-log line the boot wrote to stdout, ANSI stripped. */
60+
async function bootCapturing(config: unknown, extraPlugins?: unknown[]): Promise<{ stack: VerifyStack; lines: string[] }> {
61+
const lines: string[] = [];
62+
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
63+
lines.push(...String(chunk).replace(/\u001b\[[0-9;]*m/g, '').split('\n'));
64+
return true;
65+
}) as never);
66+
try {
67+
const stack = await bootStack(config, extraPlugins ? { extraPlugins } : {});
68+
return { stack, lines };
69+
} finally {
70+
spy.mockRestore();
71+
}
72+
}
73+
74+
/** The structured fields a kernel `WARN` line carries, or undefined for any other line. */
75+
function warnFields(line: string): Record<string, unknown> | undefined {
76+
if (!/ WARN /.test(line)) return undefined;
77+
const at = line.indexOf(' {');
78+
if (at < 0) return undefined;
79+
try {
80+
return JSON.parse(line.slice(at + 1)) as Record<string, unknown>;
81+
} catch {
82+
return undefined;
83+
}
84+
}
85+
86+
let stack: VerifyStack | undefined;
87+
afterEach(async () => {
88+
await stack?.stop();
89+
stack = undefined;
90+
});
91+
92+
describe('#23088 bootStack serves a declared endpoint, as objectstack serve does', () => {
93+
it('composes a matcher, so the declared path is gated and served, and no matcher warning is logged', async () => {
94+
const booted = await bootCapturing(stackWithApi());
95+
stack = booted.stack;
96+
const token = await stack.signIn();
97+
98+
const answer = async (res: Response) => ({
99+
status: res.status,
100+
code: ((await res.json()) as { error?: { code?: string } }).error?.code,
101+
});
102+
const observed = {
103+
// The slot carries the endpoint matcher.
104+
matcher: typeof stack.kernel.getService<{ matchEndpoint?: unknown }>('metadata').matchEndpoint,
105+
anonymous: await answer(await stack.api(FEED, { method: 'GET' })),
106+
signedIn: (await stack.apiAs(token, 'GET', FEED)).status,
107+
// CONTROL: an undeclared path under the same mount keeps the bare 404.
108+
undeclared: await answer(await stack.apiAs(token, 'GET', '/apps/mdoor/no-such-endpoint')),
109+
matcherWarnings: booted.lines.filter((line) => {
110+
const fields = warnFields(line);
111+
return fields?.appId === APP_ID && line.includes('MetadataPlugin');
112+
}),
113+
};
114+
// One composite, so a failure shows every observation at once.
115+
expect(observed).toEqual({
116+
matcher: 'function',
117+
anonymous: { status: 401, code: 'UNAUTHENTICATED' },
118+
signedIn: 200,
119+
undeclared: { status: 404, code: 'ENDPOINT_NOT_FOUND' },
120+
matcherWarnings: [],
121+
});
122+
}, BOOT_TIMEOUT);
123+
124+
it("a caller's own MetadataPlugin in extraPlugins holds the slot, and the composed one is never registered", async () => {
125+
const own = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } });
126+
const booted = await bootCapturing(stackWithApi(), [own]);
127+
stack = booted.stack;
128+
129+
// The slot is the caller's instance's manager — by identity.
130+
const ownManager = (own as unknown as { manager: unknown }).manager;
131+
expect(stack.kernel.getService('metadata')).toBe(ownManager);
132+
133+
// Composed and then superseded would log the kernel's supersede warning
134+
// for this name; not composed logs none.
135+
const superseded = booted.lines.filter((line) => warnFields(line)?.plugin === own.name);
136+
expect(superseded).toEqual([]);
137+
138+
// And the caller's instance serves the declared path.
139+
const anonymous = await stack.api(FEED, { method: 'GET' });
140+
expect(anonymous.status).toBe(401);
141+
}, BOOT_TIMEOUT);
142+
});

0 commit comments

Comments
 (0)