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
18 changes: 18 additions & 0 deletions .changeset/ai-slot-remedy-tells-the-truth.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/client-url-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
13 changes: 6 additions & 7 deletions packages/runtime/src/domains/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 9 additions & 12 deletions packages/runtime/src/domains/unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
23 changes: 21 additions & 2 deletions packages/spec/src/system/core-service-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}
});

Expand Down
23 changes: 21 additions & 2 deletions packages/spec/src/system/core-services.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,21 @@ export const CORE_SERVICE_PROVIDER: Readonly<Record<string, string | null>> = {
// `/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,
Expand All @@ -110,6 +123,12 @@ export const CORE_SERVICE_PROVIDER: Readonly<Record<string, string | null>> = {
*/
const REMEDY_DETAIL: Readonly<Record<string, string>> = {
'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',
};

/**
Expand Down
23 changes: 18 additions & 5 deletions scripts/check-service-providers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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).',
);
}
}
Expand Down Expand Up @@ -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).`,
);
Loading