Skip to content

Commit 48d5a1c

Browse files
os-zhuangclaude
andauthored
feat(runtime,client): route ledger + conformance guard for the dispatcher↔client surface (#3563) (#3569)
* feat(runtime): route ledger + conformance guard for the dispatcher↔client surface (#3563) #3528's root-cause class — a route that exists and works while @objectstack/client has no way to express it — now has an inventory and a ratchet. route-ledger.ts records the audited disposition of every dispatcher route: sdk (with the client method named) / gap / server-only / public / dynamic / mismatch. The audit found 27 gaps across 6 domains — /actions is entirely unreachable from the SDK (the largest hole), /keys has no SDK path to mint an API key, /share-links and /security have zero expression, /packages exposes 6 of 17 lifecycle routes, plus 4 shape mismatches where the client speaks REST dialect at dispatcher-only routes. route-ledger.conformance.test.ts is the guard, and it bites in both directions (each verified to fail when broken): - a dispatcher domain registered with no ledger entry fails — a new route surface cannot land without a reviewed SDK disposition; - a ledger entry claiming a client method that does not exist fails — the ledger cannot claim coverage the SDK does not have; - the gap count is ratcheted at 27 — closing a gap lowers it, raising it is a reviewed decision. The client is imported as a built artifact via a relative path deliberately: a runtime→client package edge would close a turbo build cycle (client's own devDeps point back at runtime for its Hono tests). A vitest subpath alias for @objectstack/core/logger makes that dist resolvable under the test aliases. Full findings — including the stale DEFAULT_DISPATCHER_ROUTES spec table that CLIENT_SPEC_COMPLIANCE.md anchors its "FULLY COMPLIANT" claim on, mount-less dispatch branches, the RealtimeAPI stub, six phantom methods in the client README, five surfaces with zero tests, and the un-audited @objectstack/rest second surface — live in docs/audits/2026-07-dispatcher-client-route-coverage.md with proposed follow-up slicing. No runtime behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 * fix(runtime,client): split the ledger guard along the package boundary — CI has no client dist in runtime's test task Test Core failed on the first push: the conformance test imported the built client (`../../client/dist/index.mjs`), assuming the dist exists whenever tests run. Wrong assumption — turbo's per-package test tasks build only their own dependency closure, and runtime deliberately has no edge to client (the reverse edge exists, so adding one is a build cycle turbo rejects). In CI the dist therefore never exists for runtime's suite. The fix is structural, not a skip: each package now verifies its own half. - runtime keeps the dispatcher directions (registry domain ↔ ledger, both ways) plus ledger hygiene and the gap ratchet — no client import at all. - client gains `route-ledger-coverage.test.ts`, which instantiates the real ObjectStackClient and asserts every ledger-named method resolves to a function. The ledger is imported as a relative SOURCE file: it is pure data with zero imports, and it lives in runtime because that is where routes are declared. - the now-unneeded @objectstack/core/logger vitest alias is reverted. Skipping when the dist is missing was rejected: Test Core would never exercise the client direction, and the guard's whole point is that this failure mode cannot go quiet again. Verified: phantom-method negative check bites in the new home; client suite 117 passed, runtime suite 653 passed; eslint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c8cfd1 commit 48d5a1c

6 files changed

Lines changed: 497 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/client": patch
4+
---
5+
6+
Route ledger + conformance guard for the dispatcher↔client surface (#3563)
7+
8+
#3528's root-cause class — a route that exists and works while
9+
`@objectstack/client` has no way to express it — now has an inventory and a
10+
ratchet. `route-ledger.ts` records the audited disposition of every dispatcher
11+
route (sdk / gap / server-only / public / dynamic / mismatch);
12+
The guard is split along the package boundary (a runtime→client edge is a
13+
build cycle): runtime's `route-ledger.conformance.test.ts` fails when a
14+
dispatcher domain lands with no ledger entry and ratchets the audited gap
15+
count (27 at PR-1); client's `route-ledger-coverage.test.ts` fails when a
16+
ledger entry claims a client method that doesn't exist. Findings and follow-up slicing live
17+
in `docs/audits/2026-07-dispatcher-client-route-coverage.md`. No runtime
18+
behavior change.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Dispatcher ↔ client route-coverage audit (#3563)
2+
3+
**Date:** 2026-07-27 · **Trigger:** #3528#3552 · **Machine ledger:** `packages/runtime/src/route-ledger.ts` · **Guard:** `packages/runtime/src/route-ledger.conformance.test.ts`
4+
5+
#3528 shipped because a route existed, worked, and was documented while the SDK
6+
had no way to call it (`automation resume`, fixed in #3552). This audit asks how
7+
many more instances of that class exist. Answer: **27 dispatcher routes with no
8+
SDK expression, across 6 domains — plus a second, larger un-audited surface in
9+
`@objectstack/rest`.**
10+
11+
The audit is three-column, not two. A route can fail to reach users at three
12+
distinct layers, and instances of all three were found:
13+
14+
```
15+
dispatch() branch ↔ HTTP mount (dispatcher-plugin.ts) ↔ client method
16+
```
17+
18+
## 1. Coverage by domain (dispatcher surface)
19+
20+
Full per-route dispositions live in the ledger; this is the shape of it.
21+
22+
| Domain | Routes | sdk | gap | other |
23+
|---|---|---|---|---|
24+
| `/automation` | 15 | 12 | 3 (`/actions`, `/connectors`, `/_status`) ||
25+
| `/packages` | 17 | 6 | 11 (publish/drafts/commits/revert/rollback/export/adopt/duplicate/edit) ||
26+
| `/meta` | 8 | 4 | 3 (`published`, `_drafts`, FSM states) | 1 server-only |
27+
| `/data` | 6 | 6 |||
28+
| `/actions` | 3 | 0 | **3 — the largest functional hole** ||
29+
| `/share-links` | 5 | 0 | 3 | 2 public |
30+
| `/security` | 3 | 0 | 3 ||
31+
| `/keys` | 1 | 0 | 1 (no SDK path to mint an API key at all) ||
32+
| `/i18n`, `/notifications` | 6 | 6 |||
33+
| `/analytics` | 3 | 1 || 2 **mismatch** (§4) |
34+
| `/storage` | 2 | 0 || 2 **mismatch** (§4) |
35+
| `/ui` | 1 | 1 (as `meta.getView`) |||
36+
| `/auth`, `/ai`, `/mcp*`, probes, discovery, openapi |||| wildcard / dynamic / server-only |
37+
38+
`engine.registerAction`-registered actions (`POST /actions/...`, three shapes,
39+
`http-dispatcher.ts:1951-1953`) are entirely unreachable from the SDK — every
40+
console today hand-rolls `fetch` for them.
41+
42+
## 2. Reachability: dispatch branches with no HTTP mount
43+
44+
The dispatcher has **no catch-all**; `dispatcher-plugin.ts` mounts routes
45+
explicitly, so a `dispatch()` branch without a mount is dead over HTTP in a
46+
plain runtime (`dispatcher-plugin.routes.test.ts:9-14` records `/mcp`, `/keys`,
47+
`/ready` shipping broken exactly this way). Currently mount-less:
48+
49+
- `/security/*`, `/share-links/*`, `/ui/view/*`, `/meta/*`, `/data/*`,
50+
`/openapi.json`, `/automation/actions|connectors|_status`, and the i18n
51+
query-param variants.
52+
53+
They are reachable only where `@objectstack/rest` or a host-mounted catch-all
54+
(cloud) fronts the dispatcher — noted in the domain files themselves
55+
(`domains/security.ts:13-15`, `domains/share-links.ts:11-14`). The
56+
`route-parity.integration.test.ts` gate (#3369) probes a hardcoded list of six
57+
paths; extending its probe list from the ledger is the natural follow-up.
58+
59+
## 3. The stale spec route table
60+
61+
`DEFAULT_DISPATCHER_ROUTES` (`packages/spec/src/api/dispatcher.zod.ts:141-164`)
62+
is consumed by **nothing in `packages/runtime`** — only by its own tests and
63+
`api-surface.json`. It lists `/workflow` and `/realtime` (no dispatcher branch
64+
exists; `/realtime` is deliberately never advertised,
65+
`http-dispatcher.ts:1128-1133`) and omits `/keys`, `/mcp`, `/mcp/skill`,
66+
`/actions`, `/security`, `/share-links`, `/ready`, `/openapi.json`.
67+
68+
Worse, `packages/client/CLIENT_SPEC_COMPLIANCE.md:14` anchors its "✅ FULLY
69+
COMPLIANT" claim on that table — compliance measured against a route list that
70+
predates five of the domains it should be measuring. Disposition: deprecate the
71+
export (removal is a spec major) and retire the compliance doc or regenerate it
72+
from the ledger.
73+
74+
## 4. Shape mismatches (client speaks REST, dispatcher speaks dispatcher)
75+
76+
| Dispatcher route | Client call | Effect |
77+
|---|---|---|
78+
| `GET /analytics/meta` (`domains/analytics.ts:49`) | `analytics.meta(cube)``GET /analytics/meta/:cube` | extra segment only REST understands |
79+
| `POST /analytics/sql` (`analytics.ts:55`) | `analytics.explain()``POST /analytics/explain` | different route name entirely |
80+
| `POST /storage/upload` (`domains/storage.ts:47`) | presigned/chunked protocol (`/upload/presigned`, `/upload/complete`, `/upload/chunked/*`) | client can't upload through a bare dispatcher |
81+
| `GET /storage/file/:id` (`storage.ts:56`) | `storage.getDownloadUrl``GET /storage/files/:id/url` | ditto for download |
82+
83+
These are ledgered as `mismatch`, not `sdk`: the method exists but does not
84+
speak the dispatcher's dialect. Reconciliation (pick one shape, alias the
85+
other) is its own follow-up.
86+
87+
## 5. Client-internal findings
88+
89+
- **`trigger` vs `execute`** hit different URLs for the same intent
90+
(`index.ts:2170` `POST /automation/trigger/:name` vs `index.ts:2283`
91+
`POST /automation/:name/trigger`).
92+
- **`client.analytics.*` skips `unwrapResponse()`** (`index.ts:533-554`) —
93+
callers get the raw envelope; every other surface unwraps.
94+
- **`ScopedProjectClient` silently drops `If-Match`** on `update`/`delete`
95+
(`index.ts:3567`, `:3608` vs unscoped `:3053`, `:3152`) — OCC capability loss
96+
in environment-scoped code, and re-expresses only 4 of 20 surfaces.
97+
- **`getRoute()` invents `views` and `permissions`** (`index.ts:3330-3331`) —
98+
not in `ApiRoutesSchema`, so discovery can never override them; `projects`
99+
(23 methods) bypasses routing entirely with hardcoded `/api/v1/cloud/*`.
100+
- **`client.events` (`RealtimeAPI`) is a non-functional stub** — an in-memory
101+
buffer nothing populates over the network (`realtime-api.ts:163-168`), while
102+
`client.realtime.*` is the real HTTP surface. Two surfaces, one working.
103+
104+
## 6. Documentation drift (all hand-written; no generator exists)
105+
106+
`packages/client/README.md` documents **six methods that do not exist**
107+
(`meta.getObject`, `views.share`, `views.setDefault`, `workflow.approve`,
108+
`workflow.reject`, `ai.chat` — the last three were deliberately removed) and
109+
claims "13 namespaces" where the SDK ships 20 (~171 methods; README says
110+
"95+"). `content/docs/api/client-sdk.mdx` names `organizations` /
111+
`projects` / `oauth` in its feature bullet and documents none of their 52
112+
methods. No script anywhere generates any of these lists.
113+
114+
## 7. Test deserts
115+
116+
Five entire surfaces have **zero test references**: `analytics`, `storage`,
117+
`projects` (23), `organizations` (23), `oauth` — 62 methods, ~36% of the SDK.
118+
(Grep across all five client test files; `tests/integration/` is additionally
119+
excluded from `pnpm test` by config.)
120+
121+
## 8. The second surface (out of ledger scope, tracked here)
122+
123+
`@objectstack/rest` mounts routes the dispatcher never sees; the client
124+
already targets some (views CRUD, permissions, workflow, approvals, realtime,
125+
notification devices/preferences, presigned storage, import jobs). Zero client
126+
expression exists for: `GET /search`, `POST /email/send`, `forms/:slug`
127+
(3 routes), record shares (`/data/:object/:id/shares*`), `POST .../clone`,
128+
`POST /analytics/dataset/query`, `sharing/rules` (5), `reports` (8),
129+
`external-datasource/*` (`rest-server.ts:4514-5734`,
130+
`external-datasource-routes.ts`). Auditing that surface with the same
131+
three-column method is the next tranche of #3563.
132+
133+
## Follow-up slicing (proposed)
134+
135+
1. **`client.actions.invoke(...)`** — closes the largest hole (3 routes).
136+
2. **`keys` / `share-links` / `security`** surfaces — 7 gaps, small and mechanical.
137+
3. **Packages lifecycle** (11 gaps) — publish/drafts/commits/revert/export.
138+
4. **Meta drafts/published/FSM + automation descriptors** (6 gaps).
139+
5. **Mismatch reconciliation** (§4) — server-side aliases or client-side fixes.
140+
6. **Docs**: delete or regenerate README surface table + CLIENT_SPEC_COMPLIANCE.md; extend client-sdk.mdx.
141+
7. **Deprecate `DEFAULT_DISPATCHER_ROUTES`**; point at the ledger.
142+
8. **REST-surface tranche** (§8) with the same ledger+guard treatment.
143+
144+
Each gap closed must flip its ledger row to `sdk` and lower the ratchet bound
145+
in the conformance test — the guard enforces both directions from PR-1 onward.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Route-ledger ↔ client-surface conformance (#3563) — the client half of the
5+
* guard whose dispatcher half lives in
6+
* `packages/runtime/src/route-ledger.conformance.test.ts`.
7+
*
8+
* Every ledger entry that names a client method must resolve to a real
9+
* function on an instantiated client — the ledger cannot claim coverage the
10+
* SDK does not have. This is the direction #3528 shipped through: the ledger
11+
* equivalent of the day would have said "resume → automation.resume" while no
12+
* such method existed.
13+
*
14+
* The ledger is imported as a relative SOURCE file deliberately: it is pure
15+
* data (no imports), and a client→runtime package edge for it would be
16+
* backwards — runtime is where routes are declared, so the ledger lives there,
17+
* and each package verifies its own half.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { ObjectStackClient } from './index';
22+
import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';
23+
24+
describe('route ledger ↔ @objectstack/client surface', () => {
25+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' });
26+
27+
const resolve = (path: string): unknown =>
28+
path.split('.').reduce<unknown>((o, k) => (o == null ? o : (o as Record<string, unknown>)[k]), client);
29+
30+
it('every ledger entry naming a client method resolves to a real function', () => {
31+
const broken = ROUTE_LEDGER.filter((e) => e.client != null)
32+
.filter((e) => typeof resolve(e.client!) !== 'function')
33+
.map((e) => `${e.route} → client.${e.client}`);
34+
expect(
35+
broken,
36+
`Ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
37+
).toEqual([]);
38+
});
39+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Route-ledger conformance (#3563) — the guard that keeps the dispatcher's
5+
* route surface and `@objectstack/client` from drifting apart silently again.
6+
*
7+
* #3528's root cause was exactly such a drift: the resume route existed and
8+
* worked while the SDK had no way to express it, and nothing failed. These
9+
* assertions make the two failure directions loud:
10+
*
11+
* 1. A domain registered on the dispatcher with no ledger entry — a new
12+
* route surface landed without a reviewed SDK disposition.
13+
* 2. A ledger entry claiming a client method that does not exist — the
14+
* ledger says "covered" but the SDK cannot make the call.
15+
*
16+
* The ledger's per-route rows are documentation; the machine contract here is
17+
* domain-level (registry introspection) plus method-level (client instance
18+
* introspection). The legacy if-chain prefixes cannot be enumerated from the
19+
* registry, so they are pinned in LEGACY_CHAIN_PREFIXES — when ADR-0076 D11
20+
* extracts another branch, that list and the ledger move together.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { HttpDispatcher } from './http-dispatcher.js';
25+
import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES } from './route-ledger.js';
26+
27+
/** Minimal kernel — enough for the constructor to register builtin domains. */
28+
function fakeKernel(): any {
29+
return {
30+
getState: () => 'running',
31+
getService: () => undefined,
32+
getServiceAsync: async () => undefined,
33+
};
34+
}
35+
36+
/** Live registry prefixes, normalized (`/keys?` query-form → `/keys`). */
37+
function registryPrefixes(): Set<string> {
38+
const dispatcher = new HttpDispatcher(fakeKernel());
39+
const routes = (dispatcher as any).domainRegistry.list() as Array<{ prefix: string }>;
40+
return new Set(routes.map((r) => (r.prefix.endsWith('?') ? r.prefix.slice(0, -1) : r.prefix)));
41+
}
42+
43+
describe('route ledger ↔ dispatcher domain registry', () => {
44+
it('every registered dispatcher domain has at least one ledger entry', () => {
45+
const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain));
46+
const missing = [...registryPrefixes()].filter((p) => !ledgerDomains.has(p));
47+
expect(
48+
missing,
49+
`Dispatcher domains with no route-ledger entry: ${missing.join(', ')}. ` +
50+
'A new route surface needs a reviewed disposition in route-ledger.ts (#3563).',
51+
).toEqual([]);
52+
});
53+
54+
it('every ledger domain is a live registry prefix or a pinned legacy branch', () => {
55+
const live = registryPrefixes();
56+
const legacy = new Set<string>(LEGACY_CHAIN_PREFIXES);
57+
const stale = [...new Set(ROUTE_LEDGER.map((e) => e.domain))].filter(
58+
(d) => !live.has(d) && !legacy.has(d),
59+
);
60+
expect(
61+
stale,
62+
`Route-ledger domains that no longer exist on the dispatcher: ${stale.join(', ')}. ` +
63+
'Remove or reclassify them so the ledger stays truthful.',
64+
).toEqual([]);
65+
});
66+
});
67+
68+
// The client-instance direction — "every named client method actually exists"
69+
// — lives in packages/client/src/route-ledger-coverage.test.ts, next to the
70+
// SDK it introspects. It cannot live here: a runtime→client edge (package OR
71+
// dist import) is unbuildable — client's own devDeps point back at runtime
72+
// (turbo rejects the cycle), and CI's per-package test tasks build only their
73+
// own dependency closure, so the client dist does not exist for this suite.
74+
describe('route ledger hygiene', () => {
75+
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
76+
const sdkWithout = ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
77+
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);
78+
79+
const bareNonSdk = ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
80+
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
81+
});
82+
83+
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
84+
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1. Closing one =
85+
// reclassify the entry to `sdk` AND lower this bound. Raising it demands
86+
// an explicit, reviewed decision to ship a new un-expressed route.
87+
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
88+
expect(gaps).toBeLessThanOrEqual(27);
89+
});
90+
});

0 commit comments

Comments
 (0)