Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/16211-ai-slot-501-not-404.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 22 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 7 additions & 5 deletions packages/runtime/src/domains/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading