Skip to content

Commit f851709

Browse files
committed
fix(runtime,plugin-dev): gate every dispatcher domain on handlerReady, and give each dev stub its own honesty class (#4058)
#4000 made `/analytics` execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability") and left the rest of the class open, because adopting it per-domain needed a decision the issue could not make yet: every plugin-dev stub carried the single `_dev: true` marker, which normalizes to `status: 'stub', handlerReady: false`. One label for "invents answers" AND for "really does the work, in memory" — so no gate could tell the two apart without either 404ing working in-memory implementations or keeping fabricated data on the wire. So the classification comes first, and the gate follows it. Classification — each dev stub declares its own class via the standard `__serviceInfo` marker: - `degraded` (really does the work, reduced capability; `handlerReady` defaults true): `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `realtime`, `workflow`, `metadata`. Their domains keep serving them — which is why `/storage` and `/i18n` do not regress. - `stub` (fabricates): `data`, `auth`, `security.*`, and the shapeless placeholder used for a slot with no factory. - Retired outright, joining `analytics`: `automation`, `notification`, `ai`. Each one's headline method reported success for work that never happened — `execute()` → `{ success: true }` with no flow run, `send()` → a messageId for a message nobody receives, `chat()` → a placeholder answer. An empty slot is one less layer of indirection than a slot that must be ignored, and it is what production already has without the plugin. `_dev: true` stays as the plugin-dev provenance tag (AppPlugin's i18n diagnostic reads it); `__serviceInfo` decides the class. Gate — one predicate, `isServiceServeable` (new `service-serveable.ts`, which absorbed `isAnalyticsServiceServeable`), 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 self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's own 404 or 501 — so what is advertised and what is served cannot disagree. `handlerReady`, not `status`, remains the test. `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` gets better for the stub case: an occupied-but-unserveable slot used to fall through to the `!routes` 503 and lose the `GET /ai/agents` empty list the console polls for. Both are restored. Deliberately NOT gated: `data` / `auth` / `security.*`. Those stubs back the dev stack's own core loop, so gating their domains would 404 the dev stack itself — a separate decision. They are honestly labelled either way. `search` / `realtime` are unaffected: neither has a dispatcher domain. Verified end to end against a real plugin-dev slate: the four retired slots report `unavailable` with no route and 404, `/ai/agents` answers `{ agents: [] }`, and the `degraded` slots keep their routes and serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
1 parent c53aa53 commit f851709

18 files changed

Lines changed: 645 additions & 142 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-dev': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
Gate every dispatcher service domain on `handlerReady`, not on slot occupancy, and give each plugin-dev stub its own honesty class (#4058).
8+
9+
#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability") but left the other domains gating on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was still called like a real implementation. The blocker to generalizing it was that every plugin-dev stub carried the single `_dev: true` marker, which normalizes to `status: 'stub'` — one label for "invents answers" and for "really does the work, in memory", so no gate could tell the two apart.
10+
11+
- **Each dev stub declares its own class** via the standard `__serviceInfo` marker: `degraded` for the fakes that really do the work (`cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `realtime`, `workflow`, `metadata`) and `stub` for the ones that fabricate (`data`, `auth`, `security.*`, and the shapeless placeholder used for a slot with no factory). `_dev: true` stays as the plugin-dev provenance tag; `__serviceInfo` decides the class.
12+
- **The dispatcher-owned domains** (`/analytics`, `/automation`, `/notifications`, `/ai`, `/storage`, `/i18n`), the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement all read one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly like an empty slot — the domain's existing 404 or 501 — so what is advertised and what is served cannot disagree. A `degraded` implementation defaults to `handlerReady: true` and keeps serving, which is why `/storage` and `/i18n` are unaffected.
13+
- **`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.
14+
- `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for. Both are restored.
15+
16+
FROM → TO for dev setups that relied on a retired stub. `plugin-dev` no longer registers `automation`, `notification` or `ai` (joining `analytics` from #4000) — each one's headline method reported success for work that never happened (`execute()``{ success: true }` with no flow run, `send()` → a messageId for a message nobody receives, `chat()` → a placeholder answer). Install the real service instead: `@objectstack/service-automation`, `@objectstack/service-messaging`, or an AI service. Nothing changes for `os serve` / `os dev`, where `messaging` and `analytics` are already in `ALWAYS_ON_CAPABILITIES`. Deliberately unchanged: the `data` / `auth` / `security.*` dev stubs keep their slots (the dev stack's core loop resolves them) — they are now honestly labelled `stub`, but their domains are not gated.

content/docs/kernel/services-checklist.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,31 @@ 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. The two values mean different things, and the difference is
143+
load-bearing (#4058):
144+
145+
| Declared | `handlerReady` default | Meaning | Dispatcher behaviour |
146+
|:---|:---|:---|:---|
147+
| `degraded` | `true` | Really does the work, with reduced capability (in-memory, no persistence, no cross-process fan-out) | **Served normally.** Route advertised, requests handled. |
148+
| `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). |
149+
150+
Only `handlerReady` is consulted, never `status` — so an implementation that
151+
declares `degraded` but explicitly sets `handlerReady: false` is also treated as
152+
empty. The rule is enforced by one predicate (`isServiceServeable`) shared by
153+
the service domains, the route-mount gate, and both discovery builders, so what
154+
is advertised and what is served cannot disagree. It applies to the
155+
dispatcher-owned domains: `/analytics`, `/automation`, `/notifications`, `/ai`,
156+
`/storage`, `/i18n`.
157+
158+
The `services` map still reports a registered stub as
159+
`{ enabled: true, status: "stub", handlerReady: false }` rather than collapsing
160+
it to `unavailable` — "something is in this slot, and it is a fake" says more
161+
than "install a plugin".
162+
138163
---
139164

140165
## 2. data Service ✅ Implemented
@@ -193,6 +218,14 @@ self-declared stub (`handlerReady: false`, ADR-0076 D12) exactly like an empty
193218
one — routes unmounted, request 404. To use analytics locally, install the real
194219
engine; `@objectstack/service-analytics` runs an InMemory strategy, so it needs
195220
no database of its own.
221+
222+
**And it is no longer analytics-only** (#4058): `plugin-dev`'s `automation`,
223+
`notification` and `ai` dev stubs are retired for the same reason — each
224+
reported success for work that never happened — and every dispatcher-owned
225+
domain now gates on `handlerReady`. The dev stubs that really do the work in
226+
memory (`file-storage`, `i18n`, `cache`, `queue`, `job`, `search`, `realtime`,
227+
`workflow`, `metadata`) declare `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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
132132
**Root cause of agents being misled.** Several plugins register stub / dev / fallback services under canonical names, and the discovery builder reports *any* present service as fully real: `runtime/http-dispatcher.ts`'s `svcAvailable` hardcodes `{ enabled: true, status: 'available', handlerReady: true }` for every registered service — it **ignores stub markers** (its own comment even says "handlerReady:false … may be served by a stub", but the code never computes it). So `discovery.services.*` claims capabilities that are only stubbed, and consumers (AI agents, the console) trust them. A dev AI stub advertised this way has already confused an agent.
133133

134134
**Inventory of current fakes / mis-reports:**
135-
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)*
135+
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058: `automation` / `notification` / `ai` joined `analytics` in retirement — same defect, same fix — and the survivors stopped sharing one marker. Each dev stub now declares its own honesty class via `__serviceInfo`: `degraded` when it really does the work in memory, `stub` when it fabricates. `_dev: true` remains as the plugin-dev provenance tag only; it no longer decides the class, and the "nothing respects it" of the original finding is now "everything respects the declaration it normalizes to".)*
136136
- `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available).
137137
- `http-dispatcher.ts` `svcAvailable` — the hardcode above.
138138

@@ -141,6 +141,8 @@ Decision: each capability plugin registers its routes as a **normalized handler*
141141
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.
142142
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.)*
143143

144+
*(Update #4058 — the per-domain call, made. 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. What made it safe to generalize was **splitting the honesty class first**: the blocker recorded in #4000 was that one `_dev: true` marker labelled "invents answers" and "really works, in memory" identically, so no gate could tell them apart. plugin-dev now declares `__serviceInfo` per stub — `degraded` for the fakes that really do the work (`cache`/`queue`/`job`/`file-storage`/`search`/`i18n`/`realtime`/`workflow`/`metadata`: `handlerReady` defaults `true`, so their domains keep serving them, which is why `/storage` and `/i18n` did not regress) and `stub` for the ones that fabricate. The four whose headline method reported success for work that never happened (`analytics`, `automation.execute`, `notification.send`, `ai.chat`) are not labelled but **retired** — an empty slot is one less layer of indirection than a slot that must be ignored, and it is what production already has. Deliberately NOT gated: `data` / `auth` / `security.*`, whose stubs back the dev stack's core loop — they are honestly labelled `stub` (so discovery reports them as such) but gating their domains would 404 the dev stack itself, which is a separate decision. `search` / `realtime` are unaffected either way: neither has a dispatcher domain.)*
145+
144146
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).
145147

146148
*(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
@@ -1408,18 +1408,29 @@ export class ObjectStackProtocolImplementation implements
14081408
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
14091409
};
14101410

1411-
// [#4000] The dispatcher answers a self-declared stub in the `analytics`
1412-
// slot with the same 404 an empty slot gets (`isAnalyticsServiceServeable`,
1413-
// runtime/src/domains/analytics.ts), so this builder must not advertise a
1414-
// route for it — that would be the `declared ≠ enforced` gap discovery
1415-
// exists to close. Analytics-only on purpose: every other stub-backed
1416-
// slot IS still served by its dispatcher domain, so their route
1417-
// advertisement stays presence-gated and honest. Unifying the rule
1418-
// across domains is #4058.
1419-
const analyticsUnserveable =
1420-
readServiceSelfInfo(registeredServices.get('analytics'))?.handlerReady === false;
1411+
// [#4000, #4058] The dispatcher answers a self-declared non-handler in
1412+
// one of ITS domains' slots with the same 404/501 an empty slot gets
1413+
// (`isServiceServeable`, runtime/src/service-serveable.ts), so this
1414+
// builder must not advertise a route for one — that would be the
1415+
// `declared ≠ enforced` gap discovery exists to close.
1416+
//
1417+
// Scoped to the dispatcher-owned domains on purpose. For the other
1418+
// entries in SERVICE_CONFIG the route belongs to the plugin that
1419+
// registers the service (service-storage's own `/api/v1/storage`
1420+
// routes, plugin-search, plugin-graphql, …) rather than to a dispatcher
1421+
// domain, so `handlerReady` there says nothing about whether THAT route
1422+
// is mounted, and suppressing it would be a guess. `file-storage` is
1423+
// listed because the dispatcher does own a `/storage` bridge for the
1424+
// no-plugin case; when service-storage is installed it registers a real
1425+
// (unmarked) service, so the entry never fires.
1426+
const DISPATCHER_GATED_SERVICES = new Set([
1427+
'analytics', 'automation', 'notification', 'ai', 'i18n', 'file-storage',
1428+
]);
1429+
const unserveable = (serviceName: string) =>
1430+
DISPATCHER_GATED_SERVICES.has(serviceName)
1431+
&& readServiceSelfInfo(registeredServices.get(serviceName))?.handlerReady === false;
14211432
const advertisedRoute = (serviceName: string, route?: string) =>
1422-
serviceName === 'analytics' && analyticsUnserveable ? undefined : route;
1433+
unserveable(serviceName) ? undefined : route;
14231434

14241435
// Check which services are actually registered
14251436
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', {});

0 commit comments

Comments
 (0)