You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
Copy file name to clipboardExpand all lines: content/docs/kernel/services-checklist.mdx
+33Lines changed: 33 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -135,6 +135,31 @@ The discovery endpoint returns a `services` map so clients know what is availabl
135
135
}
136
136
```
137
137
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
Copy file name to clipboardExpand all lines: docs/adr/0076-objectql-core-tiering.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -132,7 +132,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
132
132
**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.
133
133
134
134
**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".)*
136
136
-`ObjectQLPlugin` registers a ~66-line `analytics`**fallback** (the D10 note — deliberate, but it reports as fully available).
137
137
-`http-dispatcher.ts``svcAvailable` — the hardcode above.
138
138
@@ -141,6 +141,8 @@ Decision: each capability plugin registers its routes as a **normalized handler*
141
141
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.
142
142
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.)*
143
143
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
+
144
146
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).
145
147
146
148
*(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.)*
0 commit comments