From d05044f0948f22b489ff0160133590debe7a0c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 07:44:13 +0000 Subject: [PATCH] fix(spec,metadata-protocol,runtime): stop advertising routes for the kernel-internal cache/queue/job slots (#4318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SERVICE_CONFIG` declared `/api/v1/cache`, `/api/v1/queue` and `/api/v1/jobs`. Those three lines were the only mention of those paths in the repository: no dispatcher domain under `packages/runtime/src/domains/`, no adapter mount, no plugin registration. Nor is one pending — the slots' shipped providers (`service-cache` / `-queue` / `-job`, per `CORE_SERVICE_PROVIDER`) are in-process contracts and mount no HTTP surface at all. The kernel pre-injects in-memory fallbacks into all three on every default boot, and those self-report `handlerReady: false`. So every default deployment emitted a `ServiceInfo` whose `route` said "call me here" next to its own `handlerReady: false` saying "there is no handler" — the D12 honesty defect of #4089/#4130 sharpened to a contradiction *inside a single record*. Route removed at the root rather than suppressed per-occupant: the slots are route-less now, structurally, the way `realtime` already was. What differs from `realtime` is the unmarked case, so each route-less entry states it: `realtime`'s advertised capability IS the missing HTTP/WS surface, so an in-process bus there is `degraded`; a cache/queue/job slot's contract is in-process to begin with, so a real (unmarked) implementation stays `available` — "no HTTP surface" is not reduced capability for it. The dispatcher builder had the same defect one field over: `svcAvailable` gave an unmarked occupant `handlerReady: true`, a handler that does not exist. It reports these slots through `svcInProcess` now — `handlerReady` is pinned `false`, which for a route-less slot is not a proxy for anything, it is the fact itself (the reason `file-storage` stays in `DISPATCHER_GATED_SERVICES`). The explanatory message is written once, as `inProcessServiceMessage()` in `@objectstack/spec/system`, so the two builders cannot drift the way they did for `metadata` and `data`. Tests pin both builders and, for cache/queue/job, pin them against *each other* across both occupant shapes (real/unmarked and the kernel fallback). The two service READMEs advertised REST endpoint tables for surfaces that were never mounted — replaced with what is actually true. --- .../discovery-cache-queue-job-no-route.md | 25 +++++++++ packages/metadata-protocol/src/protocol.ts | 51 ++++++++++++----- .../objectql/src/protocol-discovery.test.ts | 53 ++++++++++++++++-- packages/runtime/src/http-dispatcher.test.ts | 55 ++++++++++++++++++- packages/runtime/src/http-dispatcher.ts | 25 +++++++-- packages/services/service-cache/README.md | 12 ++-- packages/services/service-job/README.md | 15 ++--- packages/spec/api-surface.json | 1 + packages/spec/src/system/core-services.zod.ts | 21 +++++++ 9 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 .changeset/discovery-cache-queue-job-no-route.md diff --git a/.changeset/discovery-cache-queue-job-no-route.md b/.changeset/discovery-cache-queue-job-no-route.md new file mode 100644 index 0000000000..70990a5b4e --- /dev/null +++ b/.changeset/discovery-cache-queue-job-no-route.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +"@objectstack/runtime": patch +--- + +fix(spec,metadata-protocol,runtime): discovery stops advertising routes for the kernel-internal cache/queue/job slots (#4318) + +The metadata-protocol discovery builder declared `/api/v1/cache`, `/api/v1/queue` +and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no +dispatcher domain, no adapter mount, no plugin registration, and the shipped +providers (`service-cache`/`-queue`/`-job`) are in-process contracts that will +never mount one. Every default boot therefore advertised a route inside the same +`ServiceInfo` whose `handlerReady: false` said the opposite — a single record +contradicting itself (ADR-0076 D12). + +These slots are route-less now, like `realtime` — but unlike `realtime` an +unmarked real implementation stays `available`: the slot's contract is +in-process, so "no HTTP surface" is not reduced capability for it. `handlerReady` +is reported `false` on both discovery builders — for a route-less slot it is not +a proxy for anything, it is the fact itself (the dispatcher used to claim +`handlerReady: true` here for an unmarked occupant, a handler that does not +exist). The explanatory message is written once, as +`inProcessServiceMessage(slot)` in `@objectstack/spec/system`, so the two +builders cannot drift apart. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cddb8e7cc1..2531f2237b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -29,7 +29,7 @@ import { import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; -import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from '@objectstack/spec/system'; +import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed } from '@objectstack/spec/kernel'; import { extractProtection, @@ -1194,9 +1194,16 @@ function suggestFieldName(name: string, knownFields: readonly string[]): string * Service Configuration for Discovery * Maps service names to their routes and plugin providers. * - * `route: undefined` means the service has NO HTTP surface — discovery must + * A missing `route` means the service has NO HTTP surface — discovery must * not advertise a route for it (ADR-0076 D12, #2462: an advertised route - * with no mounted handler 404s and misleads consumers). + * with no mounted handler 404s and misleads consumers). Such entries carry + * `noHttpSurface` instead, stating how to report an occupant that does not + * self-describe: `realtime`'s advertised capability IS the missing HTTP/WS + * surface, so an in-process bus is `degraded`; `cache`/`queue`/`job` are + * kernel-internal contracts fully served in-process (#4318), so an unmarked + * real implementation stays `available`. Either way `handlerReady` is + * reported `false` — for a route-less slot it is not a proxy for anything, + * it is the fact itself. */ /** * [#4093 follow-up] `plugin` is no longer written here. It named the package a @@ -1212,7 +1219,11 @@ function suggestFieldName(name: string, knownFields: readonly string[]): string * 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 = { +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 @@ -1220,14 +1231,24 @@ const SERVICE_CONFIG: Record = { 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' }, + // Kernel-internal slots (#4318): their providers (service-cache/-queue/ + // -job) mount no HTTP routes — these are in-process contracts, not HTTP + // capabilities, so there is no route to advertise and never will be. The + // /api/v1/cache|queue|jobs paths this table used to declare existed + // nowhere else in the repository; every default boot advertised them next + // to the fallbacks' own `handlerReady: false` — a single ServiceInfo + // contradicting itself. + cache: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('cache') } }, + queue: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('queue') } }, + job: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('job') } }, 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: {}, + // /api/v1/realtime, so no route is advertised (D12, #2462). Unlike the + // kernel-internal slots above, the capability this slot advertises is + // realtime push to clients — without a surface that IS reduced, so an + // unmarked bus reports degraded. Message matches the dispatcher builder. + realtime: { noHttpSurface: { statusWhenUnmarked: 'degraded', message: 'In-process event bus only — no HTTP/WS realtime surface is mounted' } }, notification: { route: '/api/v1/notifications' }, ai: { route: '/api/v1/ai' }, i18n: { route: '/api/v1/i18n' }, @@ -2077,12 +2098,14 @@ export class ObjectStackProtocolImplementation implements // Registered — but honor a stub/dev/fallback self-description // instead of blindly reporting 'available' (ADR-0076 D12). const self = readServiceSelfInfo(registeredServices.get(serviceName)); - // No HTTP surface at all (e.g. realtime): the handler can never - // be ready and 'available' would overstate it — report degraded. + // No HTTP surface at all: the handler can never be ready, and + // the entry's own `noHttpSurface` declaration says whether that + // also degrades the slot (realtime) or not (cache/queue/job — + // in-process contracts, #4318). const noHttpSurface = !config.route; services[serviceName] = { enabled: true, - status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)), + status: self?.status ?? (config.noHttpSurface?.statusWhenUnmarked ?? ('available' as const)), route: advertisedRoute(serviceName, config.route), provider: CORE_SERVICE_PROVIDER[serviceName] ?? undefined, ...(noHttpSurface || self?.handlerReady !== undefined @@ -2090,8 +2113,8 @@ export class ObjectStackProtocolImplementation implements : {}), ...(self?.message ? { message: self.message } - : noHttpSurface - ? { message: 'In-process service only — no HTTP surface is mounted' } + : config.noHttpSurface + ? { message: config.noHttpSurface.message } : {}), }; } else { diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 1b98d0835b..2240ba3421 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -333,10 +333,12 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => }); // Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST layer), - // `workflow`, `graphql` and the queue/job/cache families have their routes - // mounted by the plugin that registers the service, so `handlerReady` there - // says nothing about whether THAT route is mounted and the advertisement - // stays presence-gated. Suppressing it would be a guess, not honesty. + // `workflow` and `graphql` have their routes mounted by the plugin that + // registers the service, so `handlerReady` there says nothing about whether + // THAT route is mounted and the advertisement stays presence-gated. + // Suppressing it would be a guess, not honesty. (cache/queue/job used to be + // named here on the same theory, but nothing mounts routes for them at all — + // they are route-less kernel-internal slots since #4318.) it('should leave non-dispatcher-owned routes presence-gated', async () => { const mockServices = new Map(); mockServices.set('search', { __serviceInfo: { status: 'stub', message: 'dev fake' } }); @@ -348,6 +350,49 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.search.route).toBe('/api/v1/search'); }); + // ── Kernel-internal slots advertise no route, ever (#4318) ──────────────── + // SERVICE_CONFIG used to declare /api/v1/cache, /api/v1/queue and + // /api/v1/jobs — three paths that existed nowhere else in the repository: + // no dispatcher domain, no adapter mount, no plugin registration, and the + // shipped providers (service-cache/-queue/-job) are in-process contracts + // that will never mount one. Every default boot therefore advertised a + // route inside the same ServiceInfo whose `handlerReady: false` said the + // opposite. The slots are route-less now, like realtime — but unlike + // realtime an unmarked real implementation stays `available`, because the + // slot's contract is in-process and "no HTTP surface" is not reduced + // capability for it. + it('reports an unmarked cache/queue/job occupant available with no route and handlerReady false (#4318)', async () => { + const mockServices = new Map(); + for (const slot of ['cache', 'queue', 'job']) mockServices.set(slot, { /* real, unmarked */ }); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + for (const slot of ['cache', 'queue', 'job']) { + const reported = discovery.services[slot]; + expect(reported.enabled, `${slot}.enabled`).toBe(true); + expect(reported.status, `${slot}.status`).toBe('available'); + expect(reported.handlerReady, `${slot}.handlerReady`).toBe(false); + expect(reported.route, `${slot}.route`).toBeUndefined(); + expect(reported.message, `${slot}.message`).toContain('no HTTP surface'); + } + }); + + it('never advertises a route for a cache/queue/job fallback either (#4318)', async () => { + for (const slot of ['cache', 'queue', 'job']) { + const mockServices = new Map(); + mockServices.set(slot, CORE_FALLBACK_FACTORIES[slot]()); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const reported = (await protocol.getDiscovery()).services[slot]; + + // Self-description wins for status/message (the class-wide #3898 gate + // pins `degraded`); the route stays gone and handlerReady stays false. + expect(reported.route, `${slot}.route`).toBeUndefined(); + expect(reported.handlerReady, `${slot}.handlerReady`).toBe(false); + } + }); + it('should map file-storage service to storage route', async () => { const mockServices = new Map(); mockServices.set('file-storage', {}); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 1b80da7f7f..f3ac0d8d5a 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2668,8 +2668,9 @@ describe('HttpDispatcher', () => { // `available`. Table-driven so the next fallback added to the table is // gated the day it lands; this class of hole recurs with every new // fallback. cache/queue/job had no per-slot pin before this — dropping - // their `svcAvailable(…, svc)` third argument, the exact #4130 - // regression shape, was test-invisible. + // their occupant argument (`svcAvailable(…, svc)` then, + // `svcInProcess(slot, svc)` since #4318), the exact #4130 regression + // shape, was test-invisible. it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => { const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core'); @@ -2688,6 +2689,56 @@ describe('HttpDispatcher', () => { expect(reported.message, `services.${slot}.message`).toBeTruthy(); } }); + + // ── Kernel-internal slots (#4318): no route, handlerReady is the fact ── + // + // service-cache/-queue/-job mount no HTTP routes — the slots are + // in-process contracts, so no route is ever advertised for them and + // `handlerReady` is `false` as a fact, not a proxy. `svcAvailable` + // used to claim `handlerReady: true` for an unmarked occupant here — a + // handler that does not exist. The status stays `available` for an + // unmarked real implementation: "no HTTP surface" is not reduced + // capability for an in-process contract (contrast realtime). + it('reports unmarked cache/queue/job occupants available with no route and handlerReady false (#4318)', async () => { + for (const slot of ['cache', 'queue', 'job']) { + const svc = { /* real, unmarked */ }; + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null)); + (kernel as any).services = new Map([[slot, svc]]); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + const reported = (info.services as Record)[slot]; + expect(reported.enabled, `services.${slot}.enabled`).toBe(true); + expect(reported.status, `services.${slot}.status`).toBe('available'); + expect(reported.handlerReady, `services.${slot}.handlerReady`).toBe(false); + expect(reported.route, `services.${slot}.route`).toBeUndefined(); + expect(reported.message, `services.${slot}.message`).toContain('no HTTP surface'); + } + }); + + it('answers the cache/queue/job slots identically to the metadata-protocol builder (#4318)', async () => { + const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol'); + const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core'); + + for (const slot of ['cache', 'queue', 'job']) { + // Both shapes an occupant can take: a real (unmarked) service + // and the kernel's self-describing in-memory fallback. + for (const svc of [{}, CORE_FALLBACK_FACTORIES[slot]()]) { + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null)); + (kernel as any).services = new Map([[slot, svc]]); + + const fromDispatcher = ((await dispatcher.getDiscoveryInfo('/api/v1')).services as Record)[slot]; + const fromProtocol = (await new ObjectStackProtocolImplementation( + mockObjectQL as any, + () => new Map([[slot, svc]]), + ).getDiscovery()).services[slot]; + + expect(fromDispatcher.status, `${slot}.status`).toBe(fromProtocol.status); + expect(fromDispatcher.handlerReady, `${slot}.handlerReady`).toBe(fromProtocol.handlerReady); + expect(fromDispatcher.message, `${slot}.message`).toBe(fromProtocol.message); + expect(fromDispatcher.route, `${slot}.route`).toBe(fromProtocol.route); + } + } + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 58312de3a7..88a90aefb9 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, serviceUnavailableMessage } from '@objectstack/spec/system'; +import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; @@ -1037,6 +1037,23 @@ export class HttpDispatcher { enabled: false, status: 'unavailable' as const, handlerReady: false, message: serviceUnavailableMessage(name), }); + // [#4318] Kernel-internal slots (cache/queue/job): their providers + // mount no HTTP routes, so no route is advertised and `handlerReady` + // is `false` as a fact, not a proxy — `svcAvailable` would claim a + // handler that does not exist. An unmarked occupant stays `available`: + // the slot's contract is in-process, so "no HTTP surface" is not + // reduced capability (contrast `realtime` below, whose advertised + // capability IS the missing surface). Message written once in + // `@objectstack/spec/system` so both discovery builders agree. + const svcInProcess = (name: string, svc: unknown) => { + const self = svc ? readServiceSelfInfo(svc) : undefined; + return { + enabled: true, + status: self?.status ?? ('available' as const), + handlerReady: false, + message: self?.message ?? inProcessServiceMessage(name), + }; + }; // Self-description of the registered realtime service, if any (D12). const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : undefined; @@ -1148,9 +1165,9 @@ export class HttpDispatcher { // "install a plugin" would say strictly less. automation: automationRegistered ? svcAvailable(routes.automation, undefined, automationSvc) : svcUnavailable('automation'), analytics: analyticsRegistered ? svcAvailable(routes.analytics, undefined, analyticsSvc) : svcUnavailable('analytics'), - cache: hasCache ? svcAvailable(undefined, undefined, cacheSvc) : svcUnavailable('cache'), - queue: hasQueue ? svcAvailable(undefined, undefined, queueSvc) : svcUnavailable('queue'), - job: hasJob ? svcAvailable(undefined, undefined, jobSvc) : svcUnavailable('job'), + cache: hasCache ? svcInProcess('cache', cacheSvc) : svcUnavailable('cache'), + queue: hasQueue ? svcInProcess('queue', queueSvc) : svcUnavailable('queue'), + job: hasJob ? svcInProcess('job', jobSvc) : svcUnavailable('job'), // [#4093] Reported from what serves it, like the route above: // `/ui` is a dispatcher domain answered by the `protocol` // service, so its self-description (none today — MetadataPlugin diff --git a/packages/services/service-cache/README.md b/packages/services/service-cache/README.md index 48cb38d6f0..f6d87b5f04 100644 --- a/packages/services/service-cache/README.md +++ b/packages/services/service-cache/README.md @@ -240,14 +240,12 @@ const stats = await cache.stats(); await cache.resetStats(); ``` -## REST API Endpoints +## No HTTP Surface -``` -GET /api/v1/cache/stats # Get cache statistics -POST /api/v1/cache/clear # Clear cache -DELETE /api/v1/cache/:key # Delete specific key -DELETE /api/v1/cache/pattern/:pattern # Delete by pattern -``` +This service is kernel-internal: it is consumed in-process via the service +registry (`kernel.getService('cache')`) and mounts **no** REST routes. +Discovery advertises no route for the `cache` slot and reports +`handlerReady: false` (ADR-0076 D12, #4318). ## Best Practices diff --git a/packages/services/service-job/README.md b/packages/services/service-job/README.md index 6fbbd796d5..a86b2cb633 100644 --- a/packages/services/service-job/README.md +++ b/packages/services/service-job/README.md @@ -312,17 +312,12 @@ jobs.scheduleInterval({ }); ``` -## REST API Endpoints +## No HTTP Surface -``` -GET /api/v1/jobs # List all jobs -GET /api/v1/jobs/:name # Get job details -POST /api/v1/jobs/:name/run # Run job immediately -POST /api/v1/jobs/:name/stop # Stop job -POST /api/v1/jobs/:name/resume # Resume job -DELETE /api/v1/jobs/:name # Delete job -GET /api/v1/jobs/:name/history # Get execution history -``` +This service is kernel-internal: it is consumed in-process via the service +registry (`kernel.getService('job')`) and mounts **no** REST routes. Discovery +advertises no route for the `job` slot and reports `handlerReady: false` +(ADR-0076 D12, #4318). ## Best Practices diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c24fc55e53..52b79dbfe3 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1352,6 +1352,7 @@ "emailTemplateForm (const)", "gcsStorageExample (const)", "hasPlatformObjectPrefix (function)", + "inProcessServiceMessage (function)", "interpolateValidationMessage (function)", "isDataMigrationFlagVerified (function)", "isPlatformProvidedObjectName (function)", diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index 4a0e2e8e91..ae7004c160 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -148,6 +148,27 @@ export function serviceUnavailableMessage(slot: string): string { : `No implementation ships for the '${slot}' slot — register a service under it to enable`; } +/** + * The message discovery reports for an occupied slot that is kernel-internal + * by construction — `cache`, `queue`, `job` (#4318). Their shipped providers + * (see {@link CORE_SERVICE_PROVIDER}) mount no HTTP routes: the slots are + * consumed in-process via the service registry, so no route is ever advertised + * for them (ADR-0076 D12) and `handlerReady` is reported `false` — for these + * slots it is not a proxy for anything, it is the fact itself. + * + * An unmarked occupant still reports `available`: the slot's contract is + * in-process, so "no HTTP surface" is not reduced capability. Contrast + * `realtime`, whose advertised capability IS the missing HTTP/WS surface — + * there an in-process bus reports `degraded`. + * + * Written once here so the two discovery builders (`HttpDispatcher` and the + * metadata-protocol implementation) cannot drift apart — the same reason + * {@link serviceUnavailableMessage} lives here (#4089, #4130). + */ +export function inProcessServiceMessage(slot: string): string { + return `Kernel-internal service — consumed in-process via the service registry; no HTTP surface exists for the '${slot}' slot`; +} + /** * Service Criticality Level * Defines the startup behavior when a service is missing.