fix(cli,core): the ready line reports the degraded boot the kernel already concluded - #16869
Conversation
…eady banner Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…ready concluded Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…ensus Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 40 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 d584db8679fdfcf2058241dc9137c3561a789edf && git checkout d584db8679fdfcf2058241dc9137c3561a789edf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c930f859714de408ba0221f435ff957ed9e64759 0ad4dc8803af2e91060c72f8ecb01995b4dc58cd && git checkout -B drift-repro c930f859714de408ba0221f435ff957ed9e64759 && git merge --no-ff 0ad4dc8803af2e91060c72f8ecb01995b4dc58cd
node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759
|
Fixes #16630
Clause-②: no
What was actually broken
✓ Server is readyandSystem started with degraded capabilities. Missing core services: …were two statements about one boot, produced by two packages —printServerReadyin@objectstack/cli,ObjectKernel.validateSystemRequirements()in@objectstack/core— with no data path between them. The ready signal did not depend on the thing that broke, so it could not report it. Triage's reading, kept verbatim because it is the ruling this PR implements:Readiness is not made strict here. Nothing about what boots, binds or exits changes.
The data path — the shape of it
The kernel publishes the conclusion it had already computed on its own service registry, which is the seam boot facts already cross to reach the banner:
Three properties of that route, deliberately:
registerService/getServiceare alreadyObjectKernel's public API, and@objectstack/runtimealready stashesseed-summaryon exactly this registry for exactly this banner. No member and no type is added to@objectstack/core's surface. HenceClause-②: no.corestaysServiceRequirementDef's judgement alone (@objectstack/spec/system).readMissingCoreServicesfetches an array and filters it for well-formedness; it reads no requirement table, counts no services and inspects no plugins.getServicethrows on a healthy one and the reader reportsundefined. The banner then takes the original branch, unchanged.验收备注
1. Build the missing data path first — not a wording change
Diagram above. Producer:
packages/core/src/kernel.ts(DEGRADED_CAPABILITIES_SERVICE,publishDegradedCapabilities). Carrier: the kernel service registry. Reader:packages/cli/src/utils/degraded-capabilities.ts. Call site:packages/cli/src/commands/serve.ts, next to the existingauthandseed-summaryreads. ⛔ "Which services count as core" is not re-implemented anywhere on the CLI side.Ablation — the data path is load-bearing, measured. Deleting the single
publishDegradedCapabilities(...)call and rebuilding@objectstack/core(the cli test resolves core throughexports→dist, aKNOWN_UNALIASED_TEST_IMPORTSpair, so every leg rebuilt and proved the mutation reached the artifact the suite consumes):(The ablated
@objectstack/corebuild exits 1 at its DTS step —TS6133: 'publishDegradedCapabilities' is declared but its value is never read— after emitting ESM/CJS. The preflight is what certifies the mutation reached the consumed artifact; it did.)2. The ready line reports what it knows — the same list
kernel.tsprintsReal
os serveboot, production posture, noOS_AUTH_SECRET(auth skipped by the command's own documented rule), one output:The names come from the kernel's array;
format.server-ready-degraded-boot.test.tspins that equality by comparing the tail of the banner line against the tail of the kernel's own line on a two-service degradation (auth, jobon both).authandjobare the only twocoreservices with no entry inCORE_FALLBACK_FACTORIES, so they are the only names that can ever appear here. A stack that omits@objectstack/service-jobwill now say so on the ready line. That is faithful to the kernel, which already warns; ⛔ filtering the list on this side is exactly the second implementation item 1 forbids. In all five real boots measured for this PR (--devbare,--devminimal preset, production ×3)JobServicePluginwas loaded andjobnever appeared.3. No change to any start/exit behaviour — negative control, before and after
A machine deliberately running without auth, driven by the same script on both trees:
Started, bound, served, and exited 0 on SIGINT in both. The e2e's degraded leg asserts the same property in CI terms: it only resolves once the banner's LAST line is on the stream, and then asserts
child.exitCode === null.4. A healthy boot is byte-identical — diffed on real boots
Same fixture, same port, same paths,
--dev, ready block extracted from✓ Server is readythroughPress Ctrl+C to stop:The unit leg pins the same thing as an explicit literal (
HEALTHY_READY_BLOCK), transcribed rather than generated, and the e2e's healthy leg asserts✓ Server is readypresent andDEGRADEDabsent. An "always append a status line" implementation passes item 2 and fails all three of these.5. Both sides asserted in ONE output
packages/cli/src/utils/format.server-ready-base-url.test.ts:162is untouched — it still assertsServer is readyappears, and still passes.The degraded leg lives in the new
format.server-ready-degraded-boot.test.ts. The two statements do not share a stream (ObjectLoggerwriteswarnto stdout, the banner writes to stderr), so the test reassembles the single transcript a terminal shows —console.errorandprocess.stdout.writeinto one ordered buffer — and asserts against it:6. A real degraded boot is driven, not a formatter unit test
Two levels, because the defect is that two packages each spoke alone:
packages/cli/src/utils/format.server-ready-degraded-boot.test.ts— bootstraps a realObjectKernelthroughvalidateSystemRequirements()withauthgenuinely absent, reads the conclusion the wayservereads it, prints the real banner from it. Runs per-PR (unit tier).packages/cli/test/serve-ready-degraded-boot.e2e.test.ts— spawns the realos serveand reads its stderr. Both legs assert their premise FIRST (…degraded capabilities…present / absent), so neither can pass vacuously.bin/run.jswithNODE_ENVunset, notrunServe(). That helper spawnsbin/run-dev.js, which setsNODE_ENV=developmentbefore argv is parsed, so the dev fallback secret applies, auth always loads and the boot is never degraded — measured: therunServe()draft of this file went green with both legs healthy. Production posture is the only reachable spelling of "auth absent", which is why the file joins the built-entrypoint populationscripts/check-cli-test-child-env.mjspins (the roster edit in this PR, admitted by name and reason; the gate is green including its 152-case--self-test).It is named
*.e2e.test.ts, so under the 2026-09-07 maintainer direction on test cost it sits in the nightly tier rather than the per-PR queue. ⛔ Not renamed to route around that ruling; the per-PR guarantee is the unit-tier file above.Verification
pnpm --filter @objectstack/core testTest Files 49 passed (49) · Tests 1211 passed (1211)pnpm --filter @objectstack/core typecheckpnpm --filter @objectstack/cli typecheckvitest run --project unit(whole cli unit tier)Test Files 187 passed (187) · Tests 2560 passed, 6 expected fail (2566)OS_TEST_TIERS=nightly vitest run --project integration(this file +serve-node-env-production-default)Test Files 2 passed (2) · Tests 5 passed (5)dispatch-gates.mjs --ranreconciliation✓ 80 derived famil(ies) accounted for — 80 run, 0 NOT-MEASUREDpnpm lint(repo-wide,eslint . --no-inline-config)Gate readings are the gates' own verdict lines with exit codes captured before any pipe.
check:dual-build-cjs-loadsandcheck:i18n-coveragefirst answeredPREREQUISITE NOT MET/COULD NOT MEASURE(exit 3) on a partially built tree; both were re-run to exit 0 after a fullpnpm build(73/73 tasks), so neither is reported here as a pass it did not earn.Gate/lint/test readings above were taken at
0ad4dc8803, the branch head.dispatch-gates.mjsprintsSTALE TREE— the branch is based at460d4b807eandorigin/mainhas since moved, and three gate scripts it derives from changed across that range (check-published-readme-exports.mjs,check-route-envelope.mjs,engine-double-contract.pinned.json). The derivation and the runs above therefore read this branch's copies of those three; CI grades the merged ones.Docs drift advisory
Re-derived rather than read off the bot comment, for two reasons the comment states itself: its list was truncated above 15 rows, and its own run carried uncommitted changes, so the commit it names did not fully identify what it read.
Same tree, clean checkout. A detached worktree was cut at
d584db8679fdfcf2058241dc9137c3561a789edf— parentsc930f859714de408ba0221f435ff957ed9e64759(base) and0ad4dc8803af2e91060c72f8ecb01995b4dc58cd(head), i.e. exactly the mergeactions/checkoutgives apull_requestrun.git status --porcelainwas empty, and the tool's own provenance block agrees:The real count is 33, not 28. The comment's 28 came from a dirty checkout; a clean read of the same merge tree returns 33 rows, 5 of them release-owned, over 12 anchors. This is a cleaner reading of the same tree, not a different tree — no "different tree, not a wrong row" caveat applies to the count itself.
Only three of the twelve anchors actually listed anything:
os servepackages/cli/src/commands/serve.tsObjectKernelgetServiceServiceReader(
packageMentionDocs, the deliberately-wide backstop, is 40.weakAnchorsDroppedisServe (symbol).)The five release-owned pages — read, and nothing is falsified
⛔ Not edited, and no edit is proposed: release notes are written centrally at release time (AGENTS.md → Documentation Guardrails).
content/docs/releases/v12.mdxos servecontent/docs/releases/v14.mdxos servedegrad*hit is about older renderers degrading a permission-set tabcontent/docs/releases/v15.mdxObjectKerneldegradedhits are connector husks and__serviceInfostub/degraded/fallback markers, an unrelated mechanismcontent/docs/releases/v16.mdxos serveFlows:section; that section is untouchedcontent/docs/releases/v17.mdxgetService,os serveThe three v17 passages that come closest, each read in full:
Server is readyanywhere incontent/. It is historical, aboutos serve <config>cannot boot without a prebuiltdist/objectstack.json— dies withService 'manifest' is async - use await#4085: a named-but-missing artifact path "used to print "Server is ready" over an empty platform" and now fails the boot naming the path. That boot still fails; this diff does not touch it. Not falsified.serve's ready banner printsConfig: objectstack.config.tson an OS_ARTIFACT_URL boot, where no config was loaded #8978)" — about the banner'sConfig:/Artifact:row. Untouched here, and the byte-identity evidence under 验收备注 item 4 covers exactly that: a healthy boot's whole ready block is unchanged, same sha256.getServiceresolves through a slot→contract ledger" — checked rather than assumed. The gate behind that sentence ischeck:slot-lookup(scripts/check-slot-lookup-ratchet.mjs), and it bans erasing a lookup result toany; it is not a registry of permitted service names.readMissingCoreServicestypes its result asDegradedCapabilitiesReadout | undefined, the gate is green (exit 0) with the new read present, and no baseline entry was added. There is no name-keyed slot ledger inpackages/specorpackages/corefor a new service name to join —seed-summary, the exact precedent this PR follows, is in none either. Not falsified.⇒ There is no fact here to hand back for a separate docs-only PR or an issue. Stated explicitly because "nothing found" and "did not look" read identically otherwise.
Token sweep of all of
content/— the part that actually covers this diffThe advisory names its own blind spot: 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. This diff is emitter-side (it changes one printed line), so the anchor list is not the coverage; a literal sweep is. Every token below was searched over all of
content/, fixed-string:Server is readyreleases/v17.mdx:2194, the historical #4085 sentence above. Not expected output for a boot.degraded capabilitiesmissing core servicesMissing core servicesCore service missingkernel/services-checklist.mdx:511— see belowDEGRADEDOS_ALLOW_DEGRADED_TENANCY, an unrelated env var; 2 are theDEGRADED BOOTbanner — see belowAnd the banner's other rows, to catch a transcript spelled without the ready line itself:
➜(the banner's link arrow)API:/Console:/Mode:/Driver:Press Ctrl+C to stopBoot diagnosticsDev adminPlugins:devPlugins:config keys inplugins/index.mdx, not the banner rowSeeds:releases/v17.mdx:1653, prose describing the row's shape, unchanged hereTenancy:⇒ No page in
content/reproduces theos serveready banner as expected output at all. That is the decisive reading: the page this change could falsify — one printing an unconditional✓ Server is readyfor a boot that is in fact degraded — does not exist in this tree.Two hits that deserve a sentence each rather than a row:
content/docs/kernel/services-checklist.mdx:511is the closest page in the tree to this change: it explains thatjobis deliberately absent fromCORE_FALLBACK_FACTORIES(plugin-reports: the documented setInterval dispatcher fallback is unreachable on ObjectKernel, and the job fallback it takes instead never fires #10746) and that "the boot warnsCore service missing, functionality may be degraded: job". Still true and still exactly what happens — that warning is untouched. It is now incomplete rather than wrong: after this PR ajob-less boot also says so on the ready line. Not a falsification, so ⛔ no edit is proposed here; recorded so a docs pass can decide whether the page wants the extra sentence.DEGRADED BOOT(data-modeling/drivers.mdx:253,data-modeling/external-datasources.mdx:162) is a different, pre-existing banner —packages/objectql/src/engine.tsandservice-datasource, emitted whenOS_ALLOW_DRIVER_CONNECT_FAILURE=1lets a failed driver through. It shares only the wordDEGRADEDwith the new ready line, says something else, and the two can co-occur without contradicting each other. Both pages are accurate as written; no collision to repair.Method note
mainand holds a differentcontent/docs. Any list derived there could legitimately differ; that would be a different tree, not a wrong row. Everything above is fromd584db8679…with a clean working tree.Changeset
.changeset/ready-signal-reports-degraded-boot.md—@objectstack/corepatch,@objectstack/clipatch. Both are published and the banner is user-visible output, so this is not askip-changesetPR.Generated by Claude Code