Skip to content

Commit 05154a1

Browse files
os-zhuangclaude
andauthored
fix(spec,runtime): the ai slot's remedy stops claiming nothing ships (#4093 follow-up) (#4301)
CORE_SERVICE_PROVIDER recorded `null` for `ai` because no WORKSPACE package provides it, so `serviceUnavailableMessage('ai')` said "No implementation ships for the 'ai' slot". That is false — `@objectstack/service-ai` registers it — and it is the same class of wrong answer this table was introduced to end, one step further out: "not in this repository" was conflated with "does not exist". How it got in is worth naming. The claim that ai's provider is a Cloud/EE package was true, but it was asserted from a doc line and an old code comment without checking — exactly the provenance that made `plugin-search` and `plugin-workflow` wrong in the first place. 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". `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 on double-sided evidence now. `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. 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 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, and its failure text now tells the next person what to do in exactly this case: `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 four stale claims about /ai's absent-service answer: three calling it a 404 (docs/api/client-sdk.mdx, docs/releases/v17.mdx, a comment in packages/client's URL-conformance test), stale since #4222 made it 501; and the discovery sample in docs/protocol/kernel/http-protocol.mdx, which quoted the very sentence this change corrects. 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 <noreply@anthropic.com>
1 parent 863ba70 commit 05154a1

10 files changed

Lines changed: 101 additions & 35 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Discovery stops telling Cloud/Enterprise deployments that nothing implements `ai` (#4093 follow-up).
7+
8+
`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.
9+
10+
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.
11+
12+
`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.
13+
14+
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.
15+
16+
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.
17+
18+
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.

content/docs/api/client-sdk.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,9 @@ await client.notifications.markAllRead();
315315
// through sys_inbox_message and tracks read-state in sys_notification_receipt.
316316
// These helpers will be repointed during the objectui bell cut-over.
317317

318-
// AI — served by `service-ai` (Cloud/EE); 404s "AI service is not configured"
319-
// when the plugin is absent, so check `discovery.services` first.
318+
// AI — served by `service-ai` (Cloud/EE). Without it the route is mounted but
319+
// unimplemented, so it answers 501 and discovery reports the slot unavailable
320+
// with the same sentence; check `discovery.services` first.
320321
const answer = await client.ai.chat({
321322
messages: [{ role: 'user', content: 'How many open orders this quarter?' }],
322323
conversationId, // omit to have one created and echoed back

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ GET /api/v1/discovery HTTP/1.1
5555
"metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" },
5656
"auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" },
5757
"workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" },
58-
"ai": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'ai' slot — register a service under it to enable" }
58+
"ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" }
5959
},
6060
"locale": {
6161
"default": "en-US",

content/docs/releases/v17.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -727,9 +727,9 @@ query dialect only the dispatcher understood.
727727
async generator, so the request is issued — and an HTTP error thrown — when you
728728
call it, not when you first iterate.
729729

730-
`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and 404s
731-
`AI service is not configured` without it, so check `discovery.services` before
732-
calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better
730+
`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and, without
731+
it, answers 501 — the route is mounted, the implementation is not — so check
732+
`discovery.services` before calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better
733733
client — these methods are for callers that are not components.
734734

735735
The spec's dead AI declarations retire with the namespace:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ const CONTROL_PLANE_NAMESPACE = 'projects.';
154154
/**
155155
* The AI plane. `/api/v1/ai/*` is served by `service-ai`, a Cloud/EE package in
156156
* the sibling `cloud` repo; this repo's dispatcher only PROXIES the prefix to
157-
* whatever `buildAIRoutes()` mounted (or 404s "AI service is not configured").
157+
* whatever `buildAIRoutes()` mounted (or 501s when that package is absent).
158158
* No in-repo ledger can enumerate that table, so — exactly like the control
159159
* plane above — the prefix is exempt HERE and guarded THERE.
160160
*

packages/runtime/src/domains/ai.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,12 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
6969
// [#3842] Was a hand-rolled envelope with the status in `code`. It has
7070
// no header or shape of its own, so it is simply the shared exit now.
7171
// 501, not 404: `/ai/*` IS mounted, so the request reached a handler
72-
// with nothing behind it — see ./unavailable.ts. The message stays
73-
// local rather than using the shared sentence: the real provider
74-
// (`@objectstack/service-ai`) ships outside this workspace as a
75-
// Cloud/EE package, so CORE_SERVICE_PROVIDER — verified against
76-
// workspace packages by check:service-providers — records `null` for
77-
// this slot and would describe it as "nothing ships", which is wrong.
78-
return capabilityUnavailable(deps, 'ai', 'AI service is not configured');
72+
// with nothing behind it — see ./unavailable.ts. This used to pass a
73+
// local message because the shared sentence said "nothing ships",
74+
// which is false for a Cloud/Enterprise deployment. The shared table
75+
// says the accurate thing now (REMEDY_DETAIL), so the override is gone
76+
// and this answer matches what discovery reports for the slot.
77+
return capabilityUnavailable(deps, 'ai');
7978
}
8079

8180
// The AI service exposes route definitions via buildAIRoutes.

packages/runtime/src/domains/unavailable.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,17 @@ import type { DomainHandlerDeps } from '../domain-handler-registry.js';
4646
* entry cannot drift into naming different remedies — and a caller who hits
4747
* the wall gets the fix without a second round trip.
4848
*
49+
* There is deliberately no per-call message override. One briefly existed for
50+
* `ai`, whose provider ships outside this workspace, and it hid the real bug:
51+
* the shared table said "nothing ships" for a slot that Cloud/Enterprise does
52+
* provide. Teaching the table to say the accurate thing fixed the domain AND
53+
* discovery, which the override could only ever have fixed here. A slot whose
54+
* sentence is wrong needs the table corrected, not a local exception.
55+
*
4956
* @param slot the `CoreServiceName` key, NOT the route segment — `/notifications`
5057
* is served by the `notification` slot, and the remedy is looked up
5158
* by slot.
5259
*/
53-
export function capabilityUnavailable(
54-
deps: DomainHandlerDeps,
55-
slot: string,
56-
/**
57-
* Overrides the shared sentence. Only for slots whose provider cannot be
58-
* named by `CORE_SERVICE_PROVIDER` — it is verified against workspace
59-
* packages, so a real provider that ships outside this repo (`ai`) has no
60-
* entry there and would otherwise be described as "nothing ships".
61-
*/
62-
message?: string,
63-
): HttpDispatcherResult {
64-
return { handled: true, response: deps.error(message ?? serviceUnavailableMessage(slot), 501) };
60+
export function capabilityUnavailable(deps: DomainHandlerDeps, slot: string): HttpDispatcherResult {
61+
return { handled: true, response: deps.error(serviceUnavailableMessage(slot), 501) };
6562
}

packages/spec/src/system/core-service-provider.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,28 @@ describe('CORE_SERVICE_PROVIDER', () => {
2222
expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging');
2323
});
2424

25-
it('uses null — not a plausible name — for slots nothing implements', () => {
25+
it('uses null — not a plausible name — where no package can be installed', () => {
2626
for (const slot of ['ai', 'search', 'workflow', 'graphql']) {
27-
expect(CORE_SERVICE_PROVIDER[slot], `${slot} must have no provider`).toBeNull();
27+
expect(CORE_SERVICE_PROVIDER[slot], `${slot} must name no installable package`).toBeNull();
28+
}
29+
});
30+
31+
// `null` covers two different situations, and only one of them means
32+
// "nothing exists". Verified against objectstack-ai/cloud: nothing there
33+
// registers search/workflow/graphql, but `@objectstack/service-ai` does
34+
// register `ai` — it is simply `private: true`, so there is no package to
35+
// install and no name that belongs in an "Install X" sentence.
36+
it('still says something ships for a slot whose provider is real but uninstallable', () => {
37+
const ai = serviceUnavailableMessage('ai');
38+
expect(ai).not.toMatch(/No implementation ships/);
39+
expect(ai).toContain('@objectstack/service-ai');
40+
expect(ai).toMatch(/Cloud\/Enterprise/);
41+
// Still not an instruction a reader could act on and fail at.
42+
expect(ai).not.toMatch(/^Install /);
43+
44+
// The genuinely-empty slots keep the plain sentence.
45+
for (const slot of ['search', 'workflow', 'graphql']) {
46+
expect(serviceUnavailableMessage(slot), slot).toMatch(/No implementation ships/);
2847
}
2948
});
3049

packages/spec/src/system/core-services.zod.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,21 @@ export const CORE_SERVICE_PROVIDER: Readonly<Record<string, string | null>> = {
9090
// `/ui` is served by the `protocol` service, which MetadataPlugin registers;
9191
// the `ui` slot itself has no implementation anywhere (#4093 / #4146).
9292
'ui': '@objectstack/metadata-protocol',
93-
// Nothing ships for these. `search` and `workflow` have no consumer either
94-
// (ADR-0115 Evidence 5); `graphql` and `ai` have surfaces but no provider.
93+
// `null` means "no name belongs in an `Install X` sentence", which covers two
94+
// different situations — see REMEDY_DETAIL, which is how the second one still
95+
// gets an accurate message.
96+
//
97+
// Nothing provides the slot at all: `search`, `workflow` (no consumer
98+
// either — ADR-0115 Evidence 5) and `graphql` (a surface with no provider).
99+
// Verified across BOTH repositories: nothing in `objectstack-ai/cloud`
100+
// registers them.
101+
//
102+
// A provider exists but cannot be installed: `ai`. `@objectstack/service-ai`
103+
// registers this slot in `objectstack-ai/cloud` and is `private: true`, so
104+
// naming it would send a reader after a package they cannot obtain — the
105+
// exact failure this table was written to end. It carries a REMEDY_DETAIL
106+
// sentence instead; a bare `null` here would tell a Cloud/Enterprise
107+
// deployment that nothing ships, which is false.
95108
'ai': null,
96109
'search': null,
97110
'workflow': null,
@@ -110,6 +123,12 @@ export const CORE_SERVICE_PROVIDER: Readonly<Record<string, string | null>> = {
110123
*/
111124
const REMEDY_DETAIL: Readonly<Record<string, string>> = {
112125
'ui': 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable',
126+
// A provider EXISTS — `@objectstack/service-ai` registers this slot — but it
127+
// lives in the `objectstack-ai/cloud` repository and is `private: true`, so
128+
// there is nothing to install and no name that belongs in an "Install X"
129+
// sentence. Without this entry the `null` above reads as "nothing ships",
130+
// which is what a Cloud/Enterprise deployment is NOT looking at.
131+
'ai': 'Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework',
113132
};
114133

115134
/**

scripts/check-service-providers.mjs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@
2222
// 2. A slot in `CoreServiceName` with no entry at all — which would fall
2323
// back to `undefined` and print a remedy naming nothing.
2424
//
25-
// `null` is a legitimate entry: it means nothing ships for that slot yet, and
26-
// the message says so instead of naming a plausible package.
25+
// `null` is a legitimate entry, and it means "no name belongs in an `Install X`
26+
// sentence" — which covers TWO situations. Nothing provides the slot at all
27+
// (`search`, `workflow`, `graphql`), or a provider exists but cannot be
28+
// installed: `@objectstack/service-ai` registers `ai` in objectstack-ai/cloud
29+
// and is `private: true`. This script can only see workspace packages, so it
30+
// cannot tell those apart — `REMEDY_DETAIL` in the same file is where the
31+
// second kind gets an accurate sentence, and the reason a bare `null` must not
32+
// be read here (or reported) as "nothing exists".
2733

2834
import { readFileSync, readdirSync, existsSync } from 'node:fs';
2935
import { join } from 'node:path';
@@ -86,7 +92,9 @@ for (const [slot, pkg] of table) {
8692
problems.push(
8793
` ${slot}${pkg}\n`
8894
+ ' Not a workspace package. Point it at the package that actually registers\n'
89-
+ ` the '${slot}' slot, or use \`null\` if nothing ships for it yet.`,
95+
+ ` the '${slot}' slot, or use \`null\` if no installable package provides it\n`
96+
+ ' (adding a REMEDY_DETAIL sentence if something ships that simply cannot\n'
97+
+ ' be installed, e.g. a private package in another repository).',
9098
);
9199
}
92100
}
@@ -115,7 +123,12 @@ if (problems.length > 0) {
115123
}
116124

117125
const named = [...table.values()].filter(Boolean).length;
126+
// Deliberately NOT "nothing ships yet" — `null` means "no name belongs in an
127+
// `Install X` sentence", which also covers a provider that exists but cannot be
128+
// installed (`ai`, whose `@objectstack/service-ai` is private to the cloud
129+
// repo). Saying "nothing ships" here would repeat, in this script's own output,
130+
// the conflation the table was corrected to stop making.
118131
console.log(
119-
`✓ check:service-providers — ${table.size} slot(s): ${named} name a real workspace `
120-
+ `package, ${table.size - named} correctly report that nothing ships yet.`,
132+
`✓ check:service-providers — ${table.size} slot(s): ${named} name an installable workspace `
133+
+ `package, ${table.size - named} name none (see REMEDY_DETAIL for those that still ship something).`,
121134
);

0 commit comments

Comments
 (0)