Skip to content

Commit 951e43d

Browse files
committed
fix(plugin-dev,runtime): retire the data and ui dev stubs; /ui discovery gates on the protocol service (#4093)
The A-tier continuation of #4126, closing out the two items whose prerequisites are now met (#4130 landed the data-slot discovery fix; this change carries the ui-route prerequisite itself). `auth` stays untouched, blocked on #4113. data: in any boot with the objectql toggle on — the default — ObjectQLPlugin registers the real engine and the stub never fired. In an engine-less boot it was strictly harmful: both consumers of the slot carry a DELIBERATE empty-slot degradation the stub silently replaced with fabrication. service-automation's CRUD nodes document "no data engine → no-op success", but the stub's insert() minted a `dev-<ts>` record id for data it discarded, which downstream flow nodes then referenced as if stored; runtime's default-datasource plugin treats an absent engine as "nothing to wire". The /data HTTP domain never reads the slot (it resolves through callData), and discovery reads the occupant's self-description since #4130 — an empty slot is answered honestly everywhere. ui: the slot was pure fiction. Nothing in the platform registers or consumes a `ui` service — the shapeless placeholder was its only occupant ever, and domains/ui.ts serves /ui off the `protocol` service, 503ing without it. The placeholder's only observable effect was advertising /ui in boots where the route could only 503 (e.g. objectql disabled, or @objectstack/rest embedders with enableUi off reading the builder's discovery). Discovery now reads what /ui reads: routes.ui and services.ui gate on `typeof protocol?.getUiView === 'function'` — handleUiRequest's own guard, byte for byte, the same rule hasMcp already follows. That fixes both directions: a placeholder-but-no-protocol boot no longer advertises a route that can only 503, and a production boot (where the vestigial slot is always empty but the protocol serves) no longer hides a route that works. The unavailable message names the actual remedy — register MetadataPlugin — instead of svcUnavailable's "install a ui plugin", which names a plugin that does not exist. With ui retired, the registration loop's shapeless-placeholder fallback is dead code and is gone too: a slot this plugin has no implementation for is a slot it must leave empty, not occupy with a nothing. A config-drift pin in the tests asserts every core service name is either implemented or deliberately listed in NO_DEV_STUB_SERVICES, so the placeholder cannot silently regrow. Verified: plugin-dev 12, runtime 927 (4 new: route advertised + served with a protocol; not advertised without one, with the remedy named; the old dev-boot shape — ui occupant, no protocol — stays un-advertised AND 503s; wrong-shaped protocol mirrors the domain), metadata-protocol 110, service-automation 457 pass; build 71/71; eslint clean. The two dispatcher↔builder parity tests (metadata, data) pass against freshly built packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
1 parent 4dc14cc commit 951e43d

7 files changed

Lines changed: 182 additions & 56 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-dev': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Retire the `data` and `ui` dev stubs; discovery gates `/ui` on the `protocol` service that actually serves it (#4093).
7+
8+
**`data`**: with the `objectql` toggle on (the default), ObjectQLPlugin registers the real engine and the stub never fired anyway. In an engine-less boot it was strictly harmful: both consumers of the slot carry a deliberate empty-slot degradation the stub silently replaced with fabrication — service-automation's CRUD nodes document "no data engine → no-op success", but the stub's `insert()` minted a fake record id that downstream flow nodes then referenced as if stored; runtime's default-datasource plugin treats an absent engine as "nothing to wire". The `/data` HTTP domain never reads this slot, and discovery has read the occupant's self-description since #4130 — an empty slot is handled honestly everywhere.
9+
10+
**`ui`**: the slot was pure fiction — nothing in the platform registers or consumes a `ui` service; plugin-dev's shapeless placeholder was its only occupant ever. `/ui` is served by the `protocol` service (`domains/ui.ts` 503s without it), so the placeholder's only observable effect was advertising `/ui` in boots where the route could only 503.
11+
12+
**Runtime discovery now reads what `/ui` reads**: `routes.ui` and `services.ui` gate on `typeof protocol?.getUiView === 'function'` — the domain's own guard, byte for byte (the same rule `mcp` follows) — instead of the vestigial `ui` slot. This fixes both directions: a boot with a placeholder but no protocol no longer advertises a route that can only 503, and a production boot with a working protocol no longer hides a route that serves. The unavailable message names the actual remedy (register MetadataPlugin) instead of "install a ui plugin", which doesn't exist.
13+
14+
FROM → TO: dev boots no longer register `data`/`ui` stubs — the slots stay empty, exactly as production has them. Anything that resolved those slots optionally keeps its documented empty-slot path; nothing in either repo consumed the stubs' fabricated answers. Discovery's `routes.ui` may newly appear in production deployments (the route always served there) and newly disappear in protocol-less dev boots (it never worked there).

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

Lines changed: 1 addition & 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.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)* *(Update #4093: three of those `stub` entries are **gone**, not relabelled — `security.permissions` / `security.rls` / `security.fieldMasker`. Classifying them honestly was necessary but not sufficient, for the same reason honest labelling was not enough for the analytics shim (#3891): the label was accurate while the behaviour still inverted the decision it stood in for — `checkObjectPermission()` returned `true` for everything, `compileFilter()` returned `null` so no row predicate applied, `maskResults()` returned rows unmasked. That is this decision's own line — a fallback may degrade features, never security semantics — and `spec/src/contracts/security-service.ts` states it from the other side: these three are plugin-security's internals, and access-narrowing answers must fail CLOSED. `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, so the package merely being absent swapped real RBAC/RLS/masking for allow-all behind one `warn`. The slots now stay empty (what production has without SecurityPlugin), the boot log states that nothing is enforcing them, and `plugin-dev` additionally refuses to initialize at all under `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN=1` to override) — it is a published package that had no environment check of its own. Whether the remaining fabricating stubs (`data`, `auth`, `ui`) should occupy slots at all is still #4093; `auth` in particular is blocked on #4113, since the dispatcher's own `/auth` mock fallback — not the dev stub — is what actually answers `/auth/*` when the slot is empty.)*
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 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)* *(Update #4093: three of those `stub` entries are **gone**, not relabelled — `security.permissions` / `security.rls` / `security.fieldMasker`. Classifying them honestly was necessary but not sufficient, for the same reason honest labelling was not enough for the analytics shim (#3891): the label was accurate while the behaviour still inverted the decision it stood in for — `checkObjectPermission()` returned `true` for everything, `compileFilter()` returned `null` so no row predicate applied, `maskResults()` returned rows unmasked. That is this decision's own line — a fallback may degrade features, never security semantics — and `spec/src/contracts/security-service.ts` states it from the other side: these three are plugin-security's internals, and access-narrowing answers must fail CLOSED. `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, so the package merely being absent swapped real RBAC/RLS/masking for allow-all behind one `warn`. The slots now stay empty (what production has without SecurityPlugin), the boot log states that nothing is enforcing them, and `plugin-dev` additionally refuses to initialize at all under `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN=1` to override) — it is a published package that had no environment check of its own. Whether the remaining fabricating stubs (`data`, `auth`, `ui`) should occupy slots at all is still #4093; `auth` in particular is blocked on #4113, since the dispatcher's own `/auth` mock fallback — not the dev stub — is what actually answers `/auth/*` when the slot is empty.)* *(Update #4093 step 2: `data` and `ui` are retired as well. `data` never fired in an objectql-enabled boot — ObjectQLPlugin fills the slot — and in an engine-less boot both consumers (`service-automation`'s CRUD nodes, runtime's default-datasource plugin) carry a deliberate empty-slot degradation the stub replaced with fabricated record ids; discovery reads the occupant's self-description since #4130, so the empty slot reports honestly. `ui` was a shapeless placeholder in a slot nothing registers or consumes — `/ui` is served by the `protocol` service, and the dispatcher's discovery now gates `routes.ui`/`services.ui` on that service's own `getUiView` guard (the `hasMcp` rule), which the placeholder's presence used to falsify in both directions. `auth` remains the one stub left, still blocked on #4113.)*
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. *(Update #4089: `svcAvailable` respects the marker, but two entries never went through it at all — a "kernel-provided (always available)" block above the loop, hardcoded per builder and **contradicting itself across them**: the dispatcher declared `metadata` permanently `degraded` with "In-memory registry; DB persistence pending", while metadata-protocol declared the same slot permanently `available`. So one host called a `sys_metadata`-backed registry degraded and the other called the in-memory fallback fully real — the marker #4058 had just added to `createMemoryMetadata` went unread on both sides, because neither side read anything. Both now compute the slot from the registered implementation's `__serviceInfo` and agree; `handlerReady: true` stays unconditional on both, since `/api/v1/meta` is served by the protocol whichever implementation occupies the slot. `data` kept its hardcode in that pass: ObjectQL is the only producer that ever fills it, and `ObjectQLPlugin` — which plugin-dev always loads as a child — registers the real engine, so plugin-dev's `data` stub is unreachable in a stack that has a discovery builder at all.)* *(Update #4130: `data` is computed now too, which closes the block. Its hardcoded `available` was true, but by a **load-order convention in another package** rather than by anything either builder verified — the same shape the `metadata` hardcodes had while they were still "roughly right". Both builders derive the entry from the slot's `__serviceInfo`: an unmarked engine reproduces the old `available` / `handlerReady: true` byte for byte, while plugin-dev's `data` stub — `status: 'stub'`, so `handlerReady` defaults to `false` — is reported as the non-handler it declares itself to be. Unlike `metadata`, `handlerReady` here is NOT pinned `true`: `/meta` answers from the protocol (down to a last-resort default type list) whatever fills its slot, whereas `callData` needs the `protocol` service or an objectql-shaped one and throws 503 without them — and the only way a stub reaches the `data` slot is a stack where ObjectQL never registered, i.e. exactly the stack where `/data` cannot serve. Deliberately NOT done: re-deriving serveability from `protocol`/`objectql` inside the builder. Discovery resolves services **unscoped**, so that could report the required data capability as `handlerReady: false` on a multi-kernel host while per-request scoped resolution serves it fine — a worse lie than the one being fixed. No domain gates on this slot either: `isServiceServeable` (#4058 step 2) covers the optional domains, and `/data` resolves its engine directly.)*
138138
- *(Added by #4058: the **kernel's own** fallbacks — `createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`, auto-registered by ObjectKernel — were missing from this inventory and were the worst case in it: they carried `_fallback: true`, a marker **no** reader recognized (not even `readServiceSelfInfo`), so both discovery builders reported them as fully `available`. They now self-describe as `degraded` with `handlerReady: false` where no HTTP surface exists (`cache` / `queue` / `job`). The lone duck-typed consumer of `_fallback`/`_dev` — an i18n diagnostic in `app-plugin.ts` — reads `readServiceSelfInfo` instead.)*

packages/plugins/plugin-dev/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,14 @@ Most core kernel services not provided by a real plugin are registered as a dev
8686
| Class | Slots | Meaning |
8787
|:---|:---|:---|
8888
| `degraded` | `cache`, `queue`, `job`, `file-storage`, `search`, `realtime`, `i18n`, `workflow`, `metadata` | Really does the work, in memory only. Served normally over HTTP. |
89-
| `stub` | `data`, `auth`, plus `ui` (placeholder with no implementation) | Fabricates its answer. Reported as a stub in discovery, and every dispatcher-owned domain answers it exactly as it answers an empty slot. |
89+
| `stub` | `auth` | Fabricates its answer. Reported as a stub in discovery, and every dispatcher-owned domain answers it exactly as it answers an empty slot. |
9090

9191
**Never stubbed** — these slots stay empty on purpose, which is what production has when the real plugin isn't installed:
9292

9393
- `analytics` (#4000). Install `@objectstack/service-analytics` (it runs an InMemory strategy).
9494
- `security.permissions`, `security.rls`, `security.fieldMasker` (#4093). The former stubs answered "allowed" for every permission check, compiled no row-level filter, and returned rows unmasked — inverting the decisions they stood in for. ADR-0076 D12's rule is that a fallback may degrade features, **never security semantics**. Without `@objectstack/plugin-security` nothing enforces RBAC, RLS or field masking, and the boot log says so rather than a fake quietly saying yes.
95+
- `data` (#4093). With the `objectql` toggle on (the default), ObjectQLPlugin registers the real engine and a stub never fired anyway. In an engine-less boot the stub was strictly harmful: consumers of this slot carry deliberate empty-slot degradations (automation CRUD nodes no-op, the datasource plugin skips wiring) that the stub replaced with fabrication — `insert()` minted record ids for data it discarded.
96+
- `ui` (#4093). Nothing in the platform registers or consumes a `ui` service — `/ui` is served by the `protocol` service, and discovery now gates `routes.ui` on exactly that. The shapeless placeholder's only observable effect was advertising `/ui` in boots where it could only 503.
9597

9698
All services are **optional** — if a peer package isn't installed it is skipped, and for the slots above a stub takes its place.
9799

packages/plugins/plugin-dev/src/dev-plugin.test.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,9 @@ describe('DevPlugin', () => {
257257
// Really do the work, just less of it — their answers are true answers.
258258
const DEGRADED = ['cache', 'queue', 'job', 'file-storage', 'search', 'realtime', 'i18n', 'workflow', 'metadata'];
259259
// Fabricate the answer — must never be mistaken for a capability.
260-
// (`security.*` are no longer in this list because they are no longer
261-
// registered at all — see the dedicated test below, #4093.)
262-
const STUB = ['automation', 'notification', 'ai', 'data', 'auth'];
260+
// (`security.*`, `data` and `ui` are no longer in this list because they
261+
// are no longer registered at all — see the dedicated tests below, #4093.)
262+
const STUB = ['automation', 'notification', 'ai', 'auth'];
263263

264264
for (const name of DEGRADED) {
265265
const info = readServiceSelfInfo(registeredServices.get(name));
@@ -284,14 +284,24 @@ describe('DevPlugin', () => {
284284
expect(readServiceSelfInfo(registeredServices.get(name))?.handlerReady, `${name} handlerReady`).toBe(false);
285285
}
286286

287-
// `ui` has no factory at all — the shapeless placeholder must still be
288-
// honest about being nothing.
289-
const ui = readServiceSelfInfo(registeredServices.get('ui'));
290-
expect(ui?.status).toBe('stub');
291-
expect(ui?.handlerReady).toBe(false);
287+
// The retired slots stay empty: analytics (#4000); `data`, whose stub
288+
// replaced two consumers' deliberate empty-slot degradation with
289+
// fabrication; and `ui`, whose shapeless placeholder occupied a slot
290+
// nothing in the platform registers or consumes (#4093).
291+
for (const name of ['analytics', 'data', 'ui']) {
292+
expect(registeredServices.has(name), `${name} slot must stay empty`).toBe(false);
293+
}
292294

293-
// The retired analytics slot stays empty (#4000).
294-
expect(registeredServices.has('analytics')).toBe(false);
295+
// Config drift pin: every core slot is either implemented or deliberately
296+
// empty — a name in neither table would silently regrow a shapeless
297+
// placeholder, which is exactly what #4093 retired.
298+
const { CORE_SERVICE_NAMES, DEV_STUB_FACTORIES, NO_DEV_STUB_SERVICES } = await import('./dev-plugin');
299+
for (const name of CORE_SERVICE_NAMES) {
300+
expect(
301+
name in DEV_STUB_FACTORIES || NO_DEV_STUB_SERVICES.has(name),
302+
`${name} must have a stub factory or be listed in NO_DEV_STUB_SERVICES`,
303+
).toBe(true);
304+
}
295305
});
296306

297307
// [#4093] The one thing a fallback may never fake. ADR-0076 D12, from #3891:

0 commit comments

Comments
 (0)