Skip to content

Commit 20bc357

Browse files
authored
fix(spec,metadata-protocol,runtime): stop advertising routes for the kernel-internal cache/queue/job slots (#4318) (#4448)
`SERVICE_CONFIG` 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. Nor was one pending: the slots' shipped providers (`service-cache` / `-queue` / `-job`) are in-process contracts that mount no HTTP surface. The kernel pre-injects self-describing in-memory fallbacks into all three on every default boot, 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 route is removed at the root rather than suppressed per-occupant: these 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`. The dispatcher builder had the same defect one field over: `svcAvailable` gave an unmarked occupant `handlerReady: true`, a handler that does not exist. Those slots report through `svcInProcess` now, with `handlerReady` pinned `false`. The explanatory message is written once, as `inProcessServiceMessage()` in `@objectstack/spec/system`, so the two builders cannot drift. Tests pin both builders and, for cache/queue/job, pin them against each other across both occupant shapes. The two service READMEs advertised REST endpoint tables for surfaces that were never mounted; replaced with what is true. Closes #4318.
1 parent 5293114 commit 20bc357

9 files changed

Lines changed: 217 additions & 41 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/metadata-protocol": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
fix(spec,metadata-protocol,runtime): discovery stops advertising routes for the kernel-internal cache/queue/job slots (#4318)
8+
9+
The metadata-protocol discovery builder declared `/api/v1/cache`, `/api/v1/queue`
10+
and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no
11+
dispatcher domain, no adapter mount, no plugin registration, and the shipped
12+
providers (`service-cache`/`-queue`/`-job`) are in-process contracts that will
13+
never mount one. Every default boot therefore advertised a route inside the same
14+
`ServiceInfo` whose `handlerReady: false` said the opposite — a single record
15+
contradicting itself (ADR-0076 D12).
16+
17+
These slots are route-less now, like `realtime` — but unlike `realtime` an
18+
unmarked real implementation stays `available`: the slot's contract is
19+
in-process, so "no HTTP surface" is not reduced capability for it. `handlerReady`
20+
is reported `false` on both discovery builders — for a route-less slot it is not
21+
a proxy for anything, it is the fact itself (the dispatcher used to claim
22+
`handlerReady: true` here for an unmarked occupant, a handler that does not
23+
exist). The explanatory message is written once, as
24+
`inProcessServiceMessage(slot)` in `@objectstack/spec/system`, so the two
25+
builders cannot drift apart.

packages/metadata-protocol/src/protocol.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
3030
import { applyConversionsToStoredItem } from '@objectstack/spec';
3131
import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui';
32-
import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from '@objectstack/spec/system';
32+
import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
3333
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed } from '@objectstack/spec/kernel';
3434
import {
3535
extractProtection,
@@ -1194,9 +1194,16 @@ function suggestFieldName(name: string, knownFields: readonly string[]): string
11941194
* Service Configuration for Discovery
11951195
* Maps service names to their routes and plugin providers.
11961196
*
1197-
* `route: undefined` means the service has NO HTTP surface — discovery must
1197+
* A missing `route` means the service has NO HTTP surface — discovery must
11981198
* not advertise a route for it (ADR-0076 D12, #2462: an advertised route
1199-
* with no mounted handler 404s and misleads consumers).
1199+
* with no mounted handler 404s and misleads consumers). Such entries carry
1200+
* `noHttpSurface` instead, stating how to report an occupant that does not
1201+
* self-describe: `realtime`'s advertised capability IS the missing HTTP/WS
1202+
* surface, so an in-process bus is `degraded`; `cache`/`queue`/`job` are
1203+
* kernel-internal contracts fully served in-process (#4318), so an unmarked
1204+
* real implementation stays `available`. Either way `handlerReady` is
1205+
* reported `false` — for a route-less slot it is not a proxy for anything,
1206+
* it is the fact itself.
12001207
*/
12011208
/**
12021209
* [#4093 follow-up] `plugin` is no longer written here. It named the package a
@@ -1212,22 +1219,36 @@ function suggestFieldName(name: string, knownFields: readonly string[]): string
12121219
* registers each slot and guarded by `scripts/check-service-providers.mjs`.
12131220
* Only the ROUTE stays local — that is this builder's own knowledge.
12141221
*/
1215-
const SERVICE_CONFIG: Record<string, { route?: string }> = {
1222+
const SERVICE_CONFIG: Record<string, {
1223+
route?: string;
1224+
/** Route-less slots only: status + message for an occupant with no self-description. */
1225+
noHttpSurface?: { statusWhenUnmarked: 'available' | 'degraded'; message: string };
1226+
}> = {
12161227
// Plugin-provided like every other optional service since the degraded
12171228
// ObjectQL fallback was retired (#3891): advertised iff the real engine
12181229
// is registered — never hardcoded 'available' (the pre-#2462 lie the
12191230
// fallback existed to paper over).
12201231
analytics: { route: '/api/v1/analytics' },
12211232
auth: { route: '/api/v1/auth' },
12221233
automation: { route: '/api/v1/automation' },
1223-
cache: { route: '/api/v1/cache' },
1224-
queue: { route: '/api/v1/queue' },
1225-
job: { route: '/api/v1/jobs' },
1234+
// Kernel-internal slots (#4318): their providers (service-cache/-queue/
1235+
// -job) mount no HTTP routes — these are in-process contracts, not HTTP
1236+
// capabilities, so there is no route to advertise and never will be. The
1237+
// /api/v1/cache|queue|jobs paths this table used to declare existed
1238+
// nowhere else in the repository; every default boot advertised them next
1239+
// to the fallbacks' own `handlerReady: false` — a single ServiceInfo
1240+
// contradicting itself.
1241+
cache: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('cache') } },
1242+
queue: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('queue') } },
1243+
job: { noHttpSurface: { statusWhenUnmarked: 'available', message: inProcessServiceMessage('job') } },
12261244
ui: { route: '/api/v1/ui' },
12271245
workflow: { route: '/api/v1/workflow' },
12281246
// service-realtime is an in-process pub/sub bus; nothing mounts
1229-
// /api/v1/realtime, so no route is advertised (D12, #2462).
1230-
realtime: {},
1247+
// /api/v1/realtime, so no route is advertised (D12, #2462). Unlike the
1248+
// kernel-internal slots above, the capability this slot advertises is
1249+
// realtime push to clients — without a surface that IS reduced, so an
1250+
// unmarked bus reports degraded. Message matches the dispatcher builder.
1251+
realtime: { noHttpSurface: { statusWhenUnmarked: 'degraded', message: 'In-process event bus only — no HTTP/WS realtime surface is mounted' } },
12311252
notification: { route: '/api/v1/notifications' },
12321253
ai: { route: '/api/v1/ai' },
12331254
i18n: { route: '/api/v1/i18n' },
@@ -2077,21 +2098,23 @@ export class ObjectStackProtocolImplementation implements
20772098
// Registered — but honor a stub/dev/fallback self-description
20782099
// instead of blindly reporting 'available' (ADR-0076 D12).
20792100
const self = readServiceSelfInfo(registeredServices.get(serviceName));
2080-
// No HTTP surface at all (e.g. realtime): the handler can never
2081-
// be ready and 'available' would overstate it — report degraded.
2101+
// No HTTP surface at all: the handler can never be ready, and
2102+
// the entry's own `noHttpSurface` declaration says whether that
2103+
// also degrades the slot (realtime) or not (cache/queue/job —
2104+
// in-process contracts, #4318).
20822105
const noHttpSurface = !config.route;
20832106
services[serviceName] = {
20842107
enabled: true,
2085-
status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)),
2108+
status: self?.status ?? (config.noHttpSurface?.statusWhenUnmarked ?? ('available' as const)),
20862109
route: advertisedRoute(serviceName, config.route),
20872110
provider: CORE_SERVICE_PROVIDER[serviceName] ?? undefined,
20882111
...(noHttpSurface || self?.handlerReady !== undefined
20892112
? { handlerReady: noHttpSurface ? false : self?.handlerReady }
20902113
: {}),
20912114
...(self?.message
20922115
? { message: self.message }
2093-
: noHttpSurface
2094-
? { message: 'In-process service only — no HTTP surface is mounted' }
2116+
: config.noHttpSurface
2117+
? { message: config.noHttpSurface.message }
20952118
: {}),
20962119
};
20972120
} else {

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,12 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
333333
});
334334

