Skip to content

fix(cli,core): the ready line reports the degraded boot the kernel already concluded - #16869

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-16630-ready-signal-reports-degraded
Sep 8, 2026
Merged

fix(cli,core): the ready line reports the degraded boot the kernel already concluded#16869
os-project-manager merged 3 commits into
mainfrom
claude/issue-16630-ready-signal-reports-degraded

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16630

Clause-②: no

What was actually broken

✓ Server is ready and System started with degraded capabilities. Missing core services: … were two statements about one boot, produced by two packages — printServerReady in @objectstack/cli, ObjectKernel.validateSystemRequirements() in @objectstack/corewith 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 变严格。
一台故意不带 auth 的开发机照旧启动、照旧打印就绪 —— 只是那一行会说明它是在什么状态下就绪的。⇒ 没有可失去的东西,所以没有需要裁的取舍。

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:

ObjectKernel.validateSystemRequirements()          packages/core/src/kernel.ts
  missingCoreServices  ─┬─ logger.warn("System started with degraded capabilities. …")   (unchanged)
                        └─ publishDegradedCapabilities()
                              this.registerService('kernel.degraded-capabilities',
                                                   { missingCoreServices })      ← frozen copy
                                        │
                     runtime.getKernel() │  (the handle `serve` already holds)
                                        ▼
readMissingCoreServices(kernel)                packages/cli/src/utils/degraded-capabilities.ts
  kernel.getService('kernel.degraded-capabilities')     ← the accessor `serve` already uses
                                        │                 for `auth` and `seed-summary`
                                        ▼
printServerReady({ …, missingCoreServices })          packages/cli/src/utils/format.ts
  degraded → "  ⚠ Server is ready — DEGRADED: missing core services: auth"
  healthy  → "  ✓ Server is ready"                    ← byte-identical to today

Three properties of that route, deliberately:

  • No new published export. registerService / getService are already ObjectKernel's public API, and @objectstack/runtime already stashes seed-summary on exactly this registry for exactly this banner. No member and no type is added to @objectstack/core's surface. Hence Clause-②: no.
  • Nothing is re-derived on the CLI side. Which services count as core stays ServiceRequirementDef's judgement alone (@objectstack/spec/system). readMissingCoreServices fetches an array and filters it for well-formedness; it reads no requirement table, counts no services and inspects no plugins.
  • Absent means healthy. The readout is published only on a degraded boot, so getService throws on a healthy one and the reader reports undefined. 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 existing auth and seed-summary reads. ⛔ "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 through exportsdist, a KNOWN_UNALIASED_TEST_IMPORTS pair, so every leg rebuilt and proved the mutation reached the artifact the suite consumes):

mutation on disk : removed text 0 occurrences, injected text 1; blob 633de01a… ≠ HEAD 55b34588…
dist preflight   : ✓ marker absent from all 8 built files (--absent, exit 0)
ABLATED RUN      : Test Files 1 failed | Tests 2 failed | 3 passed (5)
                   AssertionError: the kernel published no degraded readout: expected undefined to deeply equal [ 'auth' ]
                   AssertionError: expected '✓ Server is ready' to be 'auth, job'   ← the defect, returning
restore          : blob 55b34588… == HEAD; `git diff HEAD` empty; whole tree clean
dist preflight   : ✓ marker present in 2 built files
RESTORED RUN     : Test Files 1 passed | Tests 5 passed (5)

