Uh oh!
There was an error while loading. Please reload this page.
feat(cloud-connection,metadata,cli): ledger the six raw-app route mounters and guard them - #12152
Conversation
…nters and guard them Six registrars across three packages mount HTTP routes on the host Hono app's framework-native handle (`http-server` -> `getRawApp()`), so their routes sit outside the dispatcher ledger, outside `RestServer.getRoutes()`, and outside `IHttpServer.getMountedRoutes()` -- the last one by the contract's own words, "routes an adapter mounts on its framework-native handle behind `getRawApp` are outside this table by construction". None carried a reviewed disposition anywhere. Adds three per-package ledgers in the #3636 / #11863 pattern plus a guard for each. All six files are new; no existing file is touched and no route behaviour changes. The guards read package SOURCE rather than driving plugin lifecycles: every one of these registrars mounts from inside a `kernel:ready` hook behind multi-service resolutions that return quietly when a service is absent, so a lifecycle drive would fail OPEN -- observing zero mounts while every accounting assertion passed vacuously. That is the completed-census defect these ledgers exist to remove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
📓 Docs Drift CheckThis PR changes 3 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 31 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 6bc9e6659df4e182942e191d43b291ba4ac82849 && git checkout 6bc9e6659df4e182942e191d43b291ba4ac82849
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c4c59ed20caf4f6cde3c979ae9205ee69e04256 dc38f112133116ce8d8526e6fdabb93a8a5d7d57 && git checkout -B drift-repro 2c4c59ed20caf4f6cde3c979ae9205ee69e04256 && git merge --no-ff dc38f112133116ce8d8526e6fdabb93a8a5d7d57
node scripts/docs-audit/affected-docs.mjs --json 2c4c59ed20caf4f6cde3c979ae9205ee69e04256
|
…gram `packages/cli` compiles its whole `include` program rather than an entry graph, so the #11882 route ledger -- a review record read only by its own conformance test, which the test globs already exclude -- was emitted into the published tarball as ~10KB of permanently dead module. Its two sibling ledgers needed no such line only because `tsup` bundles from `src/index.ts` and never reached them; this makes cli's end state match theirs. Safe against a future import, measured rather than assumed: `exclude` filters the `include` glob but does NOT remove a file that an included file imports -- TypeScript still pulls such a file in through the module graph and emits it. So this line can under-exclude, never dangle. The ledger remains in the package's `tsconfig.json` typecheck program; only the build config drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
os-trump
commented
Aug 25, 2026
Patch round — the dist-emission question is closed by ruling: option B.
The one semantic worth checking rather than trusting, because the whole change rests on it:
So this line can only ever under-exclude; it cannot leave a dangling reference. If shipped code Re-verification, all grepped rather than inferred:
Gates re-derived and re-run on Unchanged as instructed: still draft, Generated by Claude Code Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11882
Six registrars across three packages mount HTTP routes on the host Hono app's framework-native
handle (
http-server→getRawApp()). Those routes sit outside the dispatcher ledger, outsideRestServer.getRoutes(), and outsideIHttpServer.getMountedRoutes()— the last one by thecontract's own words, "routes an adapter mounts on its framework-native handle behind
getRawAppare outside this table by construction" (packages/spec/src/contracts/http-server.ts).None carried a reviewed disposition anywhere.
This adds three per-package ledgers in the #3636 / #11863 pattern plus a guard for each.
Six new files, plus one line-level change to
packages/cli/tsconfig.build.json(added in apatch round on a maintainer ruling — see Changeset below; the file-surface extension was
authorized for that one file). No route behaviour changes.
Scope reading: "six ledgers" = three per-package ledgers covering the six mounters
Triage said "author the six ledgers per the #3636/#11863 pattern"; the claim's file surface said
"new per-package route-ledger files … in
packages/cloud-connection/**,packages/metadata/**,packages/cli/**". Those resolve the same way: #3636 is a per-PACKAGE pattern, and the sixmounters live in three packages (
cloud-connectionowns four of them). So the deliverable is threeledgers covering six mounters, not six ledger files. Flagged explicitly rather than silently
picking one reading.
Population, re-verified against
origin/main@2ba4329e— nothing droppedThe card's per-file table reproduced exactly, line numbers included. Nothing gained a ledger
since filing, so no mounter drops out. Every route below is a new row.
packages/cloud-connection— 16 routes, 4 registrarscloud-connection-plugin.ts(:231,276,335,438,484,531,592,634)GET /status,POST /bind/start,POST /bind/poll,POST /unbind,POST /install,GET /installation,GET /installed,GET /org-packages— all under/api/v1/cloud-connectionserver-onlymarketplace-install-local-plugin.ts(:246-251)POST+GET /api/v1/marketplace/install-local,DELETE …/:manifestId,POST …/:manifestId/{reseed,purge}-sample-dataserver-onlymarketplace-proxy-plugin.ts(:371)ALL /api/v1/marketplace/*publicruntime-config-plugin.ts(:875,877)GET /api/v1/runtime/config,GET /api/v1/studio/runtime-configpublicpackages/metadata— 2 routes, 1 registrarroutes/hmr-routes.ts:126,185(mounted viaplugin.ts:468) —GET+POST /api/v1/dev/metadata-events.public(SSE stream) andserver-only(build-tool loopback).packages/cli— 4 routes, 2 plugin factoriesutils/console.ts:536,540,543,608—GET /,GET /_console,GET /_console/*,GET /runtime/assets/:filename. All fourstatic-asset(see below).Total: 22 routes. One precision note, since the card is the population's own record: the body's
table is exact, but the routing comment's summary line — "
cloud-connectionalone carries 15 ofthe ~24 routes" — is off by one against that same table (8+5+1+2 = 16 of 22). The table
was right; the prose summary was not.
Two things the card's sweep did not name
marketplace-proxy-plugin.ts:374-377has a fallback arm that loopsover
['get','head']and mounts throughrawApp[m]?.(…)on the marketplace prefix pattern — acomputed member a literal grep walks straight past. It is the "unreadable mount" class
check-auth-mount-ledger.mjs's constraint 4 exists for. It is declared, not skipped, inDECLARED_COMPUTED_MOUNTS, and reconciled in both directions: the guard fails if the spellingdisappears (stale declaration) and if the
ALLrow that covers it disappears (uncoveredmount). It adds no unledgered wire path — it mounts the same pattern the
ALLrow carries.cli.commands/serve.ts:4096takes the raw app to installrawApp.use('*', …)(the unknown-hostname guard). A middleware lane is not a route, but thefile is pinned so that the day it mounts one, the guard says so rather than the route going
unledgered.
The
clifamily needed a sixth disposition, and that is the point of the cardThe routing comment and the card body both flag that the
cliconsole rows are static-assetserving, not API surface. None of the five REST-ledger words is true of them, and the nearest
one is actively misleading:
gapis the wrong kind of false — it means "should be in the SDK", is ratcheted to<= 0, andfiling a file server there would assert
@objectstack/clientought to grow a method forfetching
index.html;server-onlymeans an inbound door or loopback; these are the opposite, outbound bytes to a browser;publicis true but insufficient, which is the trap. It would file a static file serveralongside genuine anonymous API endpoints like
GET /api/v1/runtime/config, so a readerauditing the platform's unauthenticated API surface would find four rows that are not API.
check-auth-mount-ledger.mjsstates the governing rule for exactly this: "IF YOU CANNOT DECIDE,DO NOT PICK THE NEAREST ALLOWED WORD." Here the disposition is not undecided, so the ledger says
it in a word that is true:
static-asset. Precedent for a per-package vocabulary extension isplugin-auth, whoseAUTH_ROUTE_LEDGERcarries a sixth word of its own (disabled).The extension is contained and the guard enforces the containment: the type is package-local,
every
static-assetnote must name the byte-serving or redirect mechanism, and the ledger muststay static-asset-only — so an API route landing in this package cannot inherit the word by
sitting in the same file. It will fail and force a real disposition.
Guards read source, not lifecycles — and why that is the safer half
#11863's trigger-api guard drives the plugin's real lifecycle, which works because that plugin
resolves three services and mounts one route. Every registrar here mounts from inside a
kernel:readyhook behind resolutions (http.server/http-server,env-registry,kernel-manager,manifest,metadata,objectql) each guarded by atry/catchthatreturns quietly when the service is absent; the two
clifactories return early unless a builtdist/exists on disk. A lifecycle drive over that fails OPEN — it observes zero mounts andevery accounting assertion passes vacuously — precisely when a resolution changes. That is the
completed-census defect these ledgers exist to remove, so each guard reads SOURCE TEXT, the shape
check-auth-mount-ledger.mjs(#10534) established forrawAppmounts. No import, no moduleresolution, no
dist/between the edit and the reading.Each guard carries: a census-is-real limb (zero mounts is a broken scan, not a clean package);
exact accounting in both directions on
METHOD /wire/path; a population identity (not acount) so a second registrar cannot hide behind a ledger that reads as a completed census; and
hygiene + anti-vacuity — since no row is
sdk, the client-method assertion would holdvacuously, so the audit's actual finding is asserted instead (the
service-datasourcerule).Two package-specific limbs:
metadata— theoptions.pathseam.registerMetadataHmrRoutesaccepts a path override.The rows are exact only because the seam is unreachable: not re-exported from
index.ts/node.ts, and the sole in-repo caller passes no options. Both halves are asserted, so the rowsstop being the whole truth loudly the day either changes.
cli— the prefix pair.GET /_consoleis a strict prefix ofGET /_console/*, so thispackage contains the very relation auth-mount coverage — follow-ups 1/2/4 LANDED; remaining scope is follow-up 3 (sdk bridge) + the #10975 ledger row (body below is the original census, now historical) #10534's own census got wrong (it read 5 when the truth was
6). Both are ledgered as distinct rows and the ablation below proves the boundary holds.
No row is
sdk, measured rather than assumed@objectstack/clientwas grepped for all five families —cloud-connection,marketplace,runtime/config,install-local,metadata-events. One hit in the entire package, and it is adoc comment (
index.ts:1526, describing a payload shape "the same shapemarketplace-install-localconsumes"). No client method builds any of these URLs.
The live half is enforced next door by omission: none of these ledgers is added to
client-url-conformance.test.ts's union, so a client method that started calling one of theseroutes would fail there for matching no ledger row at all. Adding them would remove exactly that
protection.
Named non-SDK callers, which is what the
server-onlynotes rest on rather than intent alone:packages/cli/src/commands/package/install.ts:170composes${runtime}/api/v1/marketplace/install-localdirectly, andpackages/cli/src/commands/dev.ts:553documents the
metadata-eventsPOST as the endpoint it drives.Verification
The final commit on this branch is
669232cdc(the patch-roundtsconfig.build.jsonexclusion). The ledger and guard results below were measured on
fee81b753, whose six filesthat patch does not touch; the gate union was re-derived and re-run on
669232cdc, and thebuild/typecheck/guard re-verification for the exclusion is recorded in Changeset above. Nothing
in this section was carried over unmeasured.
Reverse verification — direction predicted in writing before the run
Predicted: removing one row reddens the accounting limb naming that exact route; for
cliIremoved
GET /_console, the strict prefix, predicting two failures if the right-boundaryproperty holds. Observed, under an unconditional
trap … EXIT INT TERM:GET /api/v1/cloud-connection/status1 failed | 17 passedPOST /api/v1/dev/metadata-events1 failed | 18 passedGET /_console2 failed | 15 passed— accounting and the prefix-pair pinThe mutation was observed on disk, never inferred from an editor's exit code: the anchored
regex reported
substitutions=1per file, the removed route literal went1 → 0in each, andgit diff --numstatshowed0 10/0 11/0 16. Findings name routes by identity with realline numbers —
GET /api/v1/cloud-connection/status (cloud-connection-plugin.ts:231),POST /api/v1/dev/metadata-events (routes/hmr-routes.ts:185),GET /_console (utils/console.ts:540)— not a count.No rebuild leg applies, and that is stated rather than skipped: each guard imports its ledger
as a relative SOURCE module inside the same package, so no
exports/distresolution sits betweenthe edit and the reading. Restore was from
HEADand verified three ways:git status --porcelainempty, and disk blob ==
HEAD:blob for all three files.A defect the guards caught in themselves, kept as a pin:
stripCommentspreserves string contents(the census resolves wire paths out of them), so a structural count of
getRawApp()call sites read2 where the truth is 1 — the second occurrence was inside a log message.
maskStringsfixes itand pins both directions. Block-comment newlines are likewise preserved, or every finding would
point ~30-80 lines short of the real mount.
Suites
pnpm --filter @objectstack/cloud-connection testTest Files 27 passed (27)·Tests 299 passed (299)pnpm --filter @objectstack/metadata testTest Files 34 passed (34)·Tests 637 passed (637)pnpm --filter @objectstack/cli typechecktsc --noEmit, exit 0 (script name echoed — not a zero-match no-op)pnpm --filter @objectstack/cli testTests 1 failed | 2038 passed (2039)— one failure, diagnosed and cleared belowThe three new guards contribute 54 assertions (18 + 19 + 17).
The one
clifailure was an unbuilt-worktree artifact, and it is now cleared rather than merelyargued away.
test/serve-node-env-production-default.e2e.test.ts(#11113,NODE_ENV/trusted-origin) spawns a server and probes it over a port. It failed differently on each run —
first
command serve not found(this worktree had nopackages/cli/distyet), then, after buildingcli,TypeError: fetch failed … ECONNREFUSED 127.0.0.1:58370while a sibling agent'sturbo run build --filter=@objectstack/cliheld the shared container. Two different failures fromone unchanged tree is the signature of environment contention, not a regression. Re-run on the fully
built workspace:
Test Files 1 passed (1)·Tests 3 passed (3). These six files are inert —nothing imports the ledgers except their own guards.
Gates — derived, not recalled
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived on the finalcommit
669232cdc(provenance line: derived from the tree of 'objectstack-ai/objectstack' atcommit
669232cdc; 7 paths vs merge base2ba4329e4, three-dot — the addedtsconfig.build.jsonmatched families already in the union and introduced no new one). Every gatebelow was re-run on that commit. Exit codes captured before any pipe; each verdict is the
gate's own line.
check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declaredcheck:published-filesOK — 69 publishable package(s) … admits no test, test-harness config or build scriptcheck:slot-lookupratchet holds: 107 unswept site(s) … none new·baseline verified against 2ba4329: no files addedcheck:test-source-aliasOK — 72 packages with tests scanned(+--self-test OK)check:type-source-resolutionOK — 93 tsc program(s) across 77 packages scanned(+--self-test OK)check:durability-log-level✓ read-seam invention … 66 read seam(s), none invents an unreported answercheck:plugin-teardown-shape✓ 63 Plugin implementation(s) across 4682 source(s) … baseline fully burned downcheck:ci-filter-parityOK: all 96 declared cross-package glob(s) … coveredcheck:nul-bytesOK (scanned 6725 text file(s) … no raw ASCII control bytes)(+ 75 self-test assertions)check:query-options-erasureat the ceiling·baseline verified against 2ba4329: no files addedcheck:type-check-coveragetsconfig.jsonprogram)check:engine-double-contract390 (file, verb) row(s) held by the RETAINED ledgercheck:where-matcher0 silently-wrong and 0 unjudged matcher(s) … none newcheck:i18ncheck-i18n-bundles: OK (9 package(s) — all bundles in sync)check:i18n-coverageOK (12 config(s), 657 baselined untranslated string(s), none new)check:type-check-debt(--re-measure)OK — 32 ledger entr(ies) re-measured in 266.2s, 1897 raw tsc error(s) total, none above its recorded number— the same 1897 as the pre-patch run, so the exclusion moved no countdocs-audit/check-affected-docs·check-drift-comment✓ 56 cases pass across 5 fixture diff(s)On the two gates that first refused — both are real measurements, not narrowings.
check:i18n-coverageinitially returnedCOULD NOT MEASURE — 1 of 12 config(s) failed to lint(
examples/app-showcasecould not resolve built dists:@objectstack/connector-mcp, then@objectstack/connector-openapi). NOT MEASURED is not a pass, so rather than record it as oneI built the workspace and re-ran it; it is green above. Same for
check:type-check-debt, whose--re-measureneeds the built closure. Its surplus note names@objectstack/plugin-approvals(TEST_DEBT records 348, tsc now reports 347) — pre-existing, unrelated to this diff, and already
being handled in open PR #12110.
check:i18n-coverageis worth calling out on the patch round specifically: it drives the builtCLI (
scripts/cli-build-prerequisite.mjs), so it is the gate that would notice if excluding afile from the cli build broke that build's output. It was re-run on
669232cdcagainst a fullyrebuilt workspace and is green, which exercises the exclusion end-to-end rather than only at the
ls distlevel.The one thing genuinely not run here is repo-wide
pnpm lint. No narrowing is claimed for iteither — it is simply CI's run, and this PR asserts nothing about it.
Changeset: none, carrying
skip-changeset— and the cli ledger no longer ships#11885 (the trigger-api ledger, merged) landed with
skip-changesetand no changeset on thegrounds that the ledger is package-internal and
tsupnever emits it. That argument held for twoof the three packages here and not for the third, so it was measured per package rather than
inherited — and the gap it exposed has now been closed rather than merely disclosed.
packages/clicompiles its wholeincludeprogram instead of an entry graph, so its ledger wasemitted into the published tarball (
dist/utils/console-route-ledger.js2.8 KB +.d.ts5.9 KB +maps, ~10 KB of permanently dead module). Maintainer ruling: exclude it — a published binary
package should not carry ~10 KB nothing reads, and the exclusion makes cli's end state match its
two siblings rather than inventing a new posture.
packages/cli/tsconfig.build.jsonnow listssrc/utils/console-route-ledger.tsalongside the test globs it already excluded.dist/?cloud-connectiontsup,entry: ['src/index.ts']metadatatsup, 4 entriesclitsc -p tsconfig.build.jsonThe exclusion is safe against a future import, and that is measured, not assumed.
excludefilters the
includeglob; it does not remove a file that an included file imports — TypeScriptstill pulls such a file in through the module graph and emits it. Proven in an isolated scratch
project, both directions: a file that is excluded and imported by an included file is still
emitted (
excluded.js+excluded.d.tspresent,tscexit 0); the same file excluded andimported by nothing is not emitted. So this line can under-exclude, never dangle. In this repo
the only importer is
console-route-ledger.conformance.test.ts, which the test globs alreadyexclude — verified by grep across
packages/cli/srcandpackages/cli/test.Verification of the change itself, all grepped rather than inferred:
tsc --listFilesOnlyon the build config: ledger in the program 1 → 0, withsrc/utils/console.tsstill present as the positive control (489 files total), so the exclusion is targeted, not broad;
pnpm --filter @objectstack/cli buildfrom a removeddist/→ exit 0, anddist/utils/console-route-ledger.*absent, whiledist/utils/console.js(25,874 bytes) isemitted;
CONSOLE_ROUTE_LEDGERappears nowhere underpackages/cli/dist/. Re-confirmed absentafter a full
./packages/**workspace build;pnpm --filter @objectstack/cli typecheck→ exit 0, zeroerror TSlines. The ledger is stilltype-checked: it remains in the package's
tsconfig.jsonprogram (2 hits including its test),so only the build config drops it and
check:type-check-coveragedoes not see an orphan;must be untouched — all three guards re-ran green, 18 + 19 + 17 = 54 assertions.
Judgment: still no changeset, and now on stronger ground than before — with the exclusion in
place nothing user-visible ships in any of the three packages, and no
dist/byte moves at all.Governed surfaces
skills/**,docs/adr/**,.claude/**,AGENTS.md,CLAUDE.md,content/docs/releases/**andpackages/specare untouched. No route is added, removed, or behaviour-changed; no public APIsurface moves. #11050's detector is untouched — these routes leaving its
unmatchedlisting is aconsequence of the ledgers existing, not a change to it.
Filed alongside, not fixed here
#12140 —
POST /api/v1/dev/metadata-eventscarries no authentication andMetadataPluginapplies no environment gate of its own; the only
isDevguard in the tree is on the CLI'ssupplementary composition in
serve.ts, and the plugin's own comment states the posture as aprediction about callers ("production deployments simply won't have a CLI POSTing to this
endpoint") rather than as a gate. Surfaced by writing that row's disposition — which is what these
ledgers are for. Filed unassigned for triage; out of scope here, and the ledger row records the
posture without changing it.
Generated by Claude Code