From 9ba54be04bf8da3c6affff21ab81c45d4da32921 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 04:46:41 +0000 Subject: [PATCH 1/2] fix(spec,runtime): the `ai` slot's remedy stops claiming nothing ships (#4093 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORE_SERVICE_PROVIDER recorded `null` for `ai` because no WORKSPACE package provides it, so `serviceUnavailableMessage('ai')` produced "No implementation ships for the 'ai' slot". That is false — `@objectstack/service-ai` registers the slot — and it is the same class of wrong answer this table was introduced to end, one step further out: I conflated "not in this repository" with "does not exist". Worth naming how it got in. The claim that ai's provider is a Cloud/EE package was true, but I asserted it from a doc line and an old code comment without checking — exactly the provenance that made `plugin-search` and `plugin-workflow` wrong. Verified properly now, against the cloud repository: `packages/service-ai/src/plugin.ts` calls `ctx.registerService('ai', …)`, and the package is `private: true`. So something ships AND there is nothing to install — which is why `null` stays correct and why the sentence still must not start with "Install". `ai` gets a REMEDY_DETAIL entry, the mechanism `ui` already used for a slot the simple sentence describes badly. Discovery (`services.ai.message`) and the /ai 501 body both report it. `search`, `workflow` and `graphql` were re-checked the same way — nothing in either repository registers them — so their `null` and their "nothing ships" sentence are accurate, now on evidence from both sides rather than one. This REMOVES code. `/ai`'s domain carried a local message override precisely because the shared sentence was wrong there; correcting the table fixed the domain AND discovery, which an override could never reach. The override and `capabilityUnavailable`'s `message?` parameter are both gone, and the helper now documents why there is no per-call escape hatch: a slot whose sentence is wrong needs the table corrected. check-service-providers keeps its workspace-only rule — it cannot see another repository, and widening it is not the fix — but it no longer REPEATS the conflation in its own output ("N correctly report that nothing ships yet"), and its failure text now tells the next person what to do in exactly this case: use `null` plus a REMEDY_DETAIL sentence when something ships that simply cannot be installed. Verified by pointing the table at the private package and reading the error. Also corrected: three places still describing /ai's absent-service answer as a 404 — docs/api/client-sdk.mdx, docs/releases/v17.mdx, and a comment in packages/client's URL-conformance test. Stale since #4222 made it 501; the drift check had flagged client-sdk.mdx then and I only swept the files that document status codes directly. Verified: full `pnpm test` 132/132 tasks; runtime 954; spec's provider suite 8; build 71/71; pnpm lint, every check:* gate and spec's api-surface clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- .changeset/ai-slot-remedy-tells-the-truth.md | 18 +++++++++++++++ content/docs/api/client-sdk.mdx | 5 ++-- content/docs/releases/v17.mdx | 6 ++--- .../client/src/client-url-conformance.test.ts | 2 +- packages/runtime/src/domains/ai.ts | 13 +++++------ packages/runtime/src/domains/unavailable.ts | 21 ++++++++--------- .../src/system/core-service-provider.test.ts | 23 +++++++++++++++++-- packages/spec/src/system/core-services.zod.ts | 23 +++++++++++++++++-- scripts/check-service-providers.mjs | 23 +++++++++++++++---- 9 files changed, 100 insertions(+), 34 deletions(-) create mode 100644 .changeset/ai-slot-remedy-tells-the-truth.md diff --git a/.changeset/ai-slot-remedy-tells-the-truth.md b/.changeset/ai-slot-remedy-tells-the-truth.md new file mode 100644 index 0000000000..4990020749 --- /dev/null +++ b/.changeset/ai-slot-remedy-tells-the-truth.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/runtime': patch +--- + +Discovery stops telling Cloud/Enterprise deployments that nothing implements `ai` (#4093 follow-up). + +`CORE_SERVICE_PROVIDER` recorded `null` for `ai` because no **workspace** package provides it, and `serviceUnavailableMessage('ai')` therefore produced *"No implementation ships for the 'ai' slot"*. That is false: `@objectstack/service-ai` registers the slot in `objectstack-ai/cloud`. The table conflated "not in this repository" with "does not exist" — the same class of wrong answer the table was introduced to end, one step further out. + +Verified against the cloud repository rather than inferred: `packages/service-ai/src/plugin.ts` calls `ctx.registerService('ai', …)`, and the package is `private: true`, so there is genuinely nothing to install — which is why `null` stays right and an `Install X` sentence would still be wrong. `search`, `workflow` and `graphql` were checked the same way and nothing registers them in either repository, so their `null` and their "nothing ships" sentence are accurate. + +`ai` now carries a `REMEDY_DETAIL` sentence — *"Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework"* — the mechanism `ui` already used. Both discovery (`services.ai.message`) and the `/ai` 501 body report it. + +This **removes** code: `/ai`'s domain had a local message override, added because the shared sentence was wrong there. Correcting the table fixed the domain *and* discovery, which the override could never reach, so the override and `capabilityUnavailable`'s `message?` parameter are both gone. A slot whose sentence is wrong needs the table corrected, not a local exception. + +Also corrected: three places still describing `/ai`'s absent-service answer as a 404 (`docs/api/client-sdk.mdx`, `docs/releases/v17.mdx`, and a comment in `packages/client`'s URL-conformance test) — stale since that answer became 501. + +FROM → TO: `services.ai.message` and the `/ai/*` 501 body change text. Nothing branches on either — `status` and `enabled` are the contract, the message is prose for humans and agents. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 45f7e4b197..bb473d6494 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -315,8 +315,9 @@ await client.notifications.markAllRead(); // through sys_inbox_message and tracks read-state in sys_notification_receipt. // These helpers will be repointed during the objectui bell cut-over. -// AI — served by `service-ai` (Cloud/EE); 404s "AI service is not configured" -// when the plugin is absent, so check `discovery.services` first. +// AI — served by `service-ai` (Cloud/EE). Without it the route is mounted but +// unimplemented, so it answers 501 and discovery reports the slot unavailable +// with the same sentence; check `discovery.services` first. const answer = await client.ai.chat({ messages: [{ role: 'user', content: 'How many open orders this quarter?' }], conversationId, // omit to have one created and echoed back diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 69e490a1a1..99bc1c7d53 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -727,9 +727,9 @@ query dialect only the dispatcher understood. async generator, so the request is issued — and an HTTP error thrown — when you call it, not when you first iterate. -`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and 404s -`AI service is not configured` without it, so check `discovery.services` before -calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better +`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and, without +it, answers 501 — the route is mounted, the implementation is not — so check +`discovery.services` before calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better client — these methods are for callers that are not components. The spec's dead AI declarations retire with the namespace: diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 44e508db8a..358b919aec 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -154,7 +154,7 @@ const CONTROL_PLANE_NAMESPACE = 'projects.'; /** * The AI plane. `/api/v1/ai/*` is served by `service-ai`, a Cloud/EE package in * the sibling `cloud` repo; this repo's dispatcher only PROXIES the prefix to - * whatever `buildAIRoutes()` mounted (or 404s "AI service is not configured"). + * whatever `buildAIRoutes()` mounted (or 501s when that package is absent). * No in-repo ledger can enumerate that table, so — exactly like the control * plane above — the prefix is exempt HERE and guarded THERE. * diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index 1d2f6b15e3..fb46467acd 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -69,13 +69,12 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // [#3842] Was a hand-rolled envelope with the status in `code`. It has // no header or shape of its own, so it is simply the shared exit now. // 501, not 404: `/ai/*` IS mounted, so the request reached a handler - // with nothing behind it — see ./unavailable.ts. The message stays - // local rather than using the shared sentence: the real provider - // (`@objectstack/service-ai`) ships outside this workspace as a - // Cloud/EE package, so CORE_SERVICE_PROVIDER — verified against - // workspace packages by check:service-providers — records `null` for - // this slot and would describe it as "nothing ships", which is wrong. - return capabilityUnavailable(deps, 'ai', 'AI service is not configured'); + // with nothing behind it — see ./unavailable.ts. This used to pass a + // local message because the shared sentence said "nothing ships", + // which is false for a Cloud/Enterprise deployment. The shared table + // says the accurate thing now (REMEDY_DETAIL), so the override is gone + // and this answer matches what discovery reports for the slot. + return capabilityUnavailable(deps, 'ai'); } // The AI service exposes route definitions via buildAIRoutes. diff --git a/packages/runtime/src/domains/unavailable.ts b/packages/runtime/src/domains/unavailable.ts index 33dbeb4f00..c40c2149a6 100644 --- a/packages/runtime/src/domains/unavailable.ts +++ b/packages/runtime/src/domains/unavailable.ts @@ -46,20 +46,17 @@ import type { DomainHandlerDeps } from '../domain-handler-registry.js'; * entry cannot drift into naming different remedies — and a caller who hits * the wall gets the fix without a second round trip. * + * There is deliberately no per-call message override. One briefly existed for + * `ai`, whose provider ships outside this workspace, and it hid the real bug: + * the shared table said "nothing ships" for a slot that Cloud/Enterprise does + * provide. Teaching the table to say the accurate thing fixed the domain AND + * discovery, which the override could only ever have fixed here. A slot whose + * sentence is wrong needs the table corrected, not a local exception. + * * @param slot the `CoreServiceName` key, NOT the route segment — `/notifications` * is served by the `notification` slot, and the remedy is looked up * by slot. */ -export function capabilityUnavailable( - deps: DomainHandlerDeps, - slot: string, - /** - * Overrides the shared sentence. Only for slots whose provider cannot be - * named by `CORE_SERVICE_PROVIDER` — it is verified against workspace - * packages, so a real provider that ships outside this repo (`ai`) has no - * entry there and would otherwise be described as "nothing ships". - */ - message?: string, -): HttpDispatcherResult { - return { handled: true, response: deps.error(message ?? serviceUnavailableMessage(slot), 501) }; +export function capabilityUnavailable(deps: DomainHandlerDeps, slot: string): HttpDispatcherResult { + return { handled: true, response: deps.error(serviceUnavailableMessage(slot), 501) }; } diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts index 813ccc83c2..fc555db15e 100644 --- a/packages/spec/src/system/core-service-provider.test.ts +++ b/packages/spec/src/system/core-service-provider.test.ts @@ -22,9 +22,28 @@ describe('CORE_SERVICE_PROVIDER', () => { expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging'); }); - it('uses null — not a plausible name — for slots nothing implements', () => { + it('uses null — not a plausible name — where no package can be installed', () => { for (const slot of ['ai', 'search', 'workflow', 'graphql']) { - expect(CORE_SERVICE_PROVIDER[slot], `${slot} must have no provider`).toBeNull(); + expect(CORE_SERVICE_PROVIDER[slot], `${slot} must name no installable package`).toBeNull(); + } + }); + + // `null` covers two different situations, and only one of them means + // "nothing exists". Verified against objectstack-ai/cloud: nothing there + // registers search/workflow/graphql, but `@objectstack/service-ai` does + // register `ai` — it is simply `private: true`, so there is no package to + // install and no name that belongs in an "Install X" sentence. + it('still says something ships for a slot whose provider is real but uninstallable', () => { + const ai = serviceUnavailableMessage('ai'); + expect(ai).not.toMatch(/No implementation ships/); + expect(ai).toContain('@objectstack/service-ai'); + expect(ai).toMatch(/Cloud\/Enterprise/); + // Still not an instruction a reader could act on and fail at. + expect(ai).not.toMatch(/^Install /); + + // The genuinely-empty slots keep the plain sentence. + for (const slot of ['search', 'workflow', 'graphql']) { + expect(serviceUnavailableMessage(slot), slot).toMatch(/No implementation ships/); } }); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index a2644477d2..4a0e2e8e91 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -90,8 +90,21 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { // `/ui` is served by the `protocol` service, which MetadataPlugin registers; // the `ui` slot itself has no implementation anywhere (#4093 / #4146). 'ui': '@objectstack/metadata-protocol', - // Nothing ships for these. `search` and `workflow` have no consumer either - // (ADR-0115 Evidence 5); `graphql` and `ai` have surfaces but no provider. + // `null` means "no name belongs in an `Install X` sentence", which covers two + // different situations — see REMEDY_DETAIL, which is how the second one still + // gets an accurate message. + // + // Nothing provides the slot at all: `search`, `workflow` (no consumer + // either — ADR-0115 Evidence 5) and `graphql` (a surface with no provider). + // Verified across BOTH repositories: nothing in `objectstack-ai/cloud` + // registers them. + // + // A provider exists but cannot be installed: `ai`. `@objectstack/service-ai` + // registers this slot in `objectstack-ai/cloud` and is `private: true`, so + // naming it would send a reader after a package they cannot obtain — the + // exact failure this table was written to end. It carries a REMEDY_DETAIL + // sentence instead; a bare `null` here would tell a Cloud/Enterprise + // deployment that nothing ships, which is false. 'ai': null, 'search': null, 'workflow': null, @@ -110,6 +123,12 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { */ const REMEDY_DETAIL: Readonly> = { 'ui': 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', + // A provider EXISTS — `@objectstack/service-ai` registers this slot — but it + // lives in the `objectstack-ai/cloud` repository and is `private: true`, so + // there is nothing to install and no name that belongs in an "Install X" + // sentence. Without this entry the `null` above reads as "nothing ships", + // which is what a Cloud/Enterprise deployment is NOT looking at. + 'ai': 'Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework', }; /** diff --git a/scripts/check-service-providers.mjs b/scripts/check-service-providers.mjs index e7612794bf..60474b18a0 100644 --- a/scripts/check-service-providers.mjs +++ b/scripts/check-service-providers.mjs @@ -22,8 +22,14 @@ // 2. A slot in `CoreServiceName` with no entry at all — which would fall // back to `undefined` and print a remedy naming nothing. // -// `null` is a legitimate entry: it means nothing ships for that slot yet, and -// the message says so instead of naming a plausible package. +// `null` is a legitimate entry, and it means "no name belongs in an `Install X` +// sentence" — which covers TWO situations. Nothing provides the slot at all +// (`search`, `workflow`, `graphql`), or a provider exists but cannot be +// installed: `@objectstack/service-ai` registers `ai` in objectstack-ai/cloud +// and is `private: true`. This script can only see workspace packages, so it +// cannot tell those apart — `REMEDY_DETAIL` in the same file is where the +// second kind gets an accurate sentence, and the reason a bare `null` must not +// be read here (or reported) as "nothing exists". import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -86,7 +92,9 @@ for (const [slot, pkg] of table) { problems.push( ` ${slot} → ${pkg}\n` + ' Not a workspace package. Point it at the package that actually registers\n' - + ` the '${slot}' slot, or use \`null\` if nothing ships for it yet.`, + + ` the '${slot}' slot, or use \`null\` if no installable package provides it\n` + + ' (adding a REMEDY_DETAIL sentence if something ships that simply cannot\n' + + ' be installed, e.g. a private package in another repository).', ); } } @@ -115,7 +123,12 @@ if (problems.length > 0) { } const named = [...table.values()].filter(Boolean).length; +// Deliberately NOT "nothing ships yet" — `null` means "no name belongs in an +// `Install X` sentence", which also covers a provider that exists but cannot be +// installed (`ai`, whose `@objectstack/service-ai` is private to the cloud +// repo). Saying "nothing ships" here would repeat, in this script's own output, +// the conflation the table was corrected to stop making. console.log( - `✓ check:service-providers — ${table.size} slot(s): ${named} name a real workspace ` - + `package, ${table.size - named} correctly report that nothing ships yet.`, + `✓ check:service-providers — ${table.size} slot(s): ${named} name an installable workspace ` + + `package, ${table.size - named} name none (see REMEDY_DETAIL for those that still ship something).`, ); From 2722b93b119bea50ffe5097ff0d22bee1d50dee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 04:48:12 +0000 Subject: [PATCH 2/2] docs(protocol): the discovery example's `ai` entry carries the corrected sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift check flagged it and a targeted grep found it: http-protocol.mdx's discovery sample printed the `ai` slot's message as "No implementation ships for the 'ai' slot" — the line I wrote in #4204, made stale by this PR correcting the very claim it quotes. The `workflow` entry beside it is left alone: nothing registers that slot in either repository, so its sentence is still true. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- content/docs/protocol/kernel/http-protocol.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 291cbc8567..c1be47021a 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -55,7 +55,7 @@ GET /api/v1/discovery HTTP/1.1 "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" }, "workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" }, - "ai": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'ai' slot — register a service under it to enable" } + "ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" } }, "locale": { "default": "en-US",