Skip to content

Commit 6877e9a

Browse files
os-zhuangclaude
andauthored
test(client,runtime): the last wildcard was wrong evidence, not weak — AI ratchet 3 → 0 (#3718) (#3727)
* test(client,runtime): the last wildcard was wrong evidence, not weak — AI ratchet 3 → 0 (#3718) The capstone (#3642) ratcheted "matched only by a `**` family" as WEAKER evidence, to be driven down by enumerating each dynamic family. 60 → 3 after #3656. The last 3 were `ai.nlq` / `ai.suggest` / `ai.insights` on `* /ai/**`. Enumerating that family — in `cloud`, where service-ai lives — answered the question the ratchet was really asking. `buildAIRoutes()` mounts 12 routes (chat, chat/stream, complete, models, status, effective-model, six conversations) and NONE is `/nlq`, `/suggest` or `/insights`. client.ai methods: 3 answered by any repo: 0 real AI routes: 12 expressed by the SDK: 0 The two sets are disjoint. So the wildcard was not weak evidence, it was WRONG evidence: it certified three URLs nothing anywhere serves. Its note even asserted the client "expresses nlq/suggest/insights against the REST AI routes" — never verified, and false. `DEFAULT_AI_ROUTES` declares all three but has no runtime consumer (only the spec's own test reads it), and `aiNlq?`/`aiSuggest?`/`aiInsights?` are optional protocol methods nothing implements. Declared, never built (#3718) — instances 7-9 of the `the method exists != the method can be called` class. `/api/v1/ai/` becomes a bounded prefix exemption alongside the control plane: two cross-repo surfaces, both ledgered in `cloud`. Only `ai.*` may use it, and the namespace must still be reaching it, so the hole cannot widen. The wildcard assertion becomes `toBe(0)`, not a ratchet. Zero is the point: every matched call now rests on an exact enumerated route, and reintroducing a `**` match reintroduces the one kind of evidence this audit family has caught being wrong. Mutation-checked both ways: removing the exemption re-exposes exactly the three methods, and running the PRE-change capstone with the bound at 0 confirms the old wildcard set was exactly `ai.insights`, `ai.nlq`, `ai.suggest` and nothing else. Adds audit §14 with the lesson worth carrying out of §1-§14: a claim about a family is not a claim about a member. Wildcards, prefixes and "the service handles that" all read as coverage in a green suite, and every one this audit opened was hiding something. Test-and-comment changes only; no runtime behaviour is affected. Co-Authored-By: Claude <noreply@anthropic.com> * docs(api): the published AI surface was inverted — correct it (#3718) The docs-drift advisory on this PR prompted a check of whether any published doc advertises the three dead `ai.*` methods. It does, and the AI docs turn out to be backwards in both directions at once: - `plugin-endpoints.mdx` tabled `/ai/nlq`, `/ai/suggest`, `/ai/insights` as the AI plugin's endpoints. None of the three is mounted by anything. - The same section's callout stated "There is no `/ai/chat` route". `/api/v1/ai/chat` IS mounted, along with `/chat/stream`, `/complete`, `/models` and six `/conversations` routes. So the documentation named the three routes that do not exist and explicitly denied the one that does — while `client-sdk.mdx` carried three copy-pasteable examples that 404 on every call. That is Prime Directive #10 in the literal sense: advertising a capability the runtime does not deliver. Corrected: - `client-sdk.mdx` — the three examples are marked declared-but-unimplemented with the reason, and the chat note now says the chat endpoint exists and it is the SDK that lacks a method for it. - `plugin-endpoints.mdx` — status column on the three, plus the real 12-route table and a pointer to cloud's ai-route-ledger.ts. The callout's surviving point (use `useChat()` directly) is kept; only its false premise is fixed. - `services-checklist.mdx` — notes that all three are optional protocol methods nothing implements. Deliberately does NOT decide #3718 (implement vs remove vs express the real surface). Every edit here states what is true today and stays true whichever way that lands. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a5f04a commit 6877e9a

7 files changed

Lines changed: 184 additions & 19 deletions

File tree

.changeset/ai-wildcard-to-zero.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
test(client,runtime): the last wildcard was wrong evidence, not weak — AI ratchet 3 → 0 (#3718)
6+
7+
The capstone (#3642) ratcheted "matched only by a `**` family" as weaker
8+
evidence, to be driven down by enumerating each dynamic family. 60 → 3 after
9+
#3656. The last 3 were `ai.nlq` / `ai.suggest` / `ai.insights` on `* /ai/**`.
10+
11+
Enumerating that family (in `cloud`, where `service-ai` lives) showed the
12+
wildcard had not been weak evidence but **wrong** evidence. `buildAIRoutes()`
13+
mounts 12 routes — `chat`, `chat/stream`, `complete`, `models`, `status`,
14+
`effective-model`, six `conversations` — and **none** is `/nlq`, `/suggest` or
15+
`/insights`. The SDK's entire AI namespace is dead, the entire real AI surface
16+
is unexpressed by the SDK, and the two sets are disjoint (#3718).
17+
18+
The old row's note even claimed the client "expresses nlq/suggest/insights
19+
against the REST AI routes". That was never verified and is false:
20+
`DEFAULT_AI_ROUTES` declares them but has no runtime consumer (only the spec's
21+
own test reads it), and `aiNlq?`/`aiSuggest?`/`aiInsights?` are optional
22+
protocol methods nothing implements.
23+
24+
`/api/v1/ai/` becomes a bounded prefix exemption alongside the control plane —
25+
two cross-repo surfaces, both ledgered in `cloud` — and the wildcard-only
26+
assertion becomes `toBe(0)`, not a ratchet: every matched call now rests on an
27+
exact enumerated route. Mutation-checked in both directions (removing the
28+
exemption re-exposes exactly the 3, and the pre-change count was verified to be
29+
exactly those 3 and nothing else).
30+
31+
Test-and-comment changes only; no runtime behaviour is affected.

content/docs/api/client-sdk.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,18 @@ await client.notifications.markAllRead();
312312
// through sys_inbox_message and tracks read-state in sys_notification_receipt.
313313
// These helpers will be repointed during the objectui bell cut-over.
314314

315-
// AI — AI-powered features
315+
// AI — DECLARED BUT NOT IMPLEMENTED (#3718). These three methods build
316+
// /api/v1/ai/{nlq,suggest,insights}, and no server in any repo mounts those
317+
// paths, so every call 404s. They are typed and shipped, which is why they
318+
// look usable; they are not. Do not build on them until #3718 resolves.
316319
await client.ai.nlq({ query: 'Show me all active accounts' });
317320
await client.ai.suggest({ object: 'account', field: 'industry' });
318321
await client.ai.insights({ object: 'sales', recordId: dealId });
319-
// Note: conversational chat is no longer on the client — use the Vercel AI SDK
320-
// (`useChat()` from `@ai-sdk/react`) directly against your chat endpoint.
322+
// Conversational chat is not on the client — use the Vercel AI SDK
323+
// (`useChat()` from `@ai-sdk/react`) directly against the chat endpoint.
324+
// That endpoint DOES exist (POST /api/v1/ai/chat, plus /chat/stream,
325+
// /complete, /models and six /conversations routes, all served by service-ai);
326+
// the SDK simply has no method for any of them.
321327

322328
// i18n — Internationalization
323329
await client.i18n.getLocales();

content/docs/api/plugin-endpoints.mdx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,37 @@ The core dispatcher implements only the list / read / read-all routes above. Dev
9595

9696
### AI (`/ai`) — Plugin Required
9797

98-
| Method | Endpoint | Description |
99-
|:-------|:---------|:------------|
100-
| POST | `/ai/nlq` | Natural language → query |
101-
| POST | `/ai/suggest` | Get value suggestions |
102-
| POST | `/ai/insights` | Get data insights |
98+
| Method | Endpoint | Description | Status |
99+
|:-------|:---------|:------------|:-------|
100+
| POST | `/ai/nlq` | Natural language → query | **Not implemented** |
101+
| POST | `/ai/suggest` | Get value suggestions | **Not implemented** |
102+
| POST | `/ai/insights` | Get data insights | **Not implemented** |
103103

104-
<Callout type="info">
105-
There is no `/ai/chat` route — the AI chat route was removed so the wire protocol aligns with the Vercel AI SDK. Use `useChat()` (`@ai-sdk/react`) directly against the streaming chat endpoint rather than the client SDK's `ai` namespace, which intentionally does not expose a `chat` method.
104+
<Callout type="warn">
105+
**This table was inverted, and is corrected here (#3718).** The three routes
106+
above are declared in `DEFAULT_AI_ROUTES` and called by
107+
`client.ai.nlq/suggest/insights`, but **no plugin in any repo mounts them**
108+
every call 404s. Conversely, this callout used to state "there is no
109+
`/ai/chat` route", which was wrong: `/ai/chat` is mounted.
110+
111+
What the AI plugin (`service-ai`) actually serves is a different set of
112+
**12** routes:
113+
114+
| Method | Endpoint |
115+
|:-------|:---------|
116+
| POST | `/ai/chat`, `/ai/chat/stream`, `/ai/complete` |
117+
| GET | `/ai/models`, `/ai/status`, `/ai/effective-model` |
118+
| POST / GET | `/ai/conversations` |
119+
| GET / PATCH / DELETE | `/ai/conversations/:id` |
120+
| POST | `/ai/conversations/:id/messages` |
121+
122+
None of those has a client-SDK method — the SDK's `ai` namespace and the real
123+
AI surface do not overlap at all. Reviewed dispositions for all 12 live in the
124+
`cloud` repo, `packages/service-ai/src/ai-route-ledger.ts`.
125+
126+
The original point stands on its own terms: use `useChat()` (`@ai-sdk/react`)
127+
directly against the streaming chat endpoint, because the client SDK exposes no
128+
`chat` method.
106129
</Callout>
107130

108131
### i18n (`/i18n`) — Plugin Required

content/docs/kernel/services-checklist.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ Trigger engine, event triggers from ObjectQL hooks, flow executor, scheduled tri
251251
### 10. ai — 3 methods
252252
`aiNlq`, `aiSuggest`, `aiInsights`
253253

254+
<Callout type="warn">
255+
Declared only. All three are **optional** protocol methods (`aiNlq?` …) and no
256+
service in any repo implements them, so the `/ai/{nlq,suggest,insights}` routes
257+
they back are mounted by nothing and the matching `client.ai.*` calls 404
258+
(#3718). The AI service that does exist (`service-ai`) serves a different set
259+
— chat, complete, models, conversations — through none of these methods.
260+
</Callout>
261+
254262
### 11. i18n — 3 methods
255263
`getLocales`, `getTranslations`, `getFieldLabels`
256264

docs/audits/2026-07-dispatcher-client-route-coverage.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,50 @@ against the framework revision cloud builds and ships against. That is the
355355
right revision to check — but it means a `projects.*` change landing here is
356356
not verified against the control plane until that pin moves.
357357

358+
## 14. The last wildcard, and what it was actually worth (#3718)
359+
360+
§10's capstone ratcheted "matched only by a `**` family" as *weaker* evidence,
361+
to be driven down by enumerating each dynamic family. 60 → 3 after #3656. The
362+
last 3 were `ai.nlq` / `ai.suggest` / `ai.insights` on `* /ai/**`.
363+
364+
Enumerating that family answered the question the ratchet was really asking.
365+
`service-ai` lives in the `cloud` repo (Cloud/EE), so this repo could never see
366+
its table — the dispatcher just proxies to whatever `buildAIRoutes()` returned.
367+
That table is **12 routes**: `chat`, `chat/stream`, `complete`, `models`,
368+
`status`, `effective-model`, and six `conversations` routes.
369+
370+
**None of them is `/nlq`, `/suggest` or `/insights`.**
371+
372+
| | |
373+
|---|---|
374+
| `client.ai` methods | 3 — `nlq`, `suggest`, `insights` |
375+
| …that any repo mounts | **0** |
376+
| Real AI routes | 12 |
377+
| …expressed by the SDK | **0** |
378+
379+
The two sets are disjoint. So the wildcard was not weak evidence — it was
380+
**wrong** evidence, certifying three URLs nothing serves, and its note even
381+
asserted the client "expresses nlq/suggest/insights against the REST AI routes"
382+
(never verified, false). `DEFAULT_AI_ROUTES` in `plugin-rest-api.zod.ts`
383+
declares all three, but has **no runtime consumer** — only the spec's own test
384+
reads it — and `aiNlq?` / `aiSuggest?` / `aiInsights?` are optional protocol
385+
methods nothing implements. Declared, never built (#3718). Instances 7–9 of the
386+
`the method exists ≠ the method can be called` class.
387+
388+
`/api/v1/ai/` is now a bounded prefix exemption alongside the control plane —
389+
two cross-repo surfaces, both ledgered in `cloud`
390+
(`packages/service-ai/src/ai-route-ledger.ts`, 10 `gap` / 2 `server-only`,
391+
enumerated straight off the array `buildAIRoutes()` returns). **The
392+
wildcard-only bound is 0**, and the assertion is `toBe(0)` rather than a
393+
ratchet: every matched call now rests on an exact enumerated route, and
394+
reintroducing a `**` match reintroduces the one kind of evidence this audit
395+
family has caught being wrong.
396+
397+
The generalisable lesson, and the one worth carrying out of §1–§14: **a claim
398+
about a family is not a claim about a member.** Wildcards, prefixes and "the
399+
service handles that" all read as coverage in a green suite. Every one of them
400+
this audit opened turned out to be hiding something.
401+
358402
## Follow-up slicing (proposed)
359403

360404
1. **`client.actions.invoke(...)`** — closes the largest hole (3 routes).
@@ -370,7 +414,8 @@ not verified against the control plane until that pin moves.
370414
11. **Control-plane surface** (§10) — done in #3655; the ledger lives in `cloud`
371415
(§13), which is the only repo where both halves are in scope.
372416
12. **Enumerate `/auth/**`** (§11) — done in #3656; wildcard ratchet 60 → 3.
373-
13. **Enumerate `/ai/**`** — the last dynamic family, 3 SDK methods.
417+
13. **Enumerate `/ai/**`** — done (§14). The wildcard ratchet is now **0**; the
418+
3 SDK methods turned out to be dead (#3718).
374419
14. **Response-shape conformance** (§12) — error path done in #3675 for both
375420
services; the storage **success** bodies (three shapes, none carrying
376421
`success: true`) and the dispatcher's numeric `error.code` remain.

packages/client/src/client-url-conformance.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,29 @@ function matches(verb: string, path: string): Pattern | undefined {
148148
const CONTROL_PLANE = '/api/v1/cloud/';
149149
const CONTROL_PLANE_NAMESPACE = 'projects.';
150150

151+
/**
152+
* The AI surface — the SECOND cross-repo prefix, and exempt for the same
153+
* reason as the control plane rather than a different one.
154+
*
155+
* `/api/v1/ai/*` routes are built by `service-ai`'s `buildAIRoutes()` at plugin
156+
* start, and `service-ai` is a Cloud/EE package living in the `cloud` repo.
157+
* This repo's dispatcher only proxies to whatever that table contains (or 404s
158+
* "AI service is not configured" when it is absent), so no ledger here can
159+
* enumerate them — the same boundary `projects.*` sits behind.
160+
*
161+
* It used to be handled as a `* /ai/**` WILDCARD match instead, which was
162+
* strictly worse: a wildcard says the family is claimed, so all three `ai.*`
163+
* methods counted as matched. #3718 enumerated the real table in `cloud` and
164+
* found the SDK's three URLs are not in it — `/nlq`, `/suggest` and
165+
* `/insights` are mounted by nothing, in any repo (#3718). The wildcard was
166+
* not weak evidence, it was wrong evidence, which is exactly why the ratchet
167+
* below treats `**` matches as something to drive to zero rather than tolerate.
168+
*
169+
* Bounded the same way as the control plane: only `ai.*` may use the prefix.
170+
*/
171+
const AI_PLANE = '/api/v1/ai/';
172+
const AI_NAMESPACE = 'ai.';
173+
151174
// ---------------------------------------------------------------------------
152175
// 2. The recorder
153176
// ---------------------------------------------------------------------------
@@ -326,6 +349,7 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
326349
const silent: string[] = [];
327350
const malformed: string[] = [];
328351
const controlPlane: string[] = [];
352+
const aiPlane: string[] = [];
329353
const wildcardOnly: string[] = [];
330354

331355
for (const name of METHODS) {
@@ -347,6 +371,7 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
347371
}
348372
const path = new URL(call.url, BASE).pathname;
349373
if (path.startsWith(CONTROL_PLANE)) { controlPlane.push(`${name}${call.verb} ${path}`); continue; }
374+
if (path.startsWith(AI_PLANE)) { aiPlane.push(`${name}${call.verb} ${path}`); continue; }
350375
const hit = matches(call.verb, path);
351376
if (!hit) { unmatched.push(`${name}${call.verb} ${path}`); continue; }
352377
if (hit.route.includes('**')) wildcardOnly.push(`${name}${call.verb} ${path} (via ${hit.route})`);
@@ -381,18 +406,35 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
381406
).toEqual([]);
382407
expect(controlPlane.length, 'the projects namespace should still be reaching the control plane').toBeGreaterThan(0);
383408

409+
// Same bounding for the AI prefix. `ai.*` is the ONLY namespace allowed to
410+
// use it; anything else reaching /api/v1/ai/ is a method that has wandered
411+
// into a surface no in-repo ledger can vouch for.
412+
const aiTrespassers = aiPlane.filter((e) => !e.startsWith(AI_NAMESPACE));
413+
expect(
414+
aiTrespassers,
415+
`non-ai methods targeting the AI plane, which service-ai owns in the cloud repo:\n${aiTrespassers.join('\n')}`,
416+
).toEqual([]);
417+
expect(aiPlane.length, 'the ai namespace should still be reaching the AI plane').toBeGreaterThan(0);
418+
384419
// HOW STRONG IS THIS GUARD, HONESTLY. A `**` row asserts only that a prefix
385420
// family is CLAIMED, not that the specific URL resolves. That was this
386421
// guard's biggest weakness at #3642: 60 of ~196 matched calls rested on
387422
// nothing better, 54 of them on `* /auth/**`. #3656 enumerated better-auth's
388423
// real route table, so those now match exact rows and the bound fell 60 → 3.
389-
// What remains is `* /ai/**`, whose routes service-ai builds at plugin start.
390-
// Ratcheted: enumerating that family lowers it; nothing raises it without a
391-
// deliberate decision.
424+
// The last 3 were `ai.nlq/suggest/insights` on `* /ai/**`, and enumerating
425+
// THAT family (#3718, in `cloud`, where service-ai lives) showed the
426+
// wildcard had not been weak evidence but WRONG evidence: none of the three
427+
// URLs is in the real table, and nothing in any repo mounts them (#3718).
428+
// They are now handled by the AI_PLANE exemption above and pinned as dead
429+
// on the cloud side, so this bound is 0.
430+
//
431+
// ZERO IS THE POINT: every remaining matched call rests on an exact route
432+
// some ledger enumerated. Raising this bound reintroduces the one kind of
433+
// evidence this audit family has caught being wrong.
392434
expect(
393435
wildcardOnly.length,
394436
'methods matched only by a wildcard `**` family — weaker evidence than an exact ' +
395-
`route. Enumerate a dynamic family to lower this bound; do not raise it:\n${wildcardOnly.join('\n')}`,
396-
).toBeLessThanOrEqual(3);
437+
`route, and demonstrably able to be wrong (#3718). Enumerate the family instead:\n${wildcardOnly.join('\n')}`,
438+
).toBe(0);
397439
});
398440
});

packages/runtime/src/route-ledger.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,17 @@
3636
* not a resolvable route. `* /auth/**` was the worst of it — 54 SDK methods
3737
* resting on a prefix claim — until #3656 enumerated better-auth's real table
3838
* into `plugin-auth/src/auth-route-ledger.ts`, dropping the guard's
39-
* wildcard-only count 60 → 3. The `* /ai/**` row below is what remains.
39+
* wildcard-only count 60 → 3.
40+
*
41+
* The last 3 were the `ai.*` methods on `* /ai/**`, and enumerating THAT
42+
* family settled the question of how much a `**` row is worth: not "weaker
43+
* evidence" but, in this case, WRONG evidence. `service-ai` (a Cloud/EE
44+
* package in the `cloud` repo) mounts 12 routes and not one of them is the
45+
* `/nlq` `/suggest` `/insights` the SDK calls — the wildcard had been
46+
* certifying three URLs that nothing anywhere serves (#3718). The capstone now
47+
* exempts `/api/v1/ai/` by prefix like the control plane, the real table is
48+
* ledgered in `cloud`, and the wildcard-only bound is **0**: every matched call
49+
* rests on an exact enumerated route.
4050
*
4151
* This module is runtime-internal (not exported from the package index): it is
4252
* the guard's data, not public API. Promotion to `@objectstack/spec` is a
@@ -180,9 +190,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
180190
{ route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me',
181191
note: 'wholesale delegate to the auth service. Not enumerable route-by-route HERE, but no longer unenumerated: since #3656 plugin-auth/src/auth-route-ledger.ts carries the 55 SDK-reached routes plus the full mounted inventory, read off better-auth\'s live auth.api table' },
182192

183-
// ── ai (dynamic route table) ──────────────────────────────────────────────
193+
// ── ai (dynamic route table, owned by another repo) ───────────────────────
184194
{ route: '* /ai/**', domain: '/ai', disposition: 'dynamic',
185-
note: 'routes come from service-ai buildAIRoutes() at plugin start; client expresses nlq/suggest/insights against the REST AI routes' },
195+
note: 'routes come from service-ai buildAIRoutes() at plugin start — service-ai is a Cloud/EE package in the `cloud` repo, so this repo cannot enumerate them and the dispatcher only proxies (or 404s "AI service is not configured"). Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts. The previous note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and is FALSE — nothing mounts those three paths (#3718)' },
186196

187197
// ── meta (legacy chain) ───────────────────────────────────────────────────
188198
{ route: 'GET /meta', domain: '/meta', disposition: 'sdk', client: 'meta.getTypes' },

0 commit comments

Comments
 (0)