Skip to content

fix(cli,runtime): a host-config os serve composes a MetadataPlugin, so its declared apis are served; a matcher-less composition warns once - #23078

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22989-s2-host-config-metadata-door
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22989-s2-host-config-metadata-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

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.used 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:

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 feat(spec)!: retire an email template's category and isSystem — the grouping and the built-in flag nothing read (#22876) #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 fix(runtime): the config door registers a stack's declared api items into the metadata service #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.used 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.
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

… matcher-less composition warns

WIP: composition (A) and warning (B); tests follow.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…e production dev-route leg in-process

The built-entry population of check-cli-test-child-env is a pinned set, so
the boot legs use the tsx source entry and the production-only half is
pinned on the composed door itself.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…t-preparation parity pin

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 4 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/dev/metadata-events (route, a path literal in a comment in Serve))
  • content/docs/kernel/services-checklist.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/capabilities.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via AppPlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via AppPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 31 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 275ad4229a791f31ec90da4a14621cd54a0dad02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0e9a1a4dc68bdf047b53f5940c8df45cacd7c0fc — the merge of head 66e7f58fd5e181eeffa28a1b9aa8d0d49ef6a176 into base 275ad4229a791f31ec90da4a14621cd54a0dad02, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0e9a1a4dc68bdf047b53f5940c8df45cacd7c0fc && git checkout 0e9a1a4dc68bdf047b53f5940c8df45cacd7c0fc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 275ad4229a791f31ec90da4a14621cd54a0dad02 66e7f58fd5e181eeffa28a1b9aa8d0d49ef6a176 && git checkout -B drift-repro 275ad4229a791f31ec90da4a14621cd54a0dad02 && git merge --no-ff 66e7f58fd5e181eeffa28a1b9aa8d0d49ef6a176

node scripts/docs-audit/affected-docs.mjs --json 275ad4229a791f31ec90da4a14621cd54a0dad02

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 275ad4229a791f31ec90da4a14621cd54a0dad02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 66e7f58fd5e181eeffa28a1b9aa8d0d49ef6a176
Local-runs: none

Read: card #22989, its body and all ten comments (ruling 6125390666, slice-2 claim 6126676359, dev report 6129082934; the seat's ACCEPT 6129114437 read as a claim, never a verdict); PR #23078's body and file list; the net diff against merge-base 0a29bec6a3 (8 files, +753 / -27); the source at the head for every file the census names; the check-runs on the head. Reading time 2026-10-12T17:35Z.

