Skip to content

fix(cli): a refused Console mount answers 503 naming the remedy, not a bare 404 - #22562

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22420-console-drift-responder
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22420-console-drift-responder

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22420
Clause-②: no
A refused Console mount answers 503 naming the remedy instead of the router's bare 404 on the same routes, as the not-built position already does (PR #22419, Clause-②: no). No accepted input, key or export changes.

What changes

os serve sometimes decides not to serve the Console. On two of those boots, the HTTP surface used to say nothing: GET /_console/ and GET / answered the router's bare 404 ENDPOINT_NOT_FOUND, which looks the same as a wrong URL. Both boots now mount a responder on the routes the not-built answer (PR #22419) already uses:

  • GET /_console/* answers 503, text/plain, no-store. The body is the remedy the boot message prints, rendered without absolute host paths.
  • GET / and GET /_console redirect to /_console/, as they do when the Console is served.
position terminal HTTP before HTTP after
package resolves, no built dist/ formatConsoleDistMissingWarning 503 + redirects (PR #22419) unchanged
dev, built dist/.objectui-sha is not the repo pin formatConsoleShaDriftRefusal (byte-identical) bare 404 503 naming pnpm objectui:build, both SHAs and OS_ALLOW_CONSOLE_DRIFT=1; the pin file is named without its absolute path; / redirects
package does not resolve at all (consolePath null, Console enabled) nothing bare 404 the not-built warning's null arm (Console package `@objectstack/console` not found, then the reinstall sentence); 503 saying the Console is not installed; / redirects
--no-ui / --no-console the operator's choice bare 404 bare 404, by design

Files:

  • packages/cli/src/utils/console.ts:
    • createConsoleUnavailablePlugin (not exported) is now the ONE route-mounting body. Before, that body was inline in createConsoleNotBuiltPlugin.
    • Two responders delegate to it. createConsoleNotBuiltPlugin(consolePath: string | null) is unchanged except that it now accepts null. createConsoleShaDriftRefusalPlugin(drift) is new.
    • describeConsoleShaDrift(drift, hostPaths) (not exported) is the drift refusal's text below its headline. Both the terminal block and the HTTP answer render it.
    • describeConsoleDistMissing (the not-built picker) gains a null arm.
  • packages/cli/src/commands/serve.ts, Console mount branch only:
    • the drift arm now mounts the drift responder;
    • the not-built arm is now else if (consoleEnabled), so it also covers consolePath === null.
    • Outside the branch, the import list gains one name.
  • packages/cli/src/utils/console-route-ledger.ts: the family (now console-static | console-unavailable) and the note of GET /, GET /_console and GET /_console/* name every responder that mounts them.
  • Tests:
    • console.sha-drift.test.ts: the drift responder on a real Hono app, from a fixture tree with a made-up pin and stamp.
    • console.not-built.test.ts: the null position.
    • test/published-subpath-console.pin.test.ts: INTERNAL_SINCE_RETIREMENT records the new internal export. The module's own partition pin requires this. The ./console barrel still publishes exactly three names.
  • .changeset/22420-console-drift-responder.md: @objectstack/cli patch.

Mechanism hypotheses, measured

  • H1, confirmed. The branch at serve.ts:4961 (base 4638625e07) printed formatConsoleShaDriftRefusal on refusedForDrift && consoleDrift and mounted nothing. The no-dist arm mounted createConsoleNotBuiltPlugin(consolePath). Measured live (below): drift answered 404 ENDPOINT_NOT_FOUND at /_console/, / and /_console.

  • H2, done as "a sibling that shares its route mounting". There is one route-mounting body. Each position has one picker:

    • not-built: describeConsoleDistMissing;
    • drift: describeConsoleShaDrift.

    The terminal and HTTP forms render the same picker output. The not-built warning, the drift refusal and the drift advisory render byte-identical to base: a script printed all three for a fixed drift from a BASE worktree and from HEAD, and the two outputs have the same sha256, a9a832a6aa7d….

  • H3, confirmed. The refusal names drift.pinFile, an absolute path. The HTTP form writes .objectui-sha at the checkout root instead, and the terminal keeps the path. The SHAs stay in both, because they are commits of the public objectui repository. Ablation: making the HTTP form name drift.pinFile turns exactly one pin red, "with only the absolute pin-file path left out" (1 failed / 13 passed). The mutation and its restore were both proven on disk by scripts/ablation-replace.mjs (blob e8dd212b0e75 → 5c5be9625144 → back to e8dd212b0e75, git diff HEAD empty).

  • H4, covered in this PR, with no second remedy picker. When consolePath is null, the install is one step past an install with no dist/: @objectstack/console is a hard dependency of @objectstack/cli. The existing picker's install arm already names the fix for that ("reinstall it at the same version as the CLI"). So describeConsoleDistMissing gains a null arm. It changes only what it says is missing (Console package ... not found instead of Console dist not found), and its reinstall sentence is the same; a pin compares the two tails. The responder is the same createConsoleNotBuiltPlugin, with the headline "is not installed".

    • Reach is NOT MEASURED live, because it cannot be reached inside this repository. The CLI always resolves its own workspace packages/console. Measured: a fixture with no console package in its node_modules still resolved the workspace package, and the boot took the not-built arm.
    • The position is pinned in-process (createConsoleNotBuiltPlugin(null) on a real Hono app).

Live measurement: before and after

The harness boots bin/run-dev.js serve --dev through tsx, using an OS_ARTIFACT_PATH artifact from a scratch project. That project's node_modules/@objectstack/console is a fixture dist, and its own .objectui-sha is a fixture pin (aaaaaaaaaaaa…, stamp bbbbbbbbbbbb…). The live pin is never used. Each child was stopped by PID.

case GET /_console/ GET / GET /_console
drift, base 4638625e07 404 ENDPOINT_NOT_FOUND JSON 404 ENDPOINT_NOT_FOUND 404
drift, head 9cadb71c00 503 text/plain no-store, refusal text, 0 occurrences of the scratch path 302 to /_console/ 302 to /_console/
matching sha (control), base and head 200 SPA shell 302 302
--no-console (control), base and head 404 ENDPOINT_NOT_FOUND 404 404
package without dist/ (neighbour), head 503 not-built answer 302 302

The drift answer on head:

The ObjectStack Console here was built from a different objectui commit than this checkout pins, so this dev server refuses to serve it at /_console/.

      pinned  (.objectui-sha at the checkout root):              objectui@aaaaaaaaaaaa
      built   (console/dist/.objectui-sha): objectui@bbbbbbbbbbbb

    packages/console/dist is a gitignored local build that 'turbo run build' does NOT refresh,
    ...
        pnpm objectui:build
    ...
    To boot anyway with the stale bundle: OS_ALLOW_CONSOLE_DRIFT=1 — the API still serves either way.

Then restart the server.

Tests and gates

All readings below are against head 9cadb71c00, and every lock-held run printed VERDICT command-exit 0 unless stated otherwise.

  • Red against base. console.ts was restored to 4638625e07 (blob 0a8b6a55bd46 proven on disk, then restored by trap to the HEAD blob e8dd212b0e75, with git diff HEAD empty). Then 2 files failed and 11 new pins never passed:

    • the drift block failed with createConsoleShaDriftRefusalPlugin is not a function;
    • the null block failed with The "path" argument must be of type string. Received null.

    The 18 pre-existing tests stayed green.

  • New and touched pins: console.not-built.test.ts, console.sha-drift.test.ts, console-route-ledger.conformance.test.ts and test/console-resolve.test.ts passed, 4 files and 62 tests. test/published-subpath-console.pin.test.ts (whose partition check reads the packed .d.ts), test/vitest-tiers-partition.test.ts and the ledger conformance passed, 3 files and 54 tests.

  • cli: pnpm --filter @objectstack/cli typecheck passed: tsc --noEmit, and check:test-typecheck on tsconfig.test.json with its debt unchanged (3 files, 28 errors held).

  • cli unit layer (vitest run --project unit): 276 files, 4086 tests passed.

  • cli integration layer (--project integration, run locally because serve.ts is a spawn entry): 102 files, 945 passed and 2 skipped.

  • Dogfood. The one dogfood file that reads /_console, showcase-public-form-redirect.dogfood.test.ts, imports utils/console.ts as source, so it ran locally: 9 of 9 passed. No other file under packages/qa/dogfood names _console, per git grep.

  • Lint, a proven narrowing (the full pnpm lint is CI's):

    1. Population: eslint's own config. --print-config resolves all 6 touched .ts files into linted config objects with 5 or 6 rules each.
    2. Count: the --format json output reports 6 files, 0 errors and 0 warnings.
    3. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), and its local plugins are per-file AST rules. So this diff cannot change the verdict on any untouched file.
  • Gates:

    • node scripts/pm/dispatch-gates.mjs --commands, re-derived at 9cadb71c00 with no paths, lists 67 commands: the dispatched 66 plus pnpm check:cli-test-child-env, because of the pin-test edit.
    • All 67 ran.
    • --ran reconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN".
    • check:i18n-coverage first refused with exit 3 ("COULD NOT MEASURE", because @objectstack/connector-slack was unbuilt). After the build it named, it re-ran: "OK (13 config(s), 621 baselined untranslated string(s), none new)".
    • check:type-check-debt and check:dual-build-cjs-loads ran last, after every locked suite, and both passed.
  • main: origin/main (6a3f82efa7) has moved since the base, but no new commit touches a file in this diff, and git merge-tree is clean. So there is no merge (per the fence).

Acceptance notes


Generated by Claude Code

…a bare 404

os dev refuses a Console whose built dist/.objectui-sha differs from the
repo pin, and an install whose @objectstack/console does not resolve
mounts nothing either; both answered the router's bare 404 at /_console/
and /. Both boots now mount a responder on the not-built answer's routes:
503 at /_console/* with the boot message's own remedy text (no absolute
host path) and / redirecting to /_console/.

- utils/console.ts: one route-mounting body (createConsoleUnavailablePlugin)
  behind createConsoleNotBuiltPlugin and the new
  createConsoleShaDriftRefusalPlugin; the drift refusal's text is rendered
  from one source for the terminal and HTTP; the not-built picker gains a
  null arm for an unresolved package (same reinstall sentence).
- commands/serve.ts: the Console mount branch mounts the drift responder on
  a refused boot and the not-built responder when the package is null.
- console-route-ledger.ts: family and notes of GET /, GET /_console and
  GET /_console/* name every responder that mounts them.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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)
  • 3 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 — 28 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 6a3f82efa7d588267eb2e52c06a73c56315c636a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a95cbe2425551c16a082a4827d703edc6bdf38ff — the merge of head 9cadb71c0034a9856e76f6eacd6f6afdc13f48ff into base 6a3f82efa7d588267eb2e52c06a73c56315c636a, 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 a95cbe2425551c16a082a4827d703edc6bdf38ff && git checkout a95cbe2425551c16a082a4827d703edc6bdf38ff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a3f82efa7d588267eb2e52c06a73c56315c636a 9cadb71c0034a9856e76f6eacd6f6afdc13f48ff && git checkout -B drift-repro 6a3f82efa7d588267eb2e52c06a73c56315c636a && git merge --no-ff 9cadb71c0034a9856e76f6eacd6f6afdc13f48ff

node scripts/docs-audit/affected-docs.mjs --json 6a3f82efa7d588267eb2e52c06a73c56315c636a

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

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/m tests tooling

Projects

None yet

2 participants