Skip to content

Commit 8e9abd9

Browse files
test(runtime): route-ledger conformance checks that every route the dispatcher plugin mounts has a ledger row (#26565)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFTvndcbx3e4rrGbDvfcLXh)_ Fixes #26513 ## What changes `packages/runtime/src/route-ledger.conformance.test.ts` gains a per-route arm: **every route `dispatcher-plugin.ts` mounts on its host `IHttpServer` must have a `ROUTE_LEDGER` row.** The mounted side is recorded, never typed: the plugin's own `start()` runs against a recording server (the same technique as `automation-api-contract-mounts.test.ts`), and the recorded `VERB /path` list is the population. No count is written anywhere. - **Composition.** One start, in the plugin's widest composition, so as few mounts as possible go unobserved: no REST plugin on the kernel (the plugin keeps `/discovery`; it cedes that route when REST is present), a serveable `analytics` service (the capability-conditional `/analytics` routes mount), and `scoping.enableProjectScoping: true` under the default `projectResolution: 'auto'` (every environment-scoped twin mounts beside its unscoped route). The other compositions are strict subsets. A vacuity test asserts the boot actually reached those three branches. - **Matching.** An exact `VERB /api/v1/pattern` row, or a `* /prefix/**` family row whose base the mount sits at or under. That is the same reading the live-mount parity gate gives a wildcard row. A scoped twin answers to its unscoped row, because the plugin mounts it by calling the same registrar with the scoped base and `dispatch()` strips the scope before routing. - **Not exercised:** routes a service emits at runtime on the `ai:routes` hook. They are dynamic by construction, and the `* /ai/**` family row speaks for them. The plugin's own `/ai/*` mounts are recorded. - **Why this enumeration.** It is the cheapest complete one: it needs no booted server and no production listing method, and it observes every registration the plugin makes. The measured mount table is 124 routes. No `@objectstack/runtime` code changes; this PR adds no listing method. - **#17041 references.** The three dead `#17041` pointers (the `route-ledger.ts` census header, this suite's header, `scripts/check-route-ledger-census.mjs`) are re-pointed to #26513, and each says what the new arm does and does not cover. **#17041's open question is left as it is**: whether the ledger should be checked per route *beyond what the plugin mounts* stays an open maintainer question, now tracked at #26513. - **`check-route-ledger-census` stays.** Whether it retires is #26431's call, after this lands. It is still the only per-row guard for the rows the arm cannot see (next section). - The `route-ledger.ts` top header said this suite fails "when an entry claims a client method that does not exist". That direction lives in the client package's guards, so the sentence now says so. ## Why Deleting a single mid-ledger row left this suite green. Only the hand-kept row count noticed, and that count is the transcription-pin shape #26431 retires. ## What the arm guards, measured Per-row ablation over the recorded mount table: **71 of 105 rows** now red this suite when deleted. The other 34 cannot, because they have no mount of their own spelling: - routes only `dispatch()` answers behind a catch-all host (the hono adapter's `app.all(prefix/*)`): every `/meta` and `/data` row, `/share-links`, `/security/suggested-bindings`, `/ui/view`; - doors a plugin serves from the raw app on a self-hosted kernel: `/approvals/act`, `/webhooks/redeliver`, `/automation/hooks/:flowName/:hookId`; - the two `servedBy` specializations: `POST /actions/global/:action`, `POST /actions/_activation/:object/:action`; - family rows (`* /auth/**`, `* /apps/**`, `* /share-links/**`) and `GET /mcp/skill`, which `* /mcp/**` absorbs. ⚠️ **This includes all three rows the card named.** `GET /meta/types` is not mounted by the plugin; on `objectstack serve` REST mounts it, and on a catch-all host `dispatch()` resolves it inside the `/meta` domain's inline if-chain. The two `/actions` rows are `servedBy` specializations that are registered nowhere. So a mount-derived arm cannot red on them. Turning those red would need route-granularity enumeration of `dispatch()` itself, which today exists only as inline `parts[N] === 'literal'` code. The order's red-first used a bridge-mounted row instead (Tests below). The seat has the question of the remaining 34 rows in the report. Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:运行时路由台账的一致性测试新增一条按路由的反向检查:分派插件实际挂载的每条路由,都必须在台账里有一行。 - **为什么改**:以前删掉台账中间某一行,这个测试照样全绿,只有手写的行数计数会报错(#26431 要退役的那种)。 - **风险与回滚**:只改测试和注释,不改产品代码,不发布。回滚就是 revert 这个 PR。 - **先看**:`packages/runtime/src/route-ledger.conformance.test.ts`(新增的 `dispatcherPluginMounts` 和 `hasLedgerRow`),`packages/runtime/src/route-ledger.ts` 的 CENSUS 注释段。 - **两分钟验证**:从 `route-ledger.ts` 删掉 `GET /automation/actions` 这一行,跑 `pnpm --filter @objectstack/runtime exec vitest run --project local src/route-ledger.conformance.test.ts`,应看到 1 个失败,并点名 `GET /api/v1/automation/actions`。 - **需要你做的**:无,席位落地。 ## Tests Red first on `main` `16ca320c92` vs this head. Ablation through `scripts/ablation-replace.mjs`, deleting the row `GET /automation/actions` (mid-ledger, in a domain with siblings). The mutation landed (anchor 1 → 0) and was restored to the HEAD blob with `git diff HEAD` empty both times. The ablation is not committed. <details><summary>Ablation readings</summary> - **`main`, row deleted:** `route-ledger.conformance.test.ts` 6/6 passed (exit 0). The old suite cannot see it. - **This head, row deleted:** 1 failed, 7 passed (exit 1): `Dispatcher mounts with no route-ledger row: GET /api/v1/automation/actions, GET /api/v1/environments/:environmentId/automation/actions.` - **This head, `GET /meta/types` deleted (the card's example):** 8/8 passed (exit 0), as measured above: that row has no mount to derive from. - **This head, unmutated:** 8/8 passed. </details> Test budget: no server is booted. The arm adds one `createDispatcherPlugin().start()` against a recording server, shared by both tests through `beforeAll`. Suite wall: 15.45 s on `main`, 16.72 s here (shared box; almost all of it is the `import` phase, 15.28 s vs 16.54 s, from loading `dispatcher-plugin.ts`). Test time is 12 ms vs 16 ms. ## Acceptance notes - The arm guards what the plugin mounts. Rows only `dispatch()` answers stay census-guarded, as listed above. - No production listing method was added. The order allowed one only if no complete enumeration existed without it, and the recording server is complete for the mount. A listing of `dispatch()`'s inline arms would be a hand-written second ledger, not derived from anything that routes, so it is not offered here. ## Verification Run on `2eae6bd3`, the final head. <details><summary>Local runs</summary> - `pnpm --filter @objectstack/runtime test` (under `os-verify-lock`): Test Files 457 passed (457), Tests 7424 passed | 19 skipped. Exit 0. - `pnpm --filter @objectstack/runtime typecheck`: exit 0 (`check:test-typecheck: OK`). - `eslint --no-inline-config` on the three touched files: exit 0, no findings. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 70 commands, each exit code captured before any pipe. 68 exit 0, including `pnpm check:route-ledger-census` (its `--self-test`, then `reads 105, array holds 105`). 2 exit 3, **NOT MEASURED** (each gate refused its own prerequisite; CI runs both): `node scripts/check-plugin-teardown-shape.mjs --self-test` (the positive control is pinned to a commit this shallow clone cannot reach) and `pnpm check:dual-build-cjs-loads` (needs a full `pnpm build`). `--ran`: `✓ 70 derived famil(ies) accounted for — 68 run, 2 NOT-MEASURED`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the touched files). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. Nothing publishes (test, a runtime-internal module's comment, a repo script), so `skip-changeset`. Both declaration lines are filled in. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the lane's required Chinese summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5oFmiRCo3V9eo4wD3y --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oFmiRCo3V9eo4wD3y)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 71a4b4e commit 8e9abd9

3 files changed

Lines changed: 152 additions & 27 deletions

File tree

‎packages/runtime/src/route-ledger.conformance.test.ts‎

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,41 @@
1919
* registry, so they are pinned in LEGACY_CHAIN_PREFIXES — when ADR-0076 D11
2020
* extracts another branch, that list and the ledger move together.
2121
*
22-
* These are domain-level checks, not route-level ones, and worth naming
23-
* precisely so nobody reads this suite's green as "every route has a row".
22+
* The registry checks are domain-level, not route-level, and worth naming
23+
* precisely so nobody reads their green as "every route has a row".
2424
* "At least one ledger entry" is satisfied the moment any row exists for a
2525
* domain — most domains here carry several routes, but a few carry exactly
2626
* one, where domain- and route-granularity coincide only by accident. For a
2727
* multi-route domain, any non-last row can be deleted, or never added,
28-
* without turning anything red. That is not a slip in these six assertions —
29-
* it is what the registry exposes: `DomainHandlerRegistry.list()` returns
28+
* without turning them red. That is not a slip in those assertions — it is
29+
* what the registry exposes: `DomainHandlerRegistry.list()` returns
3030
* `{prefix, match, methods, handler}` per domain, with no sub-route field to
3131
* walk, and a busy domain like `/automation` resolves its own routes through
3232
* one function's inline `parts[N] === 'literal'` if-chain rather than a table
3333
* anything here could enumerate. An ablation on #17038 measured the
3434
* consequence directly: deleting a whole `ROUTE_LEDGER` row left this suite
3535
* fully green, because the row's domain still held "at least one" through its
36-
* siblings. Whether the ledger should also be checked at route granularity —
37-
* and how, given the registry's shape above — is an open maintainer decision
38-
* tracked in #17041; this paragraph states today's coverage, not a plan for
39-
* tomorrow's. Deliberately no row/domain counts here: a hand-typed number in
40-
* this prose is exactly the unguarded-count defect this lane spent the same
41-
* day eliminating elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would
36+
* siblings.
37+
*
38+
* The route-granularity arm (#26513) reads a different source: the routes
39+
* `dispatcher-plugin.ts` registers on the host `IHttpServer`, recorded as the
40+
* plugin starts. That table is complete for what the plugin MOUNTS, so a
41+
* mounted route whose row is deleted now reds here. It cannot see a row with
42+
* no mount of its own spelling — a route only `dispatch()` answers behind a
43+
* catch-all host (`/meta`, `/data`, …), a `servedBy` specialization, a door a
44+
* plugin serves from the raw app — so those rows are still guarded per row by
45+
* `check:route-ledger-census` alone. Whether the ledger should be checked per
46+
* route beyond the mount is an open maintainer question, left open and tracked
47+
* at #26513 (the issue it was first filed under no longer resolves).
48+
* Deliberately no row/domain counts here: a hand-typed number in this prose is
49+
* exactly the unguarded-count defect this lane spent the same day eliminating
50+
* elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would
4251
* ever notice it going stale.
4352
*/
4453

45-
import { describe, it, expect } from 'vitest';
54+
import { describe, it, expect, beforeAll } from 'vitest';
4655
import { HttpDispatcher } from './http-dispatcher.js';
56+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
4757
import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES, NON_DISPATCH_MOUNT_PREFIXES } from './route-ledger.js';
4858

4959
/** Minimal kernel — enough for the constructor to register builtin domains. */
@@ -93,6 +103,109 @@ describe('route ledger ↔ dispatcher domain registry', () => {
93103
});
94104
});
95105

