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
21 changes: 21 additions & 0 deletions .changeset/discovery-remedy-names-a-real-package.md
Original file line number Diff line number Diff line change
@@ -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.<slot>.message` and `services.<slot>.provider` change text for most unavailable slots. Anything matching on the old `Install a <slot> plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents.
10 changes: 10 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 33 additions & 19 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, { route?: string; plugin: string }> = {
/**
* [#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<string, { route?: string }> = {
// 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' },
};

/**
Expand Down Expand Up @@ -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 }
: {}),
Expand All @@ -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),
};
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
55 changes: 55 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>)) {
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<string, any>(),
).getDiscovery()).services;
const fromDispatcher = (await dispatcher.getDiscoveryInfo('/api/v1')).services as Record<string, any>;

// 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']),
Expand Down
13 changes: 10 additions & 3 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@
"BucketConfig (type)",
"BucketConfigSchema (const)",
"CLOUD_PROVIDED_OBJECT_NAMES (const)",
"CORE_SERVICE_PROVIDER (const)",
"CRDTMergeResult (type)",
"CRDTMergeResultSchema (const)",
"CRDTState (type)",
Expand Down Expand Up @@ -1363,6 +1364,7 @@
"resolveViewDescription (function)",
"resolveViewLabel (function)",
"s3StorageExample (const)",
"serviceUnavailableMessage (function)",
"toLocaleDescriptors (function)",
"translateAction (function)",
"translateApp (function)",
Expand Down
Loading
Loading