Check-runs on the head, latest per name: 31 success, 8 skipped (roster rows: Auto Label, Build Docs, Check PR Size, Console Pin Gate, the dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 1 queued at reading time: the aggregate Test Core job, whose six shards (1/6 through 6/6) had each concluded success at the same reading. Nothing concluded red. Judged on what concluded; the governed-surface, Part-of, card-claim and single-writer guards all concluded success.

① Derived judgments

A, the composition in packages/cli/src/commands/serve.ts. hostConfigMetadataDoor is constructed under exactly three conditions, !devArtifactDoor, isHostConfig(config), and no plugin in plugins named com.objectstack.metadata or constructed as MetadataPlugin, and kernel.used once, after serverPlugin, at the dev door's site. Read against the boot-mode dispatch (useArtifactFallback || shouldBootWithLibrary(config)), the boots it reaches are: (a) a non-dev os serve of a host config; (b) os start beside a host config: start.ts compiles the config and hands the path on OS_INTERNAL_ARTIFACT_PATH, and in the child cwdConfigJoinsBoot / isConfigCompiledArtifact answers true for the config's own dist/objectstack.json, so the host module loads and this branch runs, supported by source, HTTP NOT MEASURED; (c) and (d) os serve --dev of a host config with no artifact on disk, since the dev door composes only when its bytes exist; (e) --no-server, since flags.server false keeps the dev door out and the door then mounts no routes for want of an HTTP server. Not reached: the dev artifact door's boots (!devArtifactDoor); every library boot and the artifact fallback, because createStandaloneStack and createDefaultHostConfig put a com.objectstack.metadata plugin into the merged plugins and the third condition refuses; OS_MODE=off and bootMode: 'off' bare configs, because isHostConfig is false. RIGHT: every reached boot is an os serve host-config boot whose composition holds no MetadataPlugin, the ruling's A verbatim, and no boot the ruling names is missed. The os start item is a measurement gap, not a reach gap: the child executes this same file, under the production posture the census measured by hand on the showcase. Recommended, not owed here: one HTTP reading of os start beside a host config in a later slice.

The slot's new occupant on a host-config production boot, against the ten census answers, each read against the source it names:

  1. Loader: bootstrap: 'lazy' skips _loadFromFileSystem; eager with no artifactSource scans rootDir/TYPE/ (plugin.ts start). Pinned with an eager CONTROL that primes an item lazy does not. RIGHT.
  2. Repository: FileSystemRepository attaches under rootDir/.objectstack/metadata with disableWatch in every mode but artifact-only, inside a try/catch; rootDir defaults to process.cwd() when omitted, so hostRoot is load-bearing. Pinned (rootDir: hostRoot,; no .objectstack/metadata written). RIGHT.
  3. Persistence: MetadataPlugin.init registers com.objectstack.metadata-objects through the manifest service; metadata-protocol's assembly already registers the same four objects on environmentId === undefined boots and calls the second registration idempotent and harmless in its own comment. setDatabaseDriver / setDataEngine: zero non-test callers at the head. RIGHT; the identical 108-table list is the measurement.
  4. /meta and openapi.json: /meta reads the ObjectQL registry, so the fifteen types the service lacks were served on BASE and still are; openapi.json and /meta/api take the matcher when matchEndpoint exists and log no endpoint matcher is reachable otherwise (rest-server.ts, domains/meta.ts). Listing the two declared paths is the fix's intended face. RIGHT.
  5. Cluster bridge: lane 1 (service-cluster/src/metadata-cluster-bridge-plugin.ts) warns when no attachClusterPubSub, debug-skips on an in-process driver, attaches otherwise; the manager publishes metadata.changed from notifyWatchers, which register() calls unless notify: false and registerInMemory() never calls. The boot-time writes on the showcase are registerInMemory (the wrap) and ObjectQL's object bridge with { notify: false }, so zero publishes at boot. RIGHT. One thing the census did not name: that bridge (bridgeObjectsToMetadataService) now lands its objects on the manager through register() instead of the fallback. No loader save (the filesystem loader's contract is file:; the save loop takes datasource: with write only), no notify; its realtime fan-out is gated on a bridged realtime service and was not measured. The same path every library boot takes; a census omission, not a defect.
  6. Boot cost: 7 interleaved runs each, no claim beyond "not measurable". RIGHT.
  7. Dev routes: registerMetadataHmrRoutes returns null unless NODE_ENV is exactly development (hmr-routes.ts isDevMetadataEndpointEnabled), so the door mounts the SSE route only there. Pinned in process both ways. RIGHT.
  8. Security registrar: the wrap's option is devArtifactDoor ? 'artifact-door' : {}; the host door is never keyed (not.toMatch(/hostConfigMetadataDoor\s*\?/)); with no artifactSource the door loads nothing of the stack, so the wrap stays the one registrar. The integration pin serves the fixture's position and capability exactly once. RIGHT; the ⛔ holds.
  9. Order: init registers the service in Phase 1; the kernel pre-injects fallbacks only for slots no init filled, before Phase 2; registerDeclaredApiEndpoints runs in AppPlugin.start. RIGHT by construction and by the measured log.
  10. Which boots: as judged above. RIGHT, with os start unmeasured over HTTP.

Nothing moves that the census did not name, beyond the register() path noted under 5.