106+
/** The wire prefix `route-ledger.ts` documents for its non-`absolute` rows. */
107+
const LEDGER_WIRE_PREFIX = '/api/v1';
108+
109+
/** The base the plugin mounts an environment-scoped twin under (`dispatcher-plugin.ts`). */
110+
const SCOPED_PREFIX = `${LEDGER_WIRE_PREFIX}/environments/:environmentId`;
111+
112+
/**
113+
* Every `VERB /path` the dispatcher plugin registers on its host, recorded as
114+
* it starts. Mounts nothing; the plugin's own `start()` is the only author.
115+
*
116+
* The composition is the WIDEST the plugin has, so as few mounts as possible
117+
* go unobserved:
118+
* - no REST plugin on the kernel, so the plugin keeps `/discovery` (it cedes
119+
* that one route when REST is present — a strict subset);
120+
* - a serveable `analytics` service, so the capability-conditional
121+
* `/analytics` routes mount;
122+
* - environment scoping on, under the default `projectResolution: 'auto'`, so
123+
* every scoped twin mounts beside its unscoped route (`'required'` drops
124+
* the unscoped ones — again a strict subset).
125+
* Not exercised: routes a service emits at runtime on the `ai:routes` hook.
126+
* They are dynamic by construction and the `* /ai/**` family row speaks for
127+
* them; the plugin's own `/ai/*` mounts ARE recorded here.
128+
*/
129+
async function dispatcherPluginMounts(): Promise<string[]> {
130+
const mounts: string[] = [];
131+
const record = (verb: string) => (path: string) => {
132+
mounts.push(`${verb} ${path}`);
133+
};
134+
const server = {
135+
get: record('GET'),
136+
post: record('POST'),
137+
put: record('PUT'),
138+
delete: record('DELETE'),
139+
patch: record('PATCH'),
140+
use: () => {},
141+
};
142+
const services: Record<string, unknown> = { analytics: {} };
143+
const kernel = {
144+
getService: (name: string) => services[name],
145+
getServiceAsync: async (name: string) => services[name],
146+
};
147+
const plugin = createDispatcherPlugin({
148+
securityHeaders: false,
149+
scoping: { enableProjectScoping: true },
150+
});
151+
await plugin.start?.({
152+
getKernel: () => kernel,
153+
getService: (name: string) => (name === 'http.server' ? server : services[name]),
154+
environmentId: undefined,
155+
logger: { info() {}, warn() {}, error() {}, debug() {} },
156+
hook: () => {},
157+
on: () => {},
158+
} as any);
159+
return mounts;
160+
}
161+
162+
/**
163+
* Whether `mount` has a ledger row: its exact `VERB /pattern` under the wire
164+
* prefix, or a `* /prefix/**` family row whose base it sits at or under — the
165+
* same reading the live-mount parity gate gives a wildcard row. A scoped twin
166+
* answers to its unscoped row: the plugin mounts it by calling the same
167+
* registrar with the scoped base, and `dispatch()` strips the scope before
168+
* routing, so the ledger's dispatcher-internal `cleanPath` is the same route.
169+
*/
170+
function hasLedgerRow(mount: string): boolean {
171+
const sp = mount.indexOf(' ');
172+
const verb = mount.slice(0, sp);
173+
let path = mount.slice(sp + 1);
174+
if (path.startsWith(`${SCOPED_PREFIX}/`)) path = LEDGER_WIRE_PREFIX + path.slice(SCOPED_PREFIX.length);
175+
return ROUTE_LEDGER.some((row) => {
176+
const rsp = row.route.indexOf(' ');
177+
const rowVerb = row.route.slice(0, rsp);
178+
const rowPath = (row.absolute ? '' : LEDGER_WIRE_PREFIX) + row.route.slice(rsp + 1);
179+
if (rowPath.includes('*')) {
180+
const base = rowPath.replace(/\/\*+$/, '');
181+
return (rowVerb === '*' || rowVerb === verb) && (path === base || path.startsWith(`${base}/`));
182+
}
183+
return rowVerb === verb && rowPath === path;
184+
});
185+
}
186+
187+
describe('route ledger ← dispatcher plugin mounts, per route (#26513)', () => {
188+
let mounts: string[];
189+
beforeAll(async () => {
190+
mounts = await dispatcherPluginMounts();
191+
});
192+
193+
it('the composition reaches its conditional branches (a boot that skipped them would pass the arm below)', () => {
194+
expect(mounts.some((m) => m.endsWith(`${LEDGER_WIRE_PREFIX}/discovery`)), 'no `/discovery` mount').toBe(true);
195+
expect(mounts.some((m) => m.includes(`${LEDGER_WIRE_PREFIX}/analytics/`)), 'no `/analytics` mount').toBe(true);
196+
expect(mounts.some((m) => m.includes(`${SCOPED_PREFIX}/`)), 'no environment-scoped mount').toBe(true);
197+
});
198+
199+
it('every route the dispatcher plugin mounts has a ledger row', () => {
200+
const unledgered = [...new Set(mounts)].filter((m) => !hasLedgerRow(m));
201+
expect(
202+
unledgered,
203+
`Dispatcher mounts with no route-ledger row: ${unledgered.join(', ')}. ` +
204+
'Add the row to route-ledger.ts with its reviewed disposition (#3563), or delete the mount.',
205+
).toEqual([]);
206+
});
207+
});
208+
96209
describe('non-dispatch mounts (#5090)', () => {
97210
it('every pinned non-dispatch mount has a ledger row', () => {
98211
const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain));

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
* That was one instance of a class: at audit time, six whole domains had zero
1010
* client expression. This ledger makes the class visible and ratchets it —
1111
* `route-ledger.conformance.test.ts` fails when a dispatcher domain appears
12-
* with no ledger entry, and when an entry claims a client method that does not
13-
* exist. A new route therefore lands with an explicit, reviewed disposition or
14-
* not at all.
12+
* with no ledger entry, and (#26513) when a route the dispatcher plugin mounts
13+
* has no row; the client-side guards fail when an entry claims a client method
14+
* that does not exist. A new route therefore lands with an explicit, reviewed
15+
* disposition or not at all.
1516
*
1617
* SCOPE. Dispatcher (`http-dispatcher.ts`) routes, expressed as
1718
* dispatcher-internal `cleanPath` patterns (prepend `/api/v1` for the wire
@@ -295,11 +296,16 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [
295296
* "correct" the digits to make a build green — regenerate them, and read what
296297
* they moved by.
297298
*
298-
* It is a DELETION detector and deliberately NOT a route-granularity check
299-
* against what the dispatcher registers: whether this ledger should be checked
300-
* per route, given `DomainHandlerRegistry.list()` exposes domains and not
301-
* routes, is an open maintainer decision in #17041. A census stays correct
302-
* whichever way that is ruled.
299+
* It is a DELETION detector, not a route-granularity check. That check lives
300+
* in `route-ledger.conformance.test.ts` since #26513: every route
301+
* `dispatcher-plugin.ts` mounts must have a row, so deleting a mounted route's
302+
* row reds there. It sees only what the plugin mounts, so a row with no mount
303+
* of its own spelling (a route only `dispatch()` answers behind a catch-all
304+
* host, a `servedBy` specialization, a raw-app door) is still guarded per row
305+
* by this census alone. Whether the ledger should be checked per route beyond
306+
* the mount is an open maintainer question, left open and tracked at #26513
307+
* (the issue it was first filed under no longer resolves). A census stays
308+
* correct whichever way that is ruled; whether it retires is #26431's call.
303309
*/
304310
export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
305311
// ── well-known ────────────────────────────────────────────────────────────

‎scripts/check-route-ledger-census.mjs‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
* satisfied by a SHORTER list: for a multi-route domain, any non-last row can
3333
* be deleted with that suite fully green. That is not this gate's inference --
3434
* the suite's own header states it, out of the #17038 ablation, and #17075
35-
* landed the paragraph saying so.
35+
* landed the paragraph saying so. Since #26513 the suite also has a per-route
36+
* arm, but over what `dispatcher-plugin.ts` MOUNTS only: a row with no mount of
37+
* its own spelling (`/meta`, `/data`, `servedBy` rows, raw-app doors) is still
38+
* deletable there with the suite green.
3639
*
3740
* (b) THE CROSS-LEDGER GATES READ A UNION, WHICH ABSORBS DELETIONS.
3841
* `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`
@@ -51,17 +54,20 @@
5154
* and needs no boot: it is a set-difference against the union, and if the union
5255
* still holds the key, the mount is not reported unledgered.
5356
*
54-
* ## Why a census and NOT a comparison against registered routes (#17041)
57+
* ## Why a census and NOT a comparison against registered routes (#26513)
5558
*
5659
* The stronger instrument for this file would be a per-route cross-check
5760
* against what the dispatcher registers -- and whether the route ledger should
5861
* be checked at ROUTE granularity, given `DomainHandlerRegistry.list()` exposes
59-
* domains and not routes, is an OPEN MAINTAINER DECISION tracked in #17041.
60-
* Building that here would answer it silently. So this gate deliberately takes
61-
* the census/deletion direction instead: it notices that the list got SHORTER,
62-
* independent of what the routes are, and stays correct whichever way #17041 is
63-
* ruled. ⛔ Do not grow this file into a router cross-check; that work belongs
64-
* to #17041 and to whoever the maintainer rules should do it.
62+
* domains and not routes, is an OPEN MAINTAINER DECISION, left open and
63+
* tracked at #26513 (the issue it was first filed under no longer resolves).
64+
* Building that here would answer it silently. So this gate deliberately
65+
* takes the census/deletion direction instead: it notices that the list got
66+
* SHORTER, independent of what the routes are, and stays correct whichever
67+
* way that is ruled. ⛔ Do not grow this file into a router
68+
* cross-check: the cross-check against what the plugin mounts lives in
69+
* `route-ledger.conformance.test.ts` (#26513), and whether this census retires
70+
* once that lands is #26431's call.
6571
*
6672
* ## Why the number is GENERATED and never hand-typed
6773
*

0 commit comments

Comments
 (0)