diff --git a/.changeset/16211-ai-slot-501-not-404.md b/.changeset/16211-ai-slot-501-not-404.md new file mode 100644 index 0000000000..42ffcf8ae1 --- /dev/null +++ b/.changeset/16211-ai-slot-501-not-404.md @@ -0,0 +1,64 @@ +--- +'@objectstack/client': patch +--- + +`client.ai`'s docblock says the AI slot answers 501, names the 401-first and `GET /ai/agents` arms, and stops promising a 404 + +The `ai` namespace docblock described the pre-`capabilityUnavailable` +behaviour: that this repo's dispatcher *"404s `AI service is not configured` +when the service is absent (the open-source default)"*. The dispatcher has +answered **501** since the shared exit landed. `/ai/*` is registered +**unconditionally** (`createAiDomain`, plus the host wildcard across four +methods in every branch of the scoping conditional), so a request reaches a +handler with nothing behind it — which is 501 Not Implemented, not 404. +`packages/runtime/src/domains/unavailable.ts` exists to draw exactly that line: +404 means *the route is not there*, and for `/ai/*` that is false. + +**Why the replacement is narrower than "`/ai/*` answers 501".** That sentence +is not true either, and a caller branching on status needs both exceptions. +Verified against the unserveable-slot branch in +`packages/runtime/src/domains/ai.ts`, in its own evaluation order: + +``` +FROM any /ai/* with no AI service -> 404 `AI service is not configured` + +TO anonymous caller -> 401 (ANONYMOUS_DENY_STATUS; the 501 and + the courtesy below are capability + disclosures, owed to nobody who has + not authenticated) + GET /ai/agents -> 200 { agents: [] } under the envelope's + `data` — a console polls it on every + navigation to decide whether to show + AI affordances + every other /ai/* route -> 501 serviceUnavailableMessage('ai') +``` + +All three arms are already test-pinned in +`domains/ai-anonymous-deny-ordering.test.ts` — this changeset moves no +behaviour, only the sentence describing it. + +**The `GET /ai/agents` courtesy was mentioned nowhere in this docblock**, which +is the one an SDK reader actually opens, so it is added rather than merely +corrected. Also stated now: the 501 body is not a local string — it comes from +the shared `serviceUnavailableMessage`, the same sentence +`discovery.services.ai` reports for the slot, so the two cannot drift into +naming different remedies. + +⛔ No behaviour changes. This is a docblock; no export, authorable key, accept +set or response byte moves. + +**This is shipped, which is why it carries a changeset rather than +`skip-changeset`.** `@objectstack/client`'s published `files[]` ships `dist`, +and this TSDoc is emitted into all four built artifacts — `dist/index.d.ts`, +`dist/index.d.mts`, `dist/index.js` and `dist/index.mjs` — measured on the +built tree, with the stale `AI service is not configured` sentence absent from +every built file afterwards and the docblock's own neighbouring sentence +present as the lit control. The declarations are what a consumer's editor shows +on hover and what an upgrading agent greps, and they change. + +The two sibling corrections in the same change do **not** publish and are not +named here: `packages/runtime/src/route-ledger.ts` is a CI-audit ledger that is +not exported from the runtime entry (`ROUTE_LEDGER` is absent from +`packages/runtime/dist` entirely), and the `domains/ai.ts` implementation +comment is not emitted — three pre-existing comments from that same file were +probed as controls and none appears in the built output. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 177d2bead1..775119f9c3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -6254,9 +6254,28 @@ export class ObjectStackClient { * * `service-ai` is a **Cloud/EE package in the `cloud` repo**. This repo's * dispatcher only proxies `/api/v1/ai/**` to whatever `buildAIRoutes()` - * mounted, and 404s `AI service is not configured` when the service is - * absent (the open-source default) — so treat every method here as - * plugin-provided and check `discovery.services` first. + * mounted. When the service is absent (the open-source default) those + * routes are still mounted, so a request reaches a handler with nothing + * behind it and the answer is **501**, not 404 — 404 would mean the path + * does not exist, which for `/ai/*` is false. + * + * Two arms are narrower than that, and a caller branching on status needs + * both: + * + * - An **anonymous** caller is refused **401** first. The 501 and the + * `/ai/agents` courtesy below are both capability disclosures, and + * neither is owed to a caller who has not authenticated. + * - **`GET /ai/agents` answers `200`** with an empty list (`{ agents: [] }` + * under the envelope's `data`), not 501. It is a deliberate courtesy: a + * console polls it on every navigation to decide whether to show AI + * affordances, and an empty catalog conveys "no AI service here" without + * looking like a fault. + * + * The 501 body is not a local string — it comes from the shared + * `serviceUnavailableMessage`, the same sentence `discovery.services.ai` + * reports for the slot, so the two cannot drift into naming different + * remedies. Treat every method here as plugin-provided and check + * `discovery.services` first. * * That split is also why the guard for these URLs lives on the other side of * the repo boundary: `cloud`'s `packages/service-ai/src/ai-route-ledger.ts` diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index ced7e862e1..7b4fff5298 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -82,11 +82,13 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // authenticated. This is the exit the defect lived behind. if (denyAnonymous) return anonymousRefusal(); // The console polls `GET /ai/agents` on every navigation to decide - // whether to show AI affordances. Reporting that as a 404 turns the - // normal "no AI service configured" state (the open-source default — - // service-ai is a Cloud/Enterprise package) into console error-log - // spam on every page. An empty list conveys the same information - // without looking like a fault. Every other /ai/* route still 404s. + // whether to show AI affordances. Reporting that as an error turns + // the normal "no AI service configured" state (the open-source + // default — service-ai is a Cloud/Enterprise package) into console + // error-log spam on every page. An empty list conveys the same + // information without looking like a fault. Every other /ai/* route + // answers 501 below — an anonymous caller having already been + // refused 401 at the gate above. // // The body is the declared envelope (#4053). `data` carries // `AiAgentsResponseSchema`'s `{ agents }` — a RELOCATION of the declared diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 013030f987..b99c71fdab 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -468,7 +468,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── ai (dynamic route table, owned by another repo) ─────────────────────── { route: '* /ai/**', domain: '/ai', disposition: 'dynamic', - 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, whose conformance test drives client.ai.* against the table buildAIRoutes() really returns. The client now expresses that table — ai.chat / ai.chatStream / ai.complete / ai.models / ai.conversations.* — but do NOT read a `sdk` disposition into this row: it stays `dynamic` because THIS repo still cannot see the routes. An earlier note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and was FALSE — nothing has ever mounted those three paths, and both they and the methods calling them are gone (#3718)' }, + 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. With the service absent the mount stays, so the answer is 501 carrying the shared serviceUnavailableMessage (the sentence discovery reports for the slot), NOT 404 — with two narrower arms: an anonymous caller is refused 401 first, and GET /ai/agents answers 200 with an empty list as a console courtesy. Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts, whose conformance test drives client.ai.* against the table buildAIRoutes() really returns. The client now expresses that table — ai.chat / ai.chatStream / ai.complete / ai.models / ai.conversations.* — but do NOT read a `sdk` disposition into this row: it stays `dynamic` because THIS repo still cannot see the routes. An earlier note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and was FALSE — nothing has ever mounted those three paths, and both they and the methods calling them are gone (#3718)' }, // ── meta (legacy chain) ─────────────────────────────────────────────────── // [2026-08-31] SEEDED under the field's fill rule: `anonymous-deny-meta` is