(The ablated @objectstack/core build 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.ts prints

Real os serve boot, production posture, no OS_AUTH_SECRET (auth skipped by the command's own documented rule), one output:

  ⚠ Server is ready — DEGRADED: missing core services: auth
  …
  ⚠ Boot diagnostics — 3 warnings logged during startup:
    … WARN CORE: Core service missing, functionality may be degraded: auth
    … WARN System started with degraded capabilities. Missing core services: auth

The names come from the kernel's array; format.server-ready-degraded-boot.test.ts pins 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, job on both).

⚠️ Measured consequence a reviewer should know: auth and job are the only two core services with no entry in CORE_FALLBACK_FACTORIES, so they are the only names that can ever appear here. A stack that omits @objectstack/service-job will 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 (--dev bare, --dev minimal preset, production ×3) JobServicePlugin was loaded and job never 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:

BEFORE (origin/main build)   NEGCTL[before] STARTED=yes   EXIT=0    banner: "  ✓ Server is ready"
AFTER  (this branch)         NEGCTL[after]  STARTED=yes   EXIT=0    banner: "  ⚠ Server is ready — DEGRADED: missing core services: auth"

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 ready through Press Ctrl+C to stop:

$ diff BEFORE-healthy-ready-block.txt AFTER-healthy-ready-block.txt   →  no output
$ sha256sum …
dab49a75ec9b8983bd721f1ab7f94ec34b8bc6e42351163c99074e7be5cec6d4  BEFORE-healthy-ready-block.txt
dab49a75ec9b8983bd721f1ab7f94ec34b8bc6e42351163c99074e7be5cec6d4  AFTER-healthy-ready-block.txt

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 ready present and DEGRADED absent. 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:162 is untouched — it still asserts Server is ready appears, 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 (ObjectLogger writes warn to stdout, the banner writes to stderr), so the test reassembles the single transcript a terminal shows — console.error and process.stdout.write into one ordered buffer — and asserts against it:

expect(output).toContain('System started with degraded capabilities. Missing core services: auth'); // producer
expect(output).toContain('Server is ready');                                                        // still ready
expect(output).toContain('⚠ Server is ready — DEGRADED: missing core services: auth');              // consumer
expect(output).not.toContain('✓ Server is ready');                                                  // the defect

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 real ObjectKernel through validateSystemRequirements() with auth genuinely absent, reads the conclusion the way serve reads it, prints the real banner from it. Runs per-PR (unit tier).
  • packages/cli/test/serve-ready-degraded-boot.e2e.test.ts — spawns the real os serve and reads its stderr. Both legs assert their premise FIRST (…degraded capabilities… present / absent), so neither can pass vacuously.

⚠️ The e2e spawns bin/run.js with NODE_ENV unset, not runServe(). That helper spawns bin/run-dev.js, which sets NODE_ENV=development before argv is parsed, so the dev fallback secret applies, auth always loads and the boot is never degraded — measured: the runServe() 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 population scripts/check-cli-test-child-env.mjs pins (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

what result
pnpm --filter @objectstack/core test Test Files 49 passed (49) · Tests 1211 passed (1211)
pnpm --filter @objectstack/core typecheck exit 0
pnpm --filter @objectstack/cli typecheck exit 0
vitest 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 --ran reconciliation ✓ 80 derived famil(ies) accounted for — 80 run, 0 NOT-MEASURED
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0, 2m53s, zero findings — the full scan, not a narrowed one

Gate readings are the gates' own verdict lines with exit codes captured before any pipe. check:dual-build-cjs-loads and check:i18n-coverage first answered PREREQUISITE NOT MET / COULD NOT MEASURE (exit 3) on a partially built tree; both were re-run to exit 0 after a full pnpm 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.mjs prints STALE TREE — the branch is based at 460d4b807e and origin/main has 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 — parents c930f859714de408ba0221f435ff957ed9e64759 (base) and 0ad4dc8803af2e91060c72f8ecb01995b4dc58cd (head), i.e. exactly the merge actions/checkout gives a pull_request run. git status --porcelain was empty, and the tool's own provenance block agrees:

node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759
  computedOn.head        d584db8679fdfcf2058241dc9137c3561a789edf
  computedOn.headParents [c930f859714de408ba0221f435ff957ed9e64759, 0ad4dc8803af2e91060c72f8ecb01995b4dc58cd]
  computedOn.diffBase    c930f859714de408ba0221f435ff957ed9e64759
  computedOn.dirty       false          ← the bot's run could not say this

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:

anchor kind rows
os serve command, read off packages/cli/src/commands/serve.ts 22
ObjectKernel symbol, a top-level class 10
getService symbol, a field of interface ServiceReader 8

(packageMentionDocs, the deliberately-wide backstop, is 40. weakAnchorsDropped is Serve (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).

page listed via verdict
content/docs/releases/v12.mdx os serve not falsified — no claim about the ready banner or the kernel's degraded conclusion
content/docs/releases/v14.mdx os serve not falsified — its only degrad* hit is about older renderers degrading a permission-set tab
content/docs/releases/v15.mdx ObjectKernel not falsified — its degraded hits are connector husks and __serviceInfo stub/degraded/fallback markers, an unrelated mechanism
content/docs/releases/v16.mdx os serve not falsified — it says the startup banner prints a Flows: section; that section is untouched
content/docs/releases/v17.mdx getService, os serve not falsified — see the three passages below

The three v17 passages that come closest, each read in full:

  • line 2194 — the only occurrence of the string Server is ready anywhere in content/. It is historical, about os serve <config> cannot boot without a prebuilt dist/objectstack.json — dies with Service '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.
  • line 3944, "The ready banner names what actually booted ([finding] serve's ready banner prints Config: objectstack.config.ts on an OS_ARTIFACT_URL boot, where no config was loaded #8978)" — about the banner's Config: / 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.
  • line 2235, "getService resolves through a slot→contract ledger" — checked rather than assumed. The gate behind that sentence is check:slot-lookup (scripts/check-slot-lookup-ratchet.mjs), and it bans erasing a lookup result to any; it is not a registry of permitted service names. readMissingCoreServices types its result as DegradedCapabilitiesReadout | 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 in packages/spec or packages/core for 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 diff

The 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:

token hits what they are
Server is ready 1 releases/v17.mdx:2194, the historical #4085 sentence above. Not expected output for a boot.
degraded capabilities 0
missing core services 0
Missing core services 0
Core service missing 1 kernel/services-checklist.mdx:511 — see below
DEGRADED 11 9 are OS_ALLOW_DEGRADED_TENANCY, an unrelated env var; 2 are the DEGRADED BOOT banner — see below

And the banner's other rows, to catch a transcript spelled without the ready line itself:

token hits
(the banner's link arrow) 0
API: / Console: / Mode: / Driver: 0 each
Press Ctrl+C to stop 0
Boot diagnostics 0
Dev admin 0
Plugins: 2 — both devPlugins: config keys in plugins/index.mdx, not the banner row
Seeds: 1 — releases/v17.mdx:1653, prose describing the row's shape, unchanged here
Tenancy: 2 — tenancy-posture prose, not the banner row

⇒ No page in content/ reproduces the os serve ready banner as expected output at all. That is the decisive reading: the page this change could falsify — one printing an unconditional ✓ Server is ready for 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:511 is the closest page in the tree to this change: it explains that job is deliberately absent from CORE_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 warns Core 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 a job-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 bannerpackages/objectql/src/engine.ts and service-datasource, emitted when OS_ALLOW_DRIVER_CONNECT_FAILURE=1 lets a failed driver through. It shares only the word DEGRADED with 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

⚠️ The re-derivation above was run on the merge tree the bot names, in a worktree cut for that purpose — not in the branch worktree, which was cut from an older main and holds a different content/docs. Any list derived there could legitimately differ; that would be a different tree, not a wrong row. Everything above is from d584db8679… with a clean working tree.

Changeset

.changeset/ready-signal-reports-degraded-boot.md@objectstack/core patch, @objectstack/cli patch. Both are published and the banner is user-visible output, so this is not a skip-changeset PR.


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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.

Coarse fallback — 40 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 c930f859714de408ba0221f435ff957ed9e64759packageMentionDocs.

Which tree this was computed on

This run read content/docs from d584db8679fdfcf2058241dc9137c3561a789edf — the merge of head 0ad4dc8803af2e91060c72f8ecb01995b4dc58cd into base c930f859714de408ba0221f435ff957ed9e64759, 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 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

⚠️ 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 c930f859714de408ba0221f435ff957ed9e64759 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

1 participant