kernel.use(hostConfigMetadataDoor) with no catch. RIGHT. kernel.use throws only when pluginLoader.load refuses the plugin; init and start failures fail bootstrap with or without such a catch, and the dev door's catch covers that same narrow class. A door that is the slot's occupant must not fall back to the matcher-less fallback quietly (route rule 3: prefer failing to falling back). Can it fail a boot that served before? Two shapes. (i) @objectstack/metadata unresolvable: it is a hard dependencies entry (workspace:*) of both @objectstack/cli and @objectstack/runtime, and every library boot and every os dev host boot already import it, so such an install was already broken for the default boot. (ii) A host config whose plugins[] provides its own metadata service under a plugin neither named com.objectstack.metadata nor constructed as MetadataPlugin: the door registers first (Phase 1 runs in use order and the config's plugins are used later), and the host's provider then throws Service 'metadata' already registered. No in-tree provider has that shape (core/src/metadata-service-contract.ts names two implementations, both covered by the guard), so this is boundary, not defect; see ③.

B, packages/runtime/src/app-plugin.ts registerDeclaredApiEndpoints. Once: start() calls the method once per AppPlugin instance, no loop, and the new guard sits before the registerInMemory guard, so a fallback slot (which has registerInMemory and no matchEndpoint) warns and still registers. The remedy text is true: MetadataManager is the one in-tree matchEndpoint. No second matcher: the diff adds a typeof read of matchEndpoint and nothing else. warn is the AGENTS.md level for a functional absence the next caller sees as a 404, not a durability loss. Compositions that now print it: @objectstack/verify's bootStack (kernel.use(new AppPlugin(appBundle)), no MetadataPlugin; the dogfood harness) when the stack declares apis; OS_MODE=off and bootMode: 'off' bare configs with apis; any custom kernel without the plugin. That is the ruling's B by name, bootStack being its own example. Reach residual: the method returns before the check for a packages[] bundle (a pre-existing early return), so a multi-package stack's body apis reach a matcher-less slot silently; noted by the dev, zero in-repo producers. The read site is legal under the startup-registry rule: Phase 2, after fallback injection, the verdict logged and recorded nowhere else.

The reworded dev-door warning. On the boot that prints it (os dev or serve --dev over a named artifact not on disk, host config), the old text "Dev metadata-HMR endpoint not enabled" is now false: the host door composes there and mounts the SSE endpoint under NODE_ENV=development. The new text "Dev artifact reload not enabled" is true on every boot that prints it, including an OS_MODE=off bare config on --dev, which gets no host door and still no reload. The second line (the wrap keeps the collections) stays true. The catch-site warning is unchanged and still accurate. RIGHT.

The two test files outside the claimed list.

  • boot-preparation-parity.test.ts: isHostConfig was imported but unused in serve.ts code at BASE; this diff uses it, utils/plugin-detection.ts is a CLOSURE module, so the pin demands a step or a SERVE_ONLY reason. The reason is true: os migrate boots through createStandaloneStack, whose MetadataPlugin is always composed (schema-migration-plugins.ts header), so the one-shot boot has no door decision to mirror. This is the pin's own exemption mechanism, with its reason; not loosened.
  • serve-host-config-security-registrar.pin.test.ts: count('new MetadataPlugin(') 1 to 2 is true of the file; the pin now holds each construction once and the single artifactSource: { mode: 'local-file' ... } spelling once, and the companion pin holds the host door to no artifactSource and no registrar keying. The invariant (one artifact door; the registrar keyed on it alone) is preserved; not loosened. The warning literal follows the reword.

The changeset, sentence by sentence. The host-config definition: true (isHostConfig is any plugin with init). os serve and os start boot the same way: true by source (configJoins), HTTP unmeasured for os start. os dev matched it: measured. 404 to 401 and the undeclared path still 404: measured and pinned. /meta byte-for-byte for 22 types: measured at the pre-merge tree; the final-tree email_template delta is main's #22990, correctly excluded. openapi.json documents the declared endpoints: measured. No new table and no row at boot: measured. Writes nothing into the project directory and roots at the served app: measured, pinned, and confirmed by source (no datasource: loader on this door). Reads none of the stack, primes nothing, one registrar: true and pinned. Dev route unmounted unless development, mounted on --dev with no artifact: true and pinned. Cluster invalidation attaches with a remote driver and the warning is gone: measured. Boot cost: measured. "One warning per app that declares apis": true for single-package bundles; a packages[] bundle is exempt, which the PR body states and the changeset does not. Clause-②: no is present in the changeset body and on PR body line 2.

② Semver level

@objectstack/cli patch, @objectstack/runtime patch, Clause-②: no. RIGHT. The diff adds no export, no spec key and no config field, and removes nothing authorable (the 27 removed lines are comments and the reworded literal). The HTTP movement, a declared apis endpoint answering on a host-config os serve where it answered 404, is the published authoring contract (apis is a spec surface) honoured on a door that had not honoured it; route rule 3 names the old answer a defect, not an accept set an author or client relies on. openapi.json's accept set is unchanged: it always documented what is mounted (rule 4), and the mounted set moved by the fix. The new warn is a log line, not a surface. The metadata slot's occupant moving from createMemoryMetadata to MetadataManager is two implementations of one contract, and the host-config boot now matches what every library boot already receives. The dev SSE route on --dev with no artifact is development posture only and the route every other dev boot mounts. A bug fix in released packages takes patch, never skip-changeset; no carries no arm, as required.

③ Boundary flags

Deviations, each judged:

  1. Two cli test files outside the surface: both required by existing pins, test-only, this lane. Accepted.
  2. Reworded dev-door warning: the old text became false on that boot. Accepted.
  3. Integration boots run the tsx source entry under the development posture: check:cli-test-child-env holds the built-entry spawner set in a script outside the claim, so the production-only fact (no dev route) is pinned in process and the production 401 is the dev's hand measurement on the showcase. Accepted; the residual is stated below.
  4. No dogfood pin: bootStack composes no MetadataPlugin, so it cannot boot this composition; it is where B now speaks. Accepted.
  5. Two merges of main; census on the pre-merge tree, the final tree re-probed over HTTP (401, 404, dev route 404, /meta, openapi.json); the two later main commits are disjoint from this diff. Accepted.
  6. open_questions: none.

Out-of-scope findings:

  • Finding 1 (packages/rest/src/openapi-endpoints.ts selectDocumentableEndpoints logs at error "fix the declaration rather than the document" when a stored item fails ApiEndpointSchema, a strictObject, on a runtime-stamped _diagnostics key): NEEDS A CARRIER. It is reproducible (measured on BASE's showcase for both items), its remaining reach is exactly the compositions B now targets (bootStack and dogfood, OS_MODE=off), and an error that names a valid declaration as the thing to fix is a metadata-authoring trap under Prime Directive chore: version packages #10. Escalation: the owning seat files it (area:api, packages/rest). Not a condition on this PR, whose diff does not touch that path.
  • Finding 2 (packages[] early return before B's check): zero in-repo pull; the PR body states it. A note suffices; a carrier is optional and could ride on the next card that touches the method.

Flags from this review's own reading, none blocking:

  • packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts (not in the claim surface) describes the host-config boot as the "fallback metadata slot" shape and calls lane 1's warn "TRUE and VERBATIM on the host-config boot". After this PR that holds only for compositions outside os serve (bootStack, OS_MODE=off). Documentation drift in an untouched file; owed a docs-only follow-up edit, and this record is its note.
  • The guard keys on plugin identity (name or constructor) where the kernel's own vocabulary is providesServices (MetadataPlugin declares providesServices = ['metadata']). A third-party metadata provider in a host config's plugins[] would now fail the boot at registerService where it served before. No in-tree producer, and the dev door used the same identity key at BASE. Suggested for a later slice: p?.providesServices?.includes('metadata') as the key.
  • No CI pin measures a production-posture host-config boot's 401 over HTTP; the production reading is the dev's hand measurement on the showcase plus the in-process dev-route pin. Acceptable for this slice; a built-entry spawner would need check:cli-test-child-env's roster, outside the claim.
  • Census omission, not a defect: ObjectQL's bridgeObjectsToMetadataService now writes to the manager through register() with notify: false; no save, no publish; its realtime fan-out is unmeasured and is the path every library boot already takes.
  • The aggregate Test Core job was queued at reading time with all six shards already success; judged on what concluded.

Governance: the file list touches no governed surface (Governed Surface Queue Guard success); the head repo is the base repo, so this is not a fork PR; the PR is draft with needs:contract-review, and the queue waits on every check concluding green.

Implemented-by: claude/issue-22989-s2-host-config-metadata-door
Reviewed-by: session_01VoSxBQujKLZKPwK2u5ehQ6

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 17:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 17:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 7f2f081 Oct 12, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22989-s2-host-config-metadata-door branch October 12, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants