Repository navigation
fix(cli,runtime): a host-config os serve composes a MetadataPlugin, so its declared apis are served; a matcher-less composition warns once - #23078
Conversation
… 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>
…absent warning 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>
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>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Read: card #22989, its body and all ten comments (ruling 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 ① Derived judgmentsA, the composition in The slot's new occupant on a host-config production boot, against the ten census answers, each read against the source it names:
Nothing moves that the census did not name, beyond the
B, The reworded dev-door warning. On the boot that prints it ( The two test files outside the claimed list.
The changeset, sentence by sentence. The host-config definition: true ( ② Semver level
③ Boundary flagsDeviations, each judged:
Out-of-scope findings:
Flags from this review's own reading, none blocking:
Governance: the file list touches no governed surface ( Implemented-by: VERDICT: PASS Generated by Claude Code |
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'sapiitems into the metadata service. This slice makes a host-configos serveboot 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.tsofexamples/app-showcase, production posture, answersGET /api/v1/apps/showcase/taskswith no session401 UNAUTHENTICATED. It answered404 ENDPOINT_NOT_FOUND.What changes
A.
packages/cli/src/commands/serve.ts, the host-config composition. A host config (itsplugins[]holds plugin instances) never reachescreateStandaloneStack, so outsideos dev's artifact door itsmetadataslot was the kernel's in-memory core fallback, which has nomatchEndpoint. When the dev artifact door does not compose, the config is a host config (isHostConfig), andplugins[]holds noMetadataPluginof its own, the boot now composes:artifactSource: the door reads none of the stack. The AppPlugin wrap stays the single registrar of the four ADR-0057 collections, and itssecurityMetadataRegistrarstays the default'app-plugin'. ⛔ It is never'artifact-door'for this door.bootstrap: 'lazy'androotDir: hostRootare census answers 1 and 2 below.kernel.used at the dev door's site, after the HTTP server plugin, with no catch: a boot that cannot compose itsmetadataoccupant fails rather than falling back quietly.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, insideregisterDeclaredApiEndpointsonly. When the stack declares at least oneapiitem and themetadataslot has nomatchEndpoint, the method logs onewarnper app per boot, with structured fields{ appId, count }. The registration that follows is unchanged. The warning reads, measured on anOS_MODE=offboot of a scratch stack with one declared endpoint: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 everyinit()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-uithrough the built entrypackages/cli/bin/run.js, cwd the app,NODE_ENVunset,OS_CLOUD_URL=off,OS_DATABASE_URL=file:a fresh temp sqlite per boot,OS_AUTH_SECRETset,--log-level infofor the census boots. BASE is a second worktree at0cbfb7c32c. AFTER is this branch at88abc71212, before either merge ofmain. 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.The loader. Measured with
MetadataPluginbuilt from the BASE tree,rootDir= the showcase directory, no artifact,init+starton a capturing context.eager(the default) runs_loadFromFileSystem, which loggedMetadata loading complete {"totalItems":0,"registeredTypes":28}. It loads nothing from the showcase, so nothing double-registers there.lazyloggedlazy bootstrap — skipping filesystem primingand registered nothing either. Chosen:lazy, the artifact door's shape: that door loads its artifact and never runs the priming pass.eagerwith no artifact scansrootDir/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 holdsobject/probe_door.object.jsonprimesobject/probe_doorundereagerand nothing underlazy. After the change, the showcase boot logslazy bootstraponce and never logsLoading metadata from file system.The repository. Every mode but
artifact-onlyattaches aFileSystemRepository. After the change the boot logsFileSystemRepository attached {"repoRoot":".../examples/app-showcase/.objectstack/metadata","watch":false}. Attaching writes nothing: no.objectstack/metadataexists anywhere in the worktree after the boot, and none in a scripted run over an empty root.rootDirmust behostRoot, the served app's root and the valuecreateStandaloneStackhands its own door asprojectRoot. Without it the plugin roots atprocess.cwd(). Measured from the repository root (os serve examples/app-showcase/objectstack.config.ts, cwd the worktree root):repoRootis still the app's directory.Persistence. The host-config boot already provisions the
sys_metadatastorage objects through ObjectQL. The primary database holds 108 tables before and after, an identical list that includessys_metadata,sys_metadata_activation,sys_metadata_audit,sys_metadata_commitandsys_metadata_history, all with 0 rows. The door'sRegistered system metadata objects(4 objects) adds no table and no schema-sync step. Under the dev posturesys_metadata_auditlives in the telemetry sibling, unchanged. DatabaseLoader: no in-tree code callssetDatabaseDriverorsetDataEngine(git grepover every non-test.ts/.mjsoutsidemetadata-manager.ts: 0 hits). NoDatabaseLoader configuredline appears on either door, so a cold boot has nosys_metadataread-through. Reads go to the manager's registry and itsFilesystemLoaderoverhostRoot/TYPE/, the same pair the artifact door's manager has.What
/metaserves.GET /api/v1/meta/TYPEwith 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:os serve --devoverdist/)/metaalready 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/metadoes not read it alone.AppPlugin.registerUnclaimedTopLevelmoves nothing here: the showcase carries nopackages[], and the residual rule logged no line. One served face does move, as it should:GET /api/v1/openapi.jsonlisted no/api/v1/apps/showcase/path on BASE and lists both declared endpoints (/tasks,/inquiries/purge) after. On BASE,/meta/apiand/openapi.jsonloggedno endpoint matcher is reachableand omitted both items. Neither line appears after. At the final tree66e7f58fd5, after mergingmain, every body still equals BASE exceptemail_template. Its only differing keys arecategoryandisSystem, whichmainretired 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.The cluster bridge.
RuntimecomposesMetadataClusterBridgePluginon every boot (packages/runtime/src/runtime.ts). Its lane 1 callsattachClusterPubSubon themetadataservice atkernel: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 thedebugskip. A scripted run of the bridge over a fake cluster service, BASE tree:metadata.changedsubscriptionsbridged metadata.changed → cluster.pubsubregister(); 0 perregisterInMemory()So with a remote driver this composition attaches lane 1, and that is the first
metadata.changedbridge 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 ownregisterInMemorywrites publish nothing.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, cwdexamples/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.The dev routes. Production, after:
GET /api/v1/dev/metadata-eventsanswers 404, and the boot printsdev metadata-HMR endpoints NOT mounted ... NODE_ENV unset, treated as production. Pinned in process (below). Development posture, after, with no compiled artifact (os serve --devwith nodist/, and the supervisor-named missing artifact): 404 before, 200text/event-streamafter. That is the one route that moves, and only underNODE_ENV=development. It is the same route every other config's dev boot mounts.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/metabodies ofposition(16),permission(17),capability(11) andsharing_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.The order. From the AFTER boot's kernel log:
Phase 1: Init pluginsat 03.408.Initializing Metadata Managerat 03.553, thenMetadataPlugin providing metadata service (primary mode)at 03.556, so the slot is filled during Phase 1.Phase 2: Start pluginsat 03.736.Registered stack-declared api endpoints in metadata registry {"count":2}at 05.274, inside AppPlugin'sstart(). The slot holds theMetadataManagerwhenregisterDeclaredApiEndpointsruns. That holds by construction too:init()registers,start()reads, and the kernel injects a core fallback only for a service that noinit()provided.Which boots. The new composition reaches:
os serveof a host config;os startbeside a host config: itsservechild 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 foros startitself;os serve --devof a host config with no compiled artifact (measured 404 → 401);OS_INTERNAL_ARTIFACT_PATHnaming an absentdist/objectstack.json: 404 → 401);--no-server(composes; not measured over HTTP, there is none).It does not reach:
os dev,os serve --devwithdist/on disk), which stay 401 before and after;MetadataPlugin;OS_MODE=off(orbootMode: '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:os serve objectstack.config.ts(production, host config)ENDPOINT_NOT_FOUNDUNAUTHENTICATEDos serve --dev, nodist/os serve --dev, named artifact missingos serve --devoverdist/(the artifact door)os dev --fresh --no-watch66e7f58fd5, production host configPOST /api/v1/apps/showcase/inquiries/purge(declared) moves 404 → 401 on the same three rows. With a session, the declaredGETanswers 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 inplugins[], 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 throughscripts/pm/os-verify-lock.shand printedVERDICT 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 intsconfig.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 changedsrc/test files are intsconfig.json's program, and thetest/one is intsconfig.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.serve-host-config-security-registrar.pin.test.tsandserve-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'sapp-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 ofcreateStandaloneStack's plugins over an absent artifact, plus the CLI's wrap).{ appId, count: 1 }.MetadataManager, no warning, and the declared path matches.apis, no warning.packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts(A; 6 unit cases).artifactSourceorartifactWatch;lazy;rootDir: hostRoot; the wrap's'artifact-door'never keyed on this door;kernel.used after the HTTP server plugin.NODE_ENV=productionthe door mounts no dev metadata-events route (CONTROL: underdevelopmentit mountsGET /api/v1/dev/metadata-events).lazyis load-bearing. TheeagerCONTROL primesobject/probe_doorfrom the same root,lazyprimes nothing, and no.objectstack/metadatais 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, oneauthRequiredapi, one position, one capability), booted withos serve.dist/, the declared path answers401 UNAUTHENTICATED, and an undeclared path answers404 ENDPOINT_NOT_FOUND.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:isHostConfigis declared inSERVE_ONLYwith its reason. The one-shot boot always composes the standalone stack'sMetadataPlugin.Ablations
Both ablations ran through
node scripts/ablation-replace.mjsin 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 emptygit diff HEAD. Both moved in the expected direction: the positive pin turned red.await kernel.use(hostConfigMetadataDoor);went x1 → x0, and the comment// ablation-22989: the door is not composedwent x0 → x1. Blob0fa07d1065→f28bcffd47.serve.tsfrom source through the tsx entry, so no build sits in this loop.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 to0fa07d1065.node scripts/ablation-dist-preflight.mjs @objectstack/cli 'await kernel.use(hostConfigMetadataDoor)' --absentreported 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 indist/commands/serve.jswith the tree clean.if (typeof metadata?.matchEndpoint !== 'function') {went x1 → x0, with the guard made false. Blobafe89beb7f→00b2d3da1f. The test imports./app-plugin.jsfrom source.Tests 1 failed | 2 passed (3),expected [] to have a length of 1 but got +0. Both controls stayed green. Restored toafe89beb7f.bootstrap: 'lazy'carries its ablation inside the source pin: theeagerCONTROL leg primes the item thatlazydoes not.Gates
Derivation:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, at66e7f58fd5: 69 commands. The change set is 8 paths vs merge base0a29bec6a: committed 8, working tree 0, untracked 0.e61879428f, printed the identical 69.check-startup-registry-verdict(B adds awarnthat reads a service instart()).Results:
66e7f58fd5. All 75 exit 0.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-loadsneeded eight unrelated packages'dist/first, rebuilt from the turbo cache. Its first-pass answer ate61879428fwas exit 3, PREREQUISITE NOT MET: a non-measurement, not a red.origin/maingained two commits after this branch's last merge:packages/plugins/organizations,packages/verifyandscripts/check-verify-stand-in-erasure.mjs. They are disjoint from this diff and are not merged.Lint, a proven narrowing
At
66e7f58fd5, over the 7 changed TypeScript files; the repo-widepnpm lintis CI's.eslint --no-inline-config --print-configresolves a rule set for every changed.tsfile: 5 rules each, 6 forapp-plugin.ts. It resolves none for the.mdchangeset.--format json.files=7 errors=0 warnings=0, exit 0.eslint.config.mjsnever enables type-aware linting: noparserOptions.projectand noprojectServicein 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
Fixes #22989. The card's pin is met on the final tree: productionos serveofexamples/app-showcaseanswers 401 on the declared path, where it answered 404.packages/clitest files only.packages/cli/src/utils/boot-preparation-parity.test.tsgains oneSERVE_ONLYentry.serve.tsnow callsisHostConfigfrom 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).serve-host-config-security-registrar.pin.test.tsis edited, as described under Tests.check:cli-test-child-envpins 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).@objectstack/verify'sbootStack, which composes noMetadataPlugin, so it cannot boot this composition. A showcase dogfood boot is exactly where B's warning now speaks.packages[]bundle. It returns before readingapis, so a multi-package stack's top-level residual reaches a matcher-less slot without the warning. No in-repo stack declaresapisinsidepackages[]. Carrier: none.os serve --devwith no compiled artifact now mountsGET /api/v1/dev/metadata-events. It is inert there: there is no artifact to reload. Every other config's dev boot already mounts it.GET /api/v1/openapi.jsonrefuses a storedapiitem for its runtime_diagnosticskey (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 (anOS_MODE=offboot, lean kernels) is NOT MEASURED. Noted, not filed. Carrier: none.os startbeside a host config shares this code and was not separately measured over HTTP.Generated by Claude Code