Skip to content

Commit bb192c4

Browse files
os-zhuangclaude
andauthored
fix(runtime,metadata-protocol): every dispatcher domain gates on handlerReady, not slot occupancy (#4058 step 2) (#4086)
#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"). Every other service domain still gated on "is the slot occupied", so an occupant that self-declares `handlerReady: false` was called like a real implementation and its fabricated answer went out as a 200. #4082 (step 1) made the two kinds of implementation distinguishable; this is the gate that reads the distinction. One predicate — `isServiceServeable` in `runtime/src/service-serveable.ts`, which absorbed `isAnalyticsServiceServeable` — is read by the `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement. A slot whose occupant declares `handlerReady: false` gets exactly the answer an empty slot gets: the domain's own 404, or the explicit 501 `/storage` and `/i18n` use. Same predicate on the advertisement and the handler, so the two cannot disagree. `handlerReady`, not `status`, is the test, and that is what makes the rule safe to generalize: an implementation that really works declares `degraded`, which defaults `handlerReady` to true, so `file-storage` / `i18n` / `metadata` / `workflow` keep serving. Only the fabricating class is answered as empty. `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` with no `route`, which says strictly more than collapsing it to `unavailable` would. `/ai` improves for the stub case. Being truthy, such an occupant used to fall through to the `!routes` 503 ("AI service routes not yet initialized") and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored. Deliberately unchanged: - `/data`, `/meta`, `/auth` and the security path are NOT gated. Their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself. Step 1 labels them honestly either way. - No stub is deleted. #4058 preferred retiring the `automation` / `notification` / `ai` stubs too, but once step 1 classes them `stub` this gate already makes their HTTP surface equal to an empty slot — verified end to end — so "should the fabricating class occupy a slot at all" became separable. It is evaluated as a class in #4093, together with `data` / `auth` / `security.*`. - The metadata-protocol route suppression covers only the six dispatcher-owned slots. Elsewhere the route belongs to the plugin that registers the service, so `handlerReady` says nothing about whether THAT route is mounted; suppressing it would be a guess. A test pins that boundary. Filed out of scope (Prime Directive #10): #4087 (the `/storage` bridge calls `upload(file, {request})` against the contract's `upload(key, data, options?)` — pre-existing, reproduced during end-to-end verification) and #4093 (whether the "stub fills the slot" design should be retired wholesale). #4089 (the kernel fallbacks' unrecognized `_fallback` marker, filed from this branch) was fixed at the source by #4082 and is closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8d5bb5a commit bb192c4

15 files changed

Lines changed: 450 additions & 69 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
Gate every dispatcher service domain on `handlerReady` instead of on slot occupancy (#4058 step 2).
7+
8+
#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"); every other domain still gated on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was called like a real implementation and its fabricated answer went out as a 200. Step 1 (#4082) made the two kinds of dev implementation distinguishable; this is the gate that reads the distinction.
9+
10+
- The `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement now share one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's existing 404, or the explicit 501 `/storage` and `/i18n` use. One predicate, so what is advertised and what is served cannot disagree.
11+
- `handlerReady`, not `status`, is the test. An implementation that declares `degraded` defaults to `handlerReady: true` and keeps serving — which is why the in-memory `file-storage` and `i18n` implementations are unaffected.
12+
- `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` (with no `route`), which says strictly more than collapsing it to `unavailable` would.
13+
- `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "AI service routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored.
14+
15+
No change for a host whose services are real implementations. If you register your own stub under one of those six slots and relied on the dispatcher calling it, either drop the `handlerReady: false` self-declaration (declare `degraded` if it genuinely serves) or install the real service. Not gated, deliberately: `/data`, `/meta`, `/auth` and the security path — their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself.

content/docs/kernel/services-checklist.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,32 @@ The discovery endpoint returns a `services` map so clients know what is availabl
135135
}
136136
```
137137

138+
#### `stub` vs `degraded` — and what the dispatcher does with each
139+
140+
A service may self-declare that it is not the full thing (ADR-0076 D12), via a
141+
`__serviceInfo: { status, handlerReady?, message? }` property on the registered
142+
instance — see [Services → presence is not capability](./services.mdx) for how
143+
an in-process caller reads it. The two values mean different things, and for the
144+
HTTP surface the difference is load-bearing (#4058):
145+
146+
| Declared | `handlerReady` default | Meaning | Dispatcher behaviour |
147+
|:---|:---|:---|:---|
148+
| `degraded` | `true` | Really does the work, with reduced capability (in-memory, no persistence, no cross-process fan-out) | **Served normally.** Route advertised, requests handled. |
149+
| `stub` | `false` | Fabricates its answers — reports success for work that never happened | **Treated as an EMPTY slot.** Route not advertised, request answered exactly as if nothing were registered (404, or the domain's 501). |
150+
151+
Only `handlerReady` is consulted, never `status` — so an implementation that
152+
declares `degraded` but explicitly sets `handlerReady: false` is also treated as
153+
empty. The rule is enforced by one predicate (`isServiceServeable`) shared by
154+
the service domains, the route-mount gate, and both discovery builders, so what
155+
is advertised and what is served cannot disagree. It applies to the
156+
dispatcher-owned domains: `/analytics`, `/automation`, `/notifications`, `/ai`,
157+
`/storage`, `/i18n`.
158+
159+
The `services` map still reports a registered stub as
160+
`{ enabled: true, status: "stub", handlerReady: false }` rather than collapsing
161+
it to `unavailable` — "something is in this slot, and it is a fake" says more
162+
than "install a plugin".
163+
138164
---
139165

140166
## 2. data Service ✅ Implemented
@@ -193,6 +219,13 @@ self-declared stub (`handlerReady: false`, ADR-0076 D12) exactly like an empty
193219
one — routes unmounted, request 404. To use analytics locally, install the real
194220
engine; `@objectstack/service-analytics` runs an InMemory strategy, so it needs
195221
no database of its own.
222+
223+
**And it is no longer analytics-only** (#4058 step 2): every dispatcher-owned
224+
domain now gates on `handlerReady`, so a slot occupied by a self-declared `stub`
225+
`automation`, `notification`, `ai`, wherever one is registered — answers as an
226+
empty slot does. The implementations that really do the work in memory declare
227+
`degraded` and keep serving. See
228+
[`stub` vs `degraded`](#stub-vs-degraded--and-what-the-dispatcher-does-with-each).
196229
</Callout>
197230

198231
### Features (via `@objectstack/service-analytics`)

docs/adr/0076-objectql-core-tiering.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ Decision: each capability plugin registers its routes as a **normalized handler*
142142
2. Discovery MUST respect it: such services report **`status: 'stub'` (or `'degraded'`) with `handlerReady: false`**, never `status: 'available'`. Add the status value to the discovery schema.
143143
3. Consumers (agents, console) treat **only `handlerReady: true` / `status: 'available'`** as a real capability; `stub`/`unavailable` ⇒ do not use for real work. *(Update #4000: the **dispatcher is a consumer too**, and was not executing this — every service domain gates on "is the slot occupied", never on `handlerReady`, so a stub in the slot was called like a real implementation. Enforced for `analytics` (`isAnalyticsServiceServeable` in `runtime/src/domains/analytics.ts`, read by the domain, the route-mount gate, and `routes`/`features` in discovery — one predicate, so advertised and served cannot disagree). The other stub-backed domains — `file-storage` / `search` / `automation` / `realtime` / `notification` / `ai` / `i18n` — still gate on presence alone; adopting the same rule across them is a per-domain call, tracked in #4058.)*
144144

145+
*(Update #4058 step 2 — the per-domain call, made, on top of step 1's classification. The rule is now class-wide across the dispatcher-owned domains (`/analytics`, `/automation`, `/notifications`, `/ai`, `/storage`, `/i18n`), reading ONE predicate — `isServiceServeable` in `runtime/src/service-serveable.ts`, which absorbed `isAnalyticsServiceServeable` — from the domain, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement. A slot whose occupant self-declares `handlerReady: false` gets the domain's own empty-slot answer (404, or the explicit 501 `/storage` and `/i18n` use). What makes that safe is step 1: `handlerReady` — not `status` — is the test, and the implementations that really work declare `degraded`, which defaults it to `true`, so `file-storage` / `i18n` keep serving and only the fabricating occupants (`automation` / `notification` / `ai` / `data` / `auth` / `security.*`, `handlerReady: false`) are answered as empty. Deliberately NOT gated: `/data`, `/meta`, `/auth` and the security path, whose dev stubs back the dev stack's own core loop — gating those would 404 the dev stack itself. They are honestly labelled either way; whether the fabricating class should be **registered at all** is the wider evaluation in #4093 (its A tier), where the three `security.*` stubs are also flagged against this ADR's own "a fallback may degrade features, never security semantics". `search` / `realtime` are unaffected: neither has a dispatcher domain, and the surfaceless `degraded` fallbacks (`cache`/`queue`/`job`) carry `handlerReady: false` for the separate D12 reason that no HTTP surface exists for them.)*
146+
145147
This fixes the **whole class at once — without deleting any fallback** (no `/analytics` 404 regression): the analytics fallback and the dev stubs simply stop *lying*; they keep serving but are honestly labelled. It is the runtime enforcement of the D9-refinement principle (capabilities = what is actually installed, computed at runtime).
146148

147149
*(Update #4018: the same honesty binds the `routes` table, not only `services` — and it had a third publisher. `plugin-hono-server`'s `registerStandardEndpoints` convenience block still served a fully **static** `routes` map listing auth/packages/analytics/workflow/automation/ai/notifications/i18n/storage/ui regardless of what was mounted, so a standalone Hono host advertised ten route families and 404'd on the ones no plugin bridged. Closed on both axes: it now **cedes** `/discovery` to `@objectstack/rest` or the runtime dispatcher whenever either is on the kernel (single owner — D11 / OQ#9 worklist item 2; both register during `start()`, so first-registration-wins already shadowed this handler and the cede is behaviour-preserving), and when it does own the route it computes `routes` from the app's **live route table** — a family is advertised iff a route is really registered at or under its base. That keeps the honest answer without adding a third service-registry walk to keep in sync with the other two.)*

packages/metadata-protocol/src/protocol.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,18 +1469,29 @@ export class ObjectStackProtocolImplementation implements
14691469
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
14701470
};
14711471

1472-
// [#4000] The dispatcher answers a self-declared stub in the `analytics`
1473-
// slot with the same 404 an empty slot gets (`isAnalyticsServiceServeable`,
1474-
// runtime/src/domains/analytics.ts), so this builder must not advertise a
1475-
// route for it — that would be the `declared ≠ enforced` gap discovery
1476-
// exists to close. Analytics-only on purpose: every other stub-backed
1477-
// slot IS still served by its dispatcher domain, so their route
1478-
// advertisement stays presence-gated and honest. Unifying the rule
1479-
// across domains is #4058.
1480-
const analyticsUnserveable =
1481-
readServiceSelfInfo(registeredServices.get('analytics'))?.handlerReady === false;
1472+
// [#4000, #4058] The dispatcher answers a self-declared non-handler in
1473+
// one of ITS domains' slots with the same 404/501 an empty slot gets
1474+
// (`isServiceServeable`, runtime/src/service-serveable.ts), so this
1475+
// builder must not advertise a route for one — that would be the
1476+
// `declared ≠ enforced` gap discovery exists to close.
1477+
//
1478+
// Scoped to the dispatcher-owned domains on purpose. For the other
1479+
// entries in SERVICE_CONFIG the route belongs to the plugin that
1480+
// registers the service (service-storage's own `/api/v1/storage`
1481+
// routes, plugin-search, plugin-graphql, …) rather than to a dispatcher
1482+
// domain, so `handlerReady` there says nothing about whether THAT route
1483+
// is mounted, and suppressing it would be a guess. `file-storage` is
1484+
// listed because the dispatcher does own a `/storage` bridge for the
1485+
// no-plugin case; when service-storage is installed it registers a real
1486+
// (unmarked) service, so the entry never fires.
1487+
const DISPATCHER_GATED_SERVICES = new Set([
1488+
'analytics', 'automation', 'notification', 'ai', 'i18n', 'file-storage',
1489+
]);
1490+
const unserveable = (serviceName: string) =>
1491+
DISPATCHER_GATED_SERVICES.has(serviceName)
1492+
&& readServiceSelfInfo(registeredServices.get(serviceName))?.handlerReady === false;
14821493
const advertisedRoute = (serviceName: string, route?: string) =>
1483-
serviceName === 'analytics' && analyticsUnserveable ? undefined : route;
1494+
unserveable(serviceName) ? undefined : route;
14841495

14851496
// Check which services are actually registered
14861497
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,62 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
194194
expect(discovery.routes.analytics).toBeUndefined();
195195
});
196196

197+
// #4058 — the same rule for every OTHER slot whose HTTP surface is a
198+
// dispatcher domain, now that those domains gate on `handlerReady` too.
199+
it('should not advertise routes for self-declared stubs in the other dispatcher-owned slots', async () => {
200+
const mockServices = new Map<string, any>();
201+
for (const name of ['automation', 'notification', 'ai', 'i18n', 'file-storage']) {
202+
mockServices.set(name, { __serviceInfo: { status: 'stub', message: 'dev fake' } });
203+
}
204+
205+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
206+
const discovery = await protocol.getDiscovery();
207+
208+
for (const name of ['automation', 'notification', 'ai', 'i18n', 'file-storage']) {
209+
expect(discovery.services[name].enabled, `${name}.enabled`).toBe(true);
210+
expect(discovery.services[name].status, `${name}.status`).toBe('stub');
211+
expect(discovery.services[name].handlerReady, `${name}.handlerReady`).toBe(false);
212+
expect(discovery.services[name].route, `${name}.route`).toBeUndefined();
213+
}
214+
for (const key of ['automation', 'notifications', 'ai', 'i18n', 'storage']) {
215+
expect(discovery.routes[key], `routes.${key}`).toBeUndefined();
216+
}
217+
});
218+
219+
// The limit of that rule: `degraded` means "really serves, reduced
220+
// capability", so the route stays advertised — plugin-dev's in-memory file
221+
// store and i18n provider are exactly this, and the dispatcher keeps serving
222+
// them.
223+
it('should keep advertising routes for degraded implementations that really serve', async () => {
224+
const mockServices = new Map<string, any>();
225+
mockServices.set('file-storage', { __serviceInfo: { status: 'degraded', message: 'in-memory' } });
226+
mockServices.set('i18n', { __serviceInfo: { status: 'degraded', message: 'in-memory' } });
227+
228+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
229+
const discovery = await protocol.getDiscovery();
230+
231+
expect(discovery.services['file-storage'].status).toBe('degraded');
232+
expect(discovery.services['file-storage'].handlerReady).toBe(true);
233+
expect(discovery.routes.storage).toBe('/api/v1/storage');
234+
expect(discovery.routes.i18n).toBe('/api/v1/i18n');
235+
});
236+
237+
// Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST layer),
238+
// `workflow`, `graphql` and the queue/job/cache families have their routes
239+
// mounted by the plugin that registers the service, so `handlerReady` there
240+
// says nothing about whether THAT route is mounted and the advertisement
241+
// stays presence-gated. Suppressing it would be a guess, not honesty.
242+
it('should leave non-dispatcher-owned routes presence-gated', async () => {
243+
const mockServices = new Map<string, any>();
244+
mockServices.set('search', { __serviceInfo: { status: 'stub', message: 'dev fake' } });
245+
246+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
247+
const discovery = await protocol.getDiscovery();
248+
249+
expect(discovery.services.search.status).toBe('stub');
250+
expect(discovery.services.search.route).toBe('/api/v1/search');
251+
});
252+
197253
it('should map file-storage service to storage route', async () => {
198254
const mockServices = new Map<string, any>();
199255
mockServices.set('file-storage', {});

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
44
import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
55
import { DispatcherErrorCode } from '@objectstack/spec/api';
66
import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
7-
import { isAnalyticsServiceServeable } from './domains/analytics.js';
7+
import { isServiceServeable } from './service-serveable.js';
88
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js';
99
import { buildApiError } from './error-envelope.js';
1010
import {
@@ -665,7 +665,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
665665
svc = await k.getServiceAsync('analytics').catch(() => undefined);
666666
}
667667
if (!svc) svc = k?.getService?.('analytics');
668-
return isAnalyticsServiceServeable(svc);
668+
return isServiceServeable(svc);
669669
} catch {
670670
return false;
671671
}

packages/runtime/src/domains/ai.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import {
1414
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
1515
} from '@objectstack/core';
16+
import { isServiceServeable } from '../service-serveable.js';
1617
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
1718
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
1819

@@ -36,7 +37,13 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
3637
// AI service not registered
3738
}
3839

39-
if (!aiService) {
40+
// [#4058] A slot filled by a self-declared non-handler (`handlerReady:
41+
// false`, ADR-0076 D12) is no AI capability, so it takes the same exits an
42+
// empty slot takes. This is also strictly better than what such an occupant
43+
// used to get: being truthy, it fell through to the `!routes` 503 below —
44+
// which both reads as a fault and loses the `/ai/agents` empty-list
45+
// courtesy the console depends on.
46+
if (!isServiceServeable(aiService)) {
4047
// The console polls `GET /ai/agents` on every navigation to decide
4148
// whether to show AI affordances. Reporting that as a 404 turns the
4249
// normal "no AI service configured" state (the open-source default —

0 commit comments

Comments
 (0)