From 4ccbd6f36d0d60156479c759671f330405bf3b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:12:42 +0000 Subject: [PATCH 1/4] fix(spec,runtime,metadata-protocol): discovery's "install this to enable" names a package that exists (#4093 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000 — the whole D12 lineage is about it. The second was invented from the slot name, and nothing ever checked it. The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots. metadata-protocol hand-wrote a table where TEN OF FIFTEEN entries named a package that does not exist: plugin-redis, plugin-bullmq, job-scheduler, plugin-notifications, plugin-storage, plugin-automation, ui-plugin, plus plugin-ai / plugin-search / plugin-workflow for slots nothing implements at all. The same value is surfaced as discovery's `provider`. That is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real". A package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack, and an agent reading discovery cannot tell it apart from a package it should install. CORE_SERVICE_PROVIDER + serviceUnavailableMessage() in @objectstack/spec/system are now the one place that sentence is written, read by both builders — so the two hosts cannot name different packages for the same slot, the drift #4089 and #4130 closed for the `metadata` and `data` entries. A parity test asserts it. Entries were verified against what actually calls `registerService` for each slot, not against name similarity. That is how `notification` turned out to be filled by @objectstack/service-messaging — the one slot whose package shares no word with its name, and the proof that a name-derived guess cannot be right in general. Four slots (ai, search, workflow, graphql) have no implementation anywhere, so `null` is a first-class entry and the message says nothing ships rather than naming a plausible package. `ui` keeps the fuller sentence from #4146 — it is served by the `protocol` service and nothing registers the `ui` slot, so "install X" alone would read as "X fills this slot" and leave the reader staring at an empty slot afterwards. Moving it into the shared table is what the parity test surfaced: the two builders agreed on the package but not on the sentence, and the richer one deserved to win rather than be flattened. scripts/check-service-providers.mjs (wired into the lint workflow as check:service-providers) fails CI on either way this table rots: a name that is not a real workspace package, or a CoreServiceName slot with no entry at all. Both modes verified by temporarily breaking the table. Same spirit as check-route-envelope, which caught a stale exemption of mine earlier today — the guard has to outlive the fix, or the next rename re-introduces it. Verified: spec 7143, runtime 947, metadata-protocol 110 pass; build 71/71; pnpm lint and all eleven check:* scripts clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- .../discovery-remedy-names-a-real-package.md | 21 +++ .github/workflows/lint.yml | 10 ++ package.json | 1 + packages/metadata-protocol/src/protocol.ts | 52 +++++--- packages/runtime/src/http-dispatcher.test.ts | 55 ++++++++ packages/runtime/src/http-dispatcher.ts | 13 +- .../src/system/core-service-provider.test.ts | 70 ++++++++++ packages/spec/src/system/core-services.zod.ts | 86 +++++++++++++ scripts/check-service-providers.mjs | 121 ++++++++++++++++++ 9 files changed, 407 insertions(+), 22 deletions(-) create mode 100644 .changeset/discovery-remedy-names-a-real-package.md create mode 100644 packages/spec/src/system/core-service-provider.test.ts create mode 100644 scripts/check-service-providers.mjs diff --git a/.changeset/discovery-remedy-names-a-real-package.md b/.changeset/discovery-remedy-names-a-real-package.md new file mode 100644 index 0000000000..2c656512e9 --- /dev/null +++ b/.changeset/discovery-remedy-names-a-real-package.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': patch +--- + +Discovery's "install this to enable" now names a package that exists (#4093 follow-up). + +Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000. The second was invented from the slot name. + +The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots, and `metadata-protocol` carried a hand-written table in which **ten of fifteen entries named a package that does not exist** — `plugin-redis`, `plugin-bullmq`, `job-scheduler`, `plugin-notifications`, `plugin-storage`, `plugin-automation`, `ui-plugin`, plus `plugin-ai`, `plugin-search` and `plugin-workflow` for slots nothing implements at all. That value is also surfaced as discovery's `provider`. + +A remedy naming a package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack — and an agent reading discovery cannot tell it apart from a package it should install. It is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real". + +`CORE_SERVICE_PROVIDER` and `serviceUnavailableMessage()` in `@objectstack/spec/system` are now the one place that sentence is written, and both discovery builders read them, so the two hosts cannot tell a consumer to install different things (the drift #4089 and #4130 closed for the `metadata` and `data` entries). Entries were verified against what actually calls `registerService` for each slot rather than against name similarity — which is how `notification` turned out to be filled by `@objectstack/service-messaging`, the one slot whose package shares no word with its name. + +Four slots — `ai`, `search`, `workflow`, `graphql` — have no implementation anywhere, so they now say so instead of naming a plausible package. `ui` keeps the fuller sentence it got in #4146 (`/ui` is served by the `protocol` service; nothing registers the `ui` slot), and that sentence now reaches both builders instead of one. + +`scripts/check-service-providers.mjs` (wired into the lint workflow as `check:service-providers`) fails CI when a named package is not a real workspace package, or when a `CoreServiceName` slot has no entry — so a rename or a deletion cannot leave a stale instruction behind. + +FROM → TO: `services..message` and `services..provider` change text for most unavailable slots. Anything matching on the old `Install a plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a2e8888b6e..585f109d53 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -99,6 +99,16 @@ jobs: - name: Single authz resolver guard run: pnpm check:authz-resolver + # #4093 follow-up. Discovery tells a consumer an absent capability is + # absent AND what to install. The first half has been carefully honest + # since #2462/#4000; the second was invented from the slot name, so ten + # of fifteen entries named packages that do not exist — a dead end handed + # to whoever is trying to fix their stack. Asserts every package named in + # CORE_SERVICE_PROVIDER is a real workspace package, and that no core + # slot is missing an entry. + - name: Service-provider remedy guard + run: pnpm check:service-providers + # #3843 response-envelope guard. The route ledgers audit which routes EXIST; # nothing audited what comes back, so six route modules emitted bodies # outside BaseResponseSchema while carrying green `sdk` rows. Counts the diff --git a/package.json b/package.json index e48faaeaba..4c26e4897b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check:role-word": "node scripts/check-role-word.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs", "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs", + "check:service-providers": "node scripts/check-service-providers.mjs", "check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs", "check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs", "check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs", diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 229a6c3ac4..59e4230866 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21,7 +21,7 @@ import { readServiceSelfInfo } from '@objectstack/spec/api'; import { parseFilterAST, isFilterAST, type DroppedFieldsEvent, type QueryAST } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; -import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed } from '@objectstack/spec/kernel'; import { extractProtection, @@ -863,28 +863,42 @@ function suggestQueryParam(param: string, knownFields: readonly string[]): strin * not advertise a route for it (ADR-0076 D12, #2462: an advertised route * with no mounted handler 404s and misleads consumers). */ -const SERVICE_CONFIG: Record = { +/** + * [#4093 follow-up] `plugin` is no longer written here. It named the package a + * consumer should install, and ten of the fifteen names did not exist — + * `plugin-redis` / `plugin-bullmq` / `job-scheduler` / `plugin-notifications` / + * `plugin-storage` / `plugin-automation` / `ui-plugin`, plus `plugin-ai`, + * `plugin-search` and `plugin-workflow` for slots nothing implements at all. + * The value is surfaced as discovery's `provider` and as its remedy line, so a + * wrong name is a dead end handed to whoever is trying to fix their stack. + * + * It now comes from `CORE_SERVICE_PROVIDER` in `@objectstack/spec/system`, the + * one table both discovery builders read, verified against what actually + * registers each slot and guarded by `scripts/check-service-providers.mjs`. + * Only the ROUTE stays local — that is this builder's own knowledge. + */ +const SERVICE_CONFIG: Record = { // Plugin-provided like every other optional service since the degraded // ObjectQL fallback was retired (#3891): advertised iff the real engine // is registered — never hardcoded 'available' (the pre-#2462 lie the // fallback existed to paper over). - analytics: { route: '/api/v1/analytics', plugin: 'service-analytics' }, - auth: { route: '/api/v1/auth', plugin: 'plugin-auth' }, - automation: { route: '/api/v1/automation', plugin: 'plugin-automation' }, - cache: { route: '/api/v1/cache', plugin: 'plugin-redis' }, - queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' }, - job: { route: '/api/v1/jobs', plugin: 'job-scheduler' }, - ui: { route: '/api/v1/ui', plugin: 'ui-plugin' }, - workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' }, + analytics: { route: '/api/v1/analytics' }, + auth: { route: '/api/v1/auth' }, + automation: { route: '/api/v1/automation' }, + cache: { route: '/api/v1/cache' }, + queue: { route: '/api/v1/queue' }, + job: { route: '/api/v1/jobs' }, + ui: { route: '/api/v1/ui' }, + workflow: { route: '/api/v1/workflow' }, // service-realtime is an in-process pub/sub bus; nothing mounts // /api/v1/realtime, so no route is advertised (D12, #2462). - realtime: { plugin: 'service-realtime' }, - notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' }, - ai: { route: '/api/v1/ai', plugin: 'plugin-ai' }, - i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' }, - graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) - 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' }, - search: { route: '/api/v1/search', plugin: 'plugin-search' }, + realtime: {}, + notification: { route: '/api/v1/notifications' }, + ai: { route: '/api/v1/ai' }, + i18n: { route: '/api/v1/i18n' }, + graphql: { route: '/graphql' }, // GraphQL uses /graphql by convention (not versioned REST) + 'file-storage': { route: '/api/v1/storage' }, + search: { route: '/api/v1/search' }, }; /** @@ -1694,7 +1708,7 @@ export class ObjectStackProtocolImplementation implements enabled: true, status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)), route: advertisedRoute(serviceName, config.route), - provider: config.plugin, + provider: CORE_SERVICE_PROVIDER[serviceName] ?? undefined, ...(noHttpSurface || self?.handlerReady !== undefined ? { handlerReady: noHttpSurface ? false : self?.handlerReady } : {}), @@ -1709,7 +1723,7 @@ export class ObjectStackProtocolImplementation implements services[serviceName] = { enabled: false, status: 'unavailable' as const, - message: `Install ${config.plugin} to enable`, + message: serviceUnavailableMessage(serviceName), }; } } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 09736afcaa..8fa6cf2c4b 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2290,6 +2290,61 @@ describe('HttpDispatcher', () => { expect(info.features.i18n).toBe(false); }); + // [#4093 follow-up] Discovery's remedy line must name a package that can + // actually be installed. It used to be templated from the slot name + // (`Install a ${slot} plugin to enable`), which named nothing real for + // `ai` / `search` / `workflow` and got it wrong wherever the package is + // not called after its slot. Both builders now read one table. + describe('unavailable slots name a real remedy (#4093 follow-up)', () => { + beforeEach(() => { + (kernel as any).getService = vi.fn().mockResolvedValue(null); + (kernel as any).services = new Map(); + }); + + it('names the actual package, including where the name differs from the slot', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + // service-messaging fills `notification` — the case a + // name-derived guess can never get right. + expect(info.services.notification.message).toBe('Install @objectstack/service-messaging to enable'); + expect(info.services.auth.message).toBe('Install @objectstack/plugin-auth to enable'); + expect(info.services['file-storage'].message).toBe('Install @objectstack/service-storage to enable'); + }); + + it('says nothing ships rather than naming a package that does not exist', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + for (const slot of ['ai', 'search', 'workflow'] as const) { + expect(info.services[slot].message, `services.${slot}.message`).not.toMatch(/Install/); + expect(info.services[slot].message, `services.${slot}.message`).toContain(slot); + } + }); + + it('never emits the old slot-name-derived template', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + for (const [slot, entry] of Object.entries(info.services as Record)) { + if (typeof entry?.message !== 'string') continue; + expect(entry.message, `services.${slot}.message`).not.toMatch(/Install a .+ plugin to enable/); + } + }); + + it('gives the same remedy as the metadata-protocol builder', async () => { + const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol'); + const fromProtocol = (await new ObjectStackProtocolImplementation( + mockObjectQL as any, + () => new Map(), + ).getDiscovery()).services; + const fromDispatcher = (await dispatcher.getDiscoveryInfo('/api/v1')).services as Record; + + // Every slot both builders report must carry the same remedy — + // two hosts telling a consumer to install different things is + // the drift #4089/#4130 closed for `metadata` and `data`. + for (const slot of Object.keys(fromProtocol)) { + const mine = fromDispatcher[slot]; + if (!mine || mine.enabled !== false) continue; + expect(mine.message, `services.${slot}.message parity`).toBe((fromProtocol as any)[slot].message); + } + }); + }); + it('should detect i18n via getServiceAsync (async factory) in discovery', async () => { const mockI18nService = { getLocales: vi.fn().mockReturnValue(['en', 'fr']), diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 636b96798a..dd88e4dc94 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -5,7 +5,7 @@ import { } from '@objectstack/core'; import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; -import { CoreServiceName } from '@objectstack/spec/system'; +import { CoreServiceName, serviceUnavailableMessage } from '@objectstack/spec/system'; import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -995,9 +995,16 @@ export class HttpDispatcher { } return { enabled: true, status: 'available' as const, handlerReady: true, route, provider }; }; + // [#4093 follow-up] The remedy comes from the shared provider table + // (`CORE_SERVICE_PROVIDER`), not from the slot name. `Install a ${name} + // plugin to enable` named a package that does not exist for `ai`, + // `search` and `workflow` — nothing implements those slots — and got + // the name wrong wherever the package is not called after its slot + // (`notification` is filled by service-messaging). A fix a consumer + // cannot carry out is worse than no fix named. const svcUnavailable = (name: string) => ({ enabled: false, status: 'unavailable' as const, handlerReady: false, - message: `Install a ${name} plugin to enable`, + message: serviceUnavailableMessage(name), }); // Self-description of the registered realtime service, if any (D12). @@ -1124,7 +1131,7 @@ export class HttpDispatcher { ? svcAvailable(routes.ui, 'metadata-protocol', protocolSvc) : { enabled: false, status: 'unavailable' as const, handlerReady: false, - message: 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', + message: serviceUnavailableMessage('ui'), }, workflow: hasWorkflow ? svcAvailable(routes.workflow, undefined, workflowSvc) : svcUnavailable('workflow'), // Honest entry (ADR-0076 D12, #2462): the registered realtime diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts new file mode 100644 index 0000000000..813ccc83c2 --- /dev/null +++ b/packages/spec/src/system/core-service-provider.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { CoreServiceName, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from './core-services.zod'; + +/** + * [#4093 follow-up] Discovery tells a consumer two things about an absent + * capability: that it is absent, and what to do about it. The first has been + * carefully honest since #2462/#4000. The second was invented from the slot + * name — `Install a ${slot} plugin to enable` in the dispatcher, and a + * hand-written table in metadata-protocol where ten of fifteen names were not + * real packages. These pin the remedy to reality. + * + * `scripts/check-service-providers.mjs` is the other half: it fails CI when a + * name here stops being a workspace package. This file pins the SHAPE of the + * answer; the script pins its TRUTH against the filesystem. + */ +describe('CORE_SERVICE_PROVIDER', () => { + it('never names a package derived from the slot name alone', () => { + // The entry that proves the rule: nothing about "notification" suggests + // "messaging", so any name-derived guess is wrong here by construction. + expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging'); + }); + + it('uses null — not a plausible name — for slots nothing implements', () => { + for (const slot of ['ai', 'search', 'workflow', 'graphql']) { + expect(CORE_SERVICE_PROVIDER[slot], `${slot} must have no provider`).toBeNull(); + } + }); + + it('scopes every named package, so the remedy is copy-pasteable', () => { + for (const [slot, pkg] of Object.entries(CORE_SERVICE_PROVIDER)) { + if (pkg === null) continue; + expect(pkg, `${slot} provider`).toMatch(/^@objectstack\//); + } + }); + + it('covers every core slot that can be filled by an installable package', () => { + // `data` and `metadata` come from the engine itself, never from an + // optional package, so they carry no remedy. + const engineOwned = new Set(['data', 'metadata']); + for (const slot of CoreServiceName.options) { + if (engineOwned.has(slot)) continue; + expect( + Object.prototype.hasOwnProperty.call(CORE_SERVICE_PROVIDER, slot), + `${slot} must have an entry (even if null)`, + ).toBe(true); + } + }); +}); + +describe('serviceUnavailableMessage', () => { + it('names the package to install when one exists', () => { + expect(serviceUnavailableMessage('auth')).toBe('Install @objectstack/plugin-auth to enable'); + expect(serviceUnavailableMessage('file-storage')).toBe('Install @objectstack/service-storage to enable'); + }); + + it('says nothing ships rather than naming a package that cannot be installed', () => { + const msg = serviceUnavailableMessage('search'); + expect(msg).not.toMatch(/Install/); + expect(msg).toContain("'search'"); + // The old template produced exactly this — a package that never existed. + expect(msg).not.toContain('search plugin'); + }); + + it('treats an unknown slot as "nothing ships", never inventing a name', () => { + const msg = serviceUnavailableMessage('not-a-real-slot'); + expect(msg).not.toMatch(/Install/); + }); +}); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index 6ddf8e99d9..a2644477d2 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -43,6 +43,92 @@ export const CoreServiceName = z.enum([ export type CoreServiceName = z.infer; +/** + * Which published package actually fills each service slot — or `null` when + * **nothing ships for it yet**. + * + * [#4093 follow-up] Discovery tells a consumer two things about an absent + * capability: that it is absent, and what to do about it. The first has been + * carefully honest since #2462/#4000; the second was invented from the slot + * name. The dispatcher templated `Install a ${slot} plugin to enable` for 12 + * slots, and metadata-protocol carried a hand-written table in which **ten of + * fifteen** names did not exist (`plugin-redis`, `plugin-bullmq`, + * `job-scheduler`, `plugin-notifications`, `plugin-storage`, `ui-plugin`, + * `plugin-automation`, and `plugin-ai` / `plugin-search` / `plugin-workflow` + * for slots nothing implements). That value is also surfaced as `provider`. + * + * A remedy naming a package that cannot be installed is a dead end handed to + * someone at the exact moment they are trying to fix their stack — the same + * `declared ≠ enforced` failure this lineage has been closing, one level over: + * not "does the capability exist" but "is the fix real". An agent reading + * discovery cannot tell the difference between a package it should install and + * one that was never written. + * + * Entries are verified against what actually calls `registerService` for the + * slot, not against name similarity, and `scripts/check-service-providers.mjs` + * fails CI if any name here is not a real workspace package — so a rename or a + * deletion cannot leave a stale instruction behind. + * + * `null` is a first-class answer, not a gap: for those slots the honest remedy + * is "nothing to install", and saying so beats naming a plausible package. + */ +export const CORE_SERVICE_PROVIDER: Readonly> = { + // Verified: each of these registers the slot itself. + 'analytics': '@objectstack/service-analytics', + 'auth': '@objectstack/plugin-auth', + 'automation': '@objectstack/service-automation', + 'cache': '@objectstack/service-cache', + 'queue': '@objectstack/service-queue', + 'job': '@objectstack/service-job', + 'realtime': '@objectstack/service-realtime', + 'file-storage': '@objectstack/service-storage', + 'i18n': '@objectstack/service-i18n', + // The `notification` slot is filled by the messaging service — the one entry + // whose package name shares no word with its slot, and the reason a + // name-derived guess cannot be right in general. + 'notification': '@objectstack/service-messaging', + // `/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. + 'ai': null, + 'search': null, + 'workflow': null, + 'graphql': null, +} as const; + +/** + * Slots whose remedy needs more than "install X", because **the slot is not + * what serves the capability**. `Install ` would be true but misleading: + * it reads as "this package fills this slot", and someone who then checks + * discovery still sees the slot empty. + * + * `/ui` is the case (#4146): it is served by the `protocol` service, and + * nothing anywhere registers `ui` — so the sentence has to say what actually + * serves it, not just what to install. + */ +const REMEDY_DETAIL: Readonly> = { + 'ui': 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', +}; + +/** + * The remedy line discovery reports for an unavailable slot — the one place + * that sentence is written, so the dispatcher and the metadata-protocol + * builder cannot tell a consumer to install different things (the drift #4089 + * and #4130 closed for the `metadata` and `data` entries). + * + * See {@link CORE_SERVICE_PROVIDER}. + */ +export function serviceUnavailableMessage(slot: string): string { + const detail = REMEDY_DETAIL[slot]; + if (detail) return detail; + const pkg = CORE_SERVICE_PROVIDER[slot]; + return pkg + ? `Install ${pkg} to enable` + : `No implementation ships for the '${slot}' slot — register a service under it to enable`; +} + /** * Service Criticality Level * Defines the startup behavior when a service is missing. diff --git a/scripts/check-service-providers.mjs b/scripts/check-service-providers.mjs new file mode 100644 index 0000000000..e7612794bf --- /dev/null +++ b/scripts/check-service-providers.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Guards `CORE_SERVICE_PROVIDER` (packages/spec/src/system/core-services.zod.ts): +// the table that tells a consumer WHICH PACKAGE to install when discovery +// reports a service slot unavailable. +// +// The bug it exists to prevent (#4093 follow-up): that remedy used to be +// invented from the slot name — the dispatcher templated `Install a ${slot} +// plugin to enable`, and metadata-protocol hand-wrote a table in which ten of +// fifteen names were not real packages (`plugin-redis`, `plugin-bullmq`, +// `job-scheduler`, `plugin-notifications`, `plugin-storage`, `ui-plugin`, +// `plugin-automation`, plus `plugin-ai` / `plugin-search` / `plugin-workflow` +// for slots nothing implements). Discovery also surfaces the value as +// `provider`. A name that cannot be installed is a dead end handed to someone +// at the moment they are trying to fix their stack, and an agent reading +// discovery cannot tell it apart from a package it should install. +// +// Two ways that table rots, both caught here: +// 1. A name that is not a workspace package (typo, guess, or a rename +// landing in the package but not here). +// 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. + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = process.cwd(); +const TABLE_FILE = 'packages/spec/src/system/core-services.zod.ts'; + +/** Every package name declared anywhere in the workspace. */ +function workspacePackageNames() { + const names = new Set(); + const roots = ['packages', join('packages', 'plugins'), join('packages', 'services')]; + for (const dir of roots) { + const abs = join(ROOT, dir); + if (!existsSync(abs)) continue; + for (const entry of readdirSync(abs)) { + const pkg = join(abs, entry, 'package.json'); + if (!existsSync(pkg)) continue; + try { + const { name } = JSON.parse(readFileSync(pkg, 'utf8')); + if (name) names.add(name); + } catch { /* unparseable package.json is another check's problem */ } + } + } + return names; +} + +/** Slot → provider entries, read from the table's source. */ +function parseTable(src) { + const start = src.indexOf('export const CORE_SERVICE_PROVIDER'); + if (start === -1) throw new Error(`CORE_SERVICE_PROVIDER not found in ${TABLE_FILE}`); + const end = src.indexOf('} as const;', start); + if (end === -1) throw new Error('CORE_SERVICE_PROVIDER is not closed with `} as const;`'); + const body = src.slice(start, end); + const entries = new Map(); + // `'slot': '@scope/pkg',` or `'slot': null,` — comments are skipped by the + // anchored quote/null alternation rather than by stripping them. + for (const m of body.matchAll(/'([a-z0-9-]+)':\s*(?:'([^']+)'|(null))\s*,/g)) { + entries.set(m[1], m[3] ? null : m[2]); + } + return entries; +} + +/** The slot names the kernel declares. */ +function coreServiceNames(src) { + const start = src.indexOf('export const CoreServiceName = z.enum(['); + const end = src.indexOf(']);', start); + return [...src.slice(start, end).matchAll(/'([a-z0-9-]+)'/g)].map((m) => m[1]); +} + +const src = readFileSync(join(ROOT, TABLE_FILE), 'utf8'); +const table = parseTable(src); +const slots = coreServiceNames(src); +const packages = workspacePackageNames(); + +const problems = []; + +for (const [slot, pkg] of table) { + if (pkg === null) continue; + if (!packages.has(pkg)) { + 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.`, + ); + } +} + +// `data` and `metadata` are filled by the engine itself (ObjectQL / the +// protocol implementation), never by an installable optional package, so they +// carry no remedy and need no entry. +const NO_REMEDY_SLOTS = new Set(['data', 'metadata']); +for (const slot of slots) { + if (NO_REMEDY_SLOTS.has(slot) || table.has(slot)) continue; + problems.push( + ` ${slot} → (missing)\n` + + ' Declared in CoreServiceName but absent from CORE_SERVICE_PROVIDER, so\n' + + ' discovery would report it unavailable with a remedy naming nothing.', + ); +} + +if (problems.length > 0) { + console.error('✗ Service-provider remedies (#4093 follow-up)\n'); + console.error(problems.join('\n\n')); + console.error( + '\nDiscovery reports these as `provider` and as "Install X to enable". A name\n' + + `that cannot be installed is worse than none. See ${TABLE_FILE}.`, + ); + process.exit(1); +} + +const named = [...table.values()].filter(Boolean).length; +console.log( + `✓ check:service-providers — ${table.size} slot(s): ${named} name a real workspace ` + + `package, ${table.size - named} correctly report that nothing ships yet.`, +); From 02629f196892ef149060161d2ff6846dce731f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:15:34 +0000 Subject: [PATCH 2/4] docs(kernel,protocol): the two discovery examples show the remedy that ships now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift check flagged 113 docs — the blast radius of touching @objectstack/spec — but only two of them state a remedy this PR changes, and both do it by showing real discovery output: - `protocol/kernel/http-protocol.mdx` printed `Install plugin-workflow to enable` and `Install plugin-ai to enable`. Wrong twice over now: neither package exists, and both slots have no implementation at all, so the message says so instead. Its `provider` for auth is scoped too, matching the field. - `kernel/services-checklist.mdx` carried the old template verbatim, down to its grammar bug: `"Install a auth plugin to enable"`. Deliberately not touched: `references/api/discovery.mdx` is auto-generated; `releases/v9.mdx` records what v9 said and should keep saying it; `services-checklist.mdx`'s Phase 1/2/3 table is a roadmap of plugins to build, not an install instruction; `plugins/anatomy.mdx` names `ui-plugin` as a kind of plugin, not a package to fetch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- content/docs/kernel/services-checklist.mdx | 2 +- content/docs/protocol/kernel/http-protocol.mdx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index b45f69d440..b00e238652 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -134,7 +134,7 @@ no message. Below is the former — the fallback case: }, "auth": { "enabled": false, "status": "unavailable", - "message": "Install a auth plugin to enable" + "message": "Install @objectstack/plugin-auth to enable" } } } diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index a0321cb9bb..7627861d59 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -53,9 +53,9 @@ GET /api/v1/discovery HTTP/1.1 "services": { "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" }, "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, - "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "plugin-auth" }, - "workflow": { "enabled": false, "status": "unavailable", "message": "Install plugin-workflow to enable" }, - "ai": { "enabled": false, "status": "unavailable", "message": "Install plugin-ai to enable" } + "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" } }, "locale": { "default": "en-US", From b17c5fbebfba6d6367d0e54f79b67386e33ac434 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:20:40 +0000 Subject: [PATCH 3/4] chore(spec): record the two new system exports in the public API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `TypeScript Type Check` job's last step is spec's API-surface guard, and it reported exactly what this PR intends: `0 breaking (removed/narrowed), 2 added` — `CORE_SERVICE_PROVIDER (const)` and `serviceUnavailableMessage (function)` under `./system`. Regenerated per its own instruction; the snapshot diff is those two lines and nothing else. Worth the ceremony: spec is the constitution, and this PR puts a table in it that both discovery builders now depend on. An addition nobody had to acknowledge is how a "shared" source of truth quietly becomes a second one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- packages/spec/api-surface.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a786dc84..9d7e91f448 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -690,6 +690,7 @@ "BucketConfig (type)", "BucketConfigSchema (const)", "CLOUD_PROVIDED_OBJECT_NAMES (const)", + "CORE_SERVICE_PROVIDER (const)", "CRDTMergeResult (type)", "CRDTMergeResultSchema (const)", "CRDTState (type)", @@ -1363,6 +1364,7 @@ "resolveViewDescription (function)", "resolveViewLabel (function)", "s3StorageExample (const)", + "serviceUnavailableMessage (function)", "toLocaleDescriptors (function)", "translateAction (function)", "translateApp (function)", From 3acfbb6c7c2d727106ff6dc4266ec218d6e18b22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:42:45 +0000 Subject: [PATCH 4/4] test(objectql): the discovery provider assertion follows the scoped package name (#4093 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protocol-discovery.test.ts` pinned `services.auth.provider` to the bare label `plugin-auth`. It now comes from CORE_SERVICE_PROVIDER, which names the package you can actually install — the FROM → TO the changeset already declares for this field. Scoped is the point, not incidental: `provider` is what a consumer (or an agent) reads to decide what to fetch, and `@objectstack/plugin-auth` is copy-pasteable into an install command where `plugin-auth` is a guess at best and, for the ten slots this PR corrected, wrong outright. Caught by Test Core, not by my filtered local runs — a `provider`/`message` assertion can live in any package that exercises a discovery builder, and this one is in objectql. Deliberately NOT touched: the `provider: 'plugin-search'` and `Install plugin-workflow to enable` strings in spec's discovery/dispatcher schema tests. Those are arbitrary payloads proving ServiceInfoSchema accepts a shape, not claims the platform makes — and "fixing" them means inventing a slot→package pairing for slots that have none, which is the very thing this PR removes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- packages/objectql/src/protocol-discovery.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 695eb81db0..d41f52d9da 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -41,7 +41,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.auth.enabled).toBe(true); expect(discovery.services.auth.status).toBe('available'); expect(discovery.services.auth.route).toBe('/api/v1/auth'); - expect(discovery.services.auth.provider).toBe('plugin-auth'); + // [#4093 follow-up] Scoped now: `provider` comes from CORE_SERVICE_PROVIDER, + // which names the package you can actually install rather than a bare label. + expect(discovery.services.auth.provider).toBe('@objectstack/plugin-auth'); expect(discovery.routes.auth).toBe('/api/v1/auth'); });