335335
// Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST layer),
336-
// `workflow`, `graphql` and the queue/job/cache families have their routes
337-
// mounted by the plugin that registers the service, so `handlerReady` there
338-
// says nothing about whether THAT route is mounted and the advertisement
339-
// stays presence-gated. Suppressing it would be a guess, not honesty.
336+
// `workflow` and `graphql` have their routes mounted by the plugin that
337+
// registers the service, so `handlerReady` there says nothing about whether
338+
// THAT route is mounted and the advertisement stays presence-gated.
339+
// Suppressing it would be a guess, not honesty. (cache/queue/job used to be
340+
// named here on the same theory, but nothing mounts routes for them at all —
341+
// they are route-less kernel-internal slots since #4318.)
340342
it('should leave non-dispatcher-owned routes presence-gated', async () => {
341343
const mockServices = new Map<string, any>();
342344
mockServices.set('search', { __serviceInfo: { status: 'stub', message: 'dev fake' } });
@@ -348,6 +350,49 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
348350
expect(discovery.services.search.route).toBe('/api/v1/search');
349351
});
350352

353+
// ── Kernel-internal slots advertise no route, ever (#4318) ────────────────
354+
// SERVICE_CONFIG used to declare /api/v1/cache, /api/v1/queue and
355+
// /api/v1/jobs — three paths that existed nowhere else in the repository:
356+
// no dispatcher domain, no adapter mount, no plugin registration, and the
357+
// shipped providers (service-cache/-queue/-job) are in-process contracts
358+
// that will never mount one. Every default boot therefore advertised a
359+
// route inside the same ServiceInfo whose `handlerReady: false` said the
360+
// opposite. The slots are route-less now, like realtime — but unlike
361+
// realtime an unmarked real implementation stays `available`, because the
362+
// slot's contract is in-process and "no HTTP surface" is not reduced
363+
// capability for it.
364+
it('reports an unmarked cache/queue/job occupant available with no route and handlerReady false (#4318)', async () => {
365+
const mockServices = new Map<string, any>();
366+
for (const slot of ['cache', 'queue', 'job']) mockServices.set(slot, { /* real, unmarked */ });
367+
368+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
369+
const discovery = await protocol.getDiscovery();
370+
371+
for (const slot of ['cache', 'queue', 'job']) {
372+
const reported = discovery.services[slot];
373+
expect(reported.enabled, `${slot}.enabled`).toBe(true);
374+
expect(reported.status, `${slot}.status`).toBe('available');
375+
expect(reported.handlerReady, `${slot}.handlerReady`).toBe(false);
376+
expect(reported.route, `${slot}.route`).toBeUndefined();
377+
expect(reported.message, `${slot}.message`).toContain('no HTTP surface');
378+
}
379+
});
380+
381+
it('never advertises a route for a cache/queue/job fallback either (#4318)', async () => {
382+
for (const slot of ['cache', 'queue', 'job']) {
383+
const mockServices = new Map<string, any>();
384+
mockServices.set(slot, CORE_FALLBACK_FACTORIES[slot]());
385+
386+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
387+
const reported = (await protocol.getDiscovery()).services[slot];
388+
389+
// Self-description wins for status/message (the class-wide #3898 gate
390+
// pins `degraded`); the route stays gone and handlerReady stays false.
391+
expect(reported.route, `${slot}.route`).toBeUndefined();
392+
expect(reported.handlerReady, `${slot}.handlerReady`).toBe(false);
393+
}
394+
});
395+
351396
it('should map file-storage service to storage route', async () => {
352397
const mockServices = new Map<string, any>();
353398
mockServices.set('file-storage', {});

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2668,8 +2668,9 @@ describe('HttpDispatcher', () => {
26682668
// `available`. Table-driven so the next fallback added to the table is
26692669
// gated the day it lands; this class of hole recurs with every new
26702670
// fallback. cache/queue/job had no per-slot pin before this — dropping
2671-
// their `svcAvailable(…, svc)` third argument, the exact #4130
2672-
// regression shape, was test-invisible.
2671+
// their occupant argument (`svcAvailable(…, svc)` then,
2672+
// `svcInProcess(slot, svc)` since #4318), the exact #4130 regression
2673+
// shape, was test-invisible.
26732674

26742675
it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => {
26752676
const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core');
@@ -2688,6 +2689,56 @@ describe('HttpDispatcher', () => {
26882689
expect(reported.message, `services.${slot}.message`).toBeTruthy();
26892690
}
26902691
});
2692+
2693+
// ── Kernel-internal slots (#4318): no route, handlerReady is the fact ──
2694+
//
2695+
// service-cache/-queue/-job mount no HTTP routes — the slots are
2696+
// in-process contracts, so no route is ever advertised for them and
2697+
// `handlerReady` is `false` as a fact, not a proxy. `svcAvailable`
2698+
// used to claim `handlerReady: true` for an unmarked occupant here — a
2699+
// handler that does not exist. The status stays `available` for an
2700+
// unmarked real implementation: "no HTTP surface" is not reduced
2701+
// capability for an in-process contract (contrast realtime).
2702+
it('reports unmarked cache/queue/job occupants available with no route and handlerReady false (#4318)', async () => {
2703+
for (const slot of ['cache', 'queue', 'job']) {
2704+
const svc = { /* real, unmarked */ };
2705+
(kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null));
2706+
(kernel as any).services = new Map([[slot, svc]]);
2707+
2708+
const info = await dispatcher.getDiscoveryInfo('/api/v1');
2709+
const reported = (info.services as Record<string, any>)[slot];
2710+
expect(reported.enabled, `services.${slot}.enabled`).toBe(true);
2711+
expect(reported.status, `services.${slot}.status`).toBe('available');
2712+
expect(reported.handlerReady, `services.${slot}.handlerReady`).toBe(false);
2713+
expect(reported.route, `services.${slot}.route`).toBeUndefined();
2714+
expect(reported.message, `services.${slot}.message`).toContain('no HTTP surface');
2715+
}
2716+
});
2717+
2718+
it('answers the cache/queue/job slots identically to the metadata-protocol builder (#4318)', async () => {
2719+
const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol');
2720+
const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core');
2721+
2722+
for (const slot of ['cache', 'queue', 'job']) {
2723+
// Both shapes an occupant can take: a real (unmarked) service
2724+
// and the kernel's self-describing in-memory fallback.
2725+
for (const svc of [{}, CORE_FALLBACK_FACTORIES[slot]()]) {
2726+
(kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null));
2727+
(kernel as any).services = new Map([[slot, svc]]);
2728+
2729+
const fromDispatcher = ((await dispatcher.getDiscoveryInfo('/api/v1')).services as Record<string, any>)[slot];
2730+
const fromProtocol = (await new ObjectStackProtocolImplementation(
2731+
mockObjectQL as any,
2732+
() => new Map<string, any>([[slot, svc]]),
2733+
).getDiscovery()).services[slot];
2734+
2735+
expect(fromDispatcher.status, `${slot}.status`).toBe(fromProtocol.status);
2736+
expect(fromDispatcher.handlerReady, `${slot}.handlerReady`).toBe(fromProtocol.handlerReady);
2737+
expect(fromDispatcher.message, `${slot}.message`).toBe(fromProtocol.message);
2738+
expect(fromDispatcher.route, `${slot}.route`).toBe(fromProtocol.route);
2739+
}
2740+
}
2741+
});
26912742
});
26922743

26932744
// ═══════════════════════════════════════════════════════════════

packages/runtime/src/http-dispatcher.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from '@objectstack/core';
66
import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
77
import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
8-
import { CoreServiceName, serviceUnavailableMessage } from '@objectstack/spec/system';
8+
import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
99
import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
1010
import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api';
1111
import { apiErrorResponse } from './error-envelope.js';
@@ -1037,6 +1037,23 @@ export class HttpDispatcher {
10371037
enabled: false, status: 'unavailable' as const, handlerReady: false,
10381038
message: serviceUnavailableMessage(name),
10391039
});
1040+
// [#4318] Kernel-internal slots (cache/queue/job): their providers
1041+
// mount no HTTP routes, so no route is advertised and `handlerReady`
1042+
// is `false` as a fact, not a proxy — `svcAvailable` would claim a
1043+
// handler that does not exist. An unmarked occupant stays `available`:
1044+
// the slot's contract is in-process, so "no HTTP surface" is not
1045+
// reduced capability (contrast `realtime` below, whose advertised
1046+
// capability IS the missing surface). Message written once in
1047+
// `@objectstack/spec/system` so both discovery builders agree.
1048+
const svcInProcess = (name: string, svc: unknown) => {
1049+
const self = svc ? readServiceSelfInfo(svc) : undefined;
1050+
return {
1051+
enabled: true,
1052+
status: self?.status ?? ('available' as const),
1053+
handlerReady: false,
1054+
message: self?.message ?? inProcessServiceMessage(name),
1055+
};
1056+
};
10401057

10411058
// Self-description of the registered realtime service, if any (D12).
10421059
const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : undefined;
@@ -1148,9 +1165,9 @@ export class HttpDispatcher {
11481165
// "install a plugin" would say strictly less.
11491166
automation: automationRegistered ? svcAvailable(routes.automation, undefined, automationSvc) : svcUnavailable('automation'),
11501167
analytics: analyticsRegistered ? svcAvailable(routes.analytics, undefined, analyticsSvc) : svcUnavailable('analytics'),
1151-
cache: hasCache ? svcAvailable(undefined, undefined, cacheSvc) : svcUnavailable('cache'),
1152-
queue: hasQueue ? svcAvailable(undefined, undefined, queueSvc) : svcUnavailable('queue'),
1153-
job: hasJob ? svcAvailable(undefined, undefined, jobSvc) : svcUnavailable('job'),
1168+
cache: hasCache ? svcInProcess('cache', cacheSvc) : svcUnavailable('cache'),
1169+
queue: hasQueue ? svcInProcess('queue', queueSvc) : svcUnavailable('queue'),
1170+
job: hasJob ? svcInProcess('job', jobSvc) : svcUnavailable('job'),
11541171
// [#4093] Reported from what serves it, like the route above:
11551172
// `/ui` is a dispatcher domain answered by the `protocol`
11561173
// service, so its self-description (none today — MetadataPlugin

0 commit comments

Comments
 (0)