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,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>
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.
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,32 @@ 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 — 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
Copy file name to clipboardExpand all lines: docs/adr/0076-objectql-core-tiering.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -142,6 +142,8 @@ Decision: each capability plugin registers its routes as a **normalized handler*
142
142
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.
143
143
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.)*
144
144
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
+
145
147
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).
146
148
147
149
*(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