|
| 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. |
0 commit comments