Skip to content

Commit 5ae4a77

Browse files
committed
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
1 parent 474fe39 commit 5ae4a77

5 files changed

Lines changed: 472 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
Route ledger + conformance guard for the dispatcher↔client surface (#3563)
6+
7+
#3528's root-cause class — a route that exists and works while
8+
`@objectstack/client` has no way to express it — now has an inventory and a
9+
ratchet. `route-ledger.ts` records the audited disposition of every dispatcher
10+
route (sdk / gap / server-only / public / dynamic / mismatch);
11+
`route-ledger.conformance.test.ts` fails when a dispatcher domain lands with no
12+
ledger entry, when an entry claims a client method that doesn't exist, and when
13+
the audited gap count (27 at PR-1) grows. Findings and follow-up slicing live
14+
in `docs/audits/2026-07-dispatcher-client-route-coverage.md`. No runtime
15+
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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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+
// Relative import of the BUILT client, NOT a package dependency:
25+
// @objectstack/client's own devDependencies already point back at runtime
26+
// (its Hono tests boot a real server), so a runtime→client package edge would
27+
// close a turbo build cycle. Importing the dist keeps the graph acyclic — and
28+
// CI's Test job always runs after Build, so the dist is present. (The client
29+
// SOURCE can't be imported here: its module graph resolves through runtime's
30+
// vitest aliases and breaks on subpath imports like @objectstack/core/logger.)
31+
import { ObjectStackClient } from '../../client/dist/index.mjs';
32+
import { HttpDispatcher } from './http-dispatcher.js';
33+
import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES } from './route-ledger.js';
34+
35+
/** Minimal kernel — enough for the constructor to register builtin domains. */
36+
function fakeKernel(): any {
37+
return {
38+
getState: () => 'running',
39+
getService: () => undefined,
40+
getServiceAsync: async () => undefined,
41+
};
42+
}
43+
44+
/** Live registry prefixes, normalized (`/keys?` query-form → `/keys`). */
45+
function registryPrefixes(): Set<string> {
46+
const dispatcher = new HttpDispatcher(fakeKernel());
47+
const routes = (dispatcher as any).domainRegistry.list() as Array<{ prefix: string }>;
48+
return new Set(routes.map((r) => (r.prefix.endsWith('?') ? r.prefix.slice(0, -1) : r.prefix)));
49+
}
50+
51+
describe('route ledger ↔ dispatcher domain registry', () => {
52+
it('every registered dispatcher domain has at least one ledger entry', () => {
53+
const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain));
54+
const missing = [...registryPrefixes()].filter((p) => !ledgerDomains.has(p));
55+
expect(
56+
missing,
57+
`Dispatcher domains with no route-ledger entry: ${missing.join(', ')}. ` +
58+
'A new route surface needs a reviewed disposition in route-ledger.ts (#3563).',
59+
).toEqual([]);
60+
});
61+
62+
it('every ledger domain is a live registry prefix or a pinned legacy branch', () => {
63+
const live = registryPrefixes();
64+
const legacy = new Set<string>(LEGACY_CHAIN_PREFIXES);
65+
const stale = [...new Set(ROUTE_LEDGER.map((e) => e.domain))].filter(
66+
(d) => !live.has(d) && !legacy.has(d),
67+
);
68+
expect(
69+
stale,
70+
`Route-ledger domains that no longer exist on the dispatcher: ${stale.join(', ')}. ` +
71+
'Remove or reclassify them so the ledger stays truthful.',
72+
).toEqual([]);
73+
});
74+
});
75+
76+
describe('route ledger ↔ @objectstack/client surface', () => {
77+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' });
78+
79+
const resolve = (path: string): unknown =>
80+
path.split('.').reduce<unknown>((o, k) => (o == null ? o : (o as Record<string, unknown>)[k]), client);
81+
82+
it('every `sdk`/`mismatch` entry names a client method that actually exists', () => {
83+
const broken = ROUTE_LEDGER.filter((e) => e.client != null)
84+
.filter((e) => typeof resolve(e.client!) !== 'function')
85+
.map((e) => `${e.route} → client.${e.client}`);
86+
expect(
87+
broken,
88+
`Ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
89+
).toEqual([]);
90+
});
91+
92+
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
93+
const sdkWithout = ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
94+
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);
95+
96+
const bareNonSdk = ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
97+
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
98+
});
99+
100+
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
101+
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1. Closing one =
102+
// reclassify the entry to `sdk` AND lower this bound. Raising it demands
103+
// an explicit, reviewed decision to ship a new un-expressed route.
104+
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
105+
expect(gaps).toBeLessThanOrEqual(27);
106+
});
107+
});

0 commit comments

Comments
 (0)