From 951e43d841fd041b6537d7ffec13bb5bc805b65d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:46:58 +0000 Subject: [PATCH 1/2] fix(plugin-dev,runtime): retire the data and ui dev stubs; /ui discovery gates on the protocol service (#4093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-` 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 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- ...-plugin-data-ui-slots-and-ui-route-gate.md | 14 ++++ docs/adr/0076-objectql-core-tiering.md | 2 +- packages/plugins/plugin-dev/README.md | 4 +- .../plugins/plugin-dev/src/dev-plugin.test.ts | 30 ++++--- packages/plugins/plugin-dev/src/dev-plugin.ts | 83 ++++++++++--------- packages/runtime/src/http-dispatcher.test.ts | 74 +++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 31 ++++++- 7 files changed, 182 insertions(+), 56 deletions(-) create mode 100644 .changeset/dev-plugin-data-ui-slots-and-ui-route-gate.md diff --git a/.changeset/dev-plugin-data-ui-slots-and-ui-route-gate.md b/.changeset/dev-plugin-data-ui-slots-and-ui-route-gate.md new file mode 100644 index 0000000000..b746f0f9b1 --- /dev/null +++ b/.changeset/dev-plugin-data-ui-slots-and-ui-route-gate.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-dev': minor +'@objectstack/runtime': patch +--- + +Retire the `data` and `ui` dev stubs; discovery gates `/ui` on the `protocol` service that actually serves it (#4093). + +**`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. + +**`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. + +**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. + +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). diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index cb067858b1..8a6c8c703c 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -132,7 +132,7 @@ Decision: each capability plugin registers its routes as a **normalized handler* **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. **Inventory of current fakes / mis-reports:** -- `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.)* +- `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.)* - `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available). - `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.)* - *(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.)* diff --git a/packages/plugins/plugin-dev/README.md b/packages/plugins/plugin-dev/README.md index c6ca0254f6..c73bb6de3f 100644 --- a/packages/plugins/plugin-dev/README.md +++ b/packages/plugins/plugin-dev/README.md @@ -86,12 +86,14 @@ Most core kernel services not provided by a real plugin are registered as a dev | Class | Slots | Meaning | |:---|:---|:---| | `degraded` | `cache`, `queue`, `job`, `file-storage`, `search`, `realtime`, `i18n`, `workflow`, `metadata` | Really does the work, in memory only. Served normally over HTTP. | -| `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. | +| `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. | **Never stubbed** — these slots stay empty on purpose, which is what production has when the real plugin isn't installed: - `analytics` (#4000). Install `@objectstack/service-analytics` (it runs an InMemory strategy). - `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. +- `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. +- `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. All services are **optional** — if a peer package isn't installed it is skipped, and for the slots above a stub takes its place. diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index 9fa7ac754c..aa2fcdbcde 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -257,9 +257,9 @@ describe('DevPlugin', () => { // Really do the work, just less of it — their answers are true answers. const DEGRADED = ['cache', 'queue', 'job', 'file-storage', 'search', 'realtime', 'i18n', 'workflow', 'metadata']; // Fabricate the answer — must never be mistaken for a capability. - // (`security.*` are no longer in this list because they are no longer - // registered at all — see the dedicated test below, #4093.) - const STUB = ['automation', 'notification', 'ai', 'data', 'auth']; + // (`security.*`, `data` and `ui` are no longer in this list because they + // are no longer registered at all — see the dedicated tests below, #4093.) + const STUB = ['automation', 'notification', 'ai', 'auth']; for (const name of DEGRADED) { const info = readServiceSelfInfo(registeredServices.get(name)); @@ -284,14 +284,24 @@ describe('DevPlugin', () => { expect(readServiceSelfInfo(registeredServices.get(name))?.handlerReady, `${name} handlerReady`).toBe(false); } - // `ui` has no factory at all — the shapeless placeholder must still be - // honest about being nothing. - const ui = readServiceSelfInfo(registeredServices.get('ui')); - expect(ui?.status).toBe('stub'); - expect(ui?.handlerReady).toBe(false); + // The retired slots stay empty: analytics (#4000); `data`, whose stub + // replaced two consumers' deliberate empty-slot degradation with + // fabrication; and `ui`, whose shapeless placeholder occupied a slot + // nothing in the platform registers or consumes (#4093). + for (const name of ['analytics', 'data', 'ui']) { + expect(registeredServices.has(name), `${name} slot must stay empty`).toBe(false); + } - // The retired analytics slot stays empty (#4000). - expect(registeredServices.has('analytics')).toBe(false); + // Config drift pin: every core slot is either implemented or deliberately + // empty — a name in neither table would silently regrow a shapeless + // placeholder, which is exactly what #4093 retired. + const { CORE_SERVICE_NAMES, DEV_STUB_FACTORIES, NO_DEV_STUB_SERVICES } = await import('./dev-plugin'); + for (const name of CORE_SERVICE_NAMES) { + expect( + name in DEV_STUB_FACTORIES || NO_DEV_STUB_SERVICES.has(name), + `${name} must have a stub factory or be listed in NO_DEV_STUB_SERVICES`, + ).toBe(true); + } }); // [#4093] The one thing a fallback may never fake. ADR-0076 D12, from #3891: diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 276e05493e..ede7a139f4 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -8,7 +8,7 @@ import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; * All 17 core kernel service names as defined in CoreServiceName. * @see packages/spec/src/system/core-services.zod.ts */ -const CORE_SERVICE_NAMES = [ +export const CORE_SERVICE_NAMES = [ 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', 'automation', 'analytics', 'realtime', @@ -238,19 +238,9 @@ function createAuthStub() { }; } -/** IDataEngine — minimal no-op data stub (fallback) */ -function createDataStub() { - return { - _serviceName: 'data', - async find() { return []; }, - async findOne() { return undefined; }, - async insert(_obj: string, params: any) { return { id: `dev-${Date.now()}`, ...params?.data }; }, - async update(_obj: string, _id: string, params: any) { return params?.data ?? {}; }, - async delete() { return true; }, - async count() { return 0; }, - async aggregate() { return []; }, - }; -} +// [#4093] The `data` stub is GONE — see NO_DEV_STUB_SERVICES for why. It was +// a fabricating IDataEngine: find() always [], insert() minted `dev-` ids +// and stored nothing. // [#4093] The three security sub-service stubs are GONE — see // SECURITY_SERVICE_NAMES below for why. They were: @@ -267,7 +257,7 @@ function createDataStub() { * Each factory creates a new instance implementing the protocol interface * from `packages/spec/src/contracts/`. */ -const DEV_STUB_FACTORIES: Record Record> = { +export const DEV_STUB_FACTORIES: Record Record> = { 'cache': () => createMemoryCache(), 'queue': () => createMemoryQueue(), 'job': () => createMemoryJob(), @@ -280,7 +270,6 @@ const DEV_STUB_FACTORIES: Record Record> = { 'i18n': createI18nStub, 'workflow': createWorkflowStub, 'metadata': createMetadataStub, - 'data': createDataStub, 'auth': createAuthStub, }; @@ -327,17 +316,9 @@ const DEV_STUB_SELF_INFO: Record, info: Record): } /** - * Core service slots that deliberately get NO dev stub — not even the - * shapeless placeholder the registration loop uses for slots - * without a factory. + * Core service slots that deliberately get NO dev stub. An empty slot is what + * production has when the real provider isn't installed, and for each of + * these, every consumer already answers an empty slot honestly — so a stub + * could only make the answer worse. * * `analytics` (#4000): #3891/#3989 retired the degraded analytics shim, making * an unoccupied slot the honest signal — `/analytics/*` is not mounted, the @@ -364,8 +346,27 @@ function applySelfInfo(svc: Record, info: Record): * but "the capability is present" must mean the same thing in dev as in * production. To use analytics locally, install the real engine: * `@objectstack/service-analytics` runs an InMemory strategy. + * + * `data` (#4093): in any boot with the objectql toggle on, ObjectQLPlugin + * registers the real engine and the stub never fired. In an engine-less boot + * it was strictly harmful: the two consumers of this slot each 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 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 at all (it resolves through + * `callData`), and discovery reads the occupant's self-info since #4130 — + * an empty slot is handled everywhere. + * + * `ui` (#4093): the slot was pure fiction — NOTHING in the platform registers + * a `ui` service and nothing consumes one. `/ui` is served by the `protocol` + * service (domains/ui.ts), and discovery gates `routes.ui` on exactly that + * predicate now, so the shapeless placeholder's only observable effect was to + * advertise `/ui` in boots where the protocol was absent and the route could + * only 503. */ -const NO_DEV_STUB_SERVICES = new Set(['analytics']); +export const NO_DEV_STUB_SERVICES = new Set(['analytics', 'data', 'ui']); /** * Escape hatch for {@link assertNotProduction} — deliberately ungrouped and @@ -378,7 +379,8 @@ const ALLOW_IN_PRODUCTION_ENV = 'OS_ALLOW_DEV_PLUGIN' as const; * * This plugin's whole purpose is to make a local stack work without installing * anything: it fills unclaimed service slots with fakes. Several of those - * fabricate their answers (`data` discards writes and reports success), and it + * fabricate their answers (`automation` reports success without running the + * flow), and it * ships as a published package with no environment check of its own — so an * `objectstack.config.ts` that carries `new DevPlugin()` into a production * deploy got the whole fake slate silently, with only a boot log to say so. @@ -774,13 +776,16 @@ export class DevPlugin implements Plugin { } // ── Register contract-compliant dev stubs for remaining services ──── - // The kernel defines 17 core services + 3 security services. // Real plugins (ObjectQL, Auth, Security, etc.) already registered some. - // For any service NOT yet registered, we create a contract-compliant + // For any core service NOT yet registered, we create a contract-compliant // dev stub (implementing the interface from packages/spec/src/contracts/) // so that the full kernel service map is populated and downstream code // receives correct return types (arrays, booleans, objects — not undefined). - // Exception: NO_DEV_STUB_SERVICES slots stay empty on purpose (#4000). + // Exception: NO_DEV_STUB_SERVICES slots stay empty on purpose (#4000, + // #4093) — and there is no catch-all placeholder for a slot without a + // factory: a slot this file has no implementation for is a slot it must + // leave empty, not occupy with a shapeless nothing (the retired `ui` + // placeholder was exactly that). // // Each registered implementation carries its D12 self-description from // DEV_STUB_SELF_INFO (#4058), so discovery reports what it actually is — @@ -789,21 +794,19 @@ export class DevPlugin implements Plugin { const stubNames: string[] = []; - /** Build + self-describe one slot's dev implementation. */ - const makeStub = (svc: string) => { - const factory = DEV_STUB_FACTORIES[svc]; - const info = DEV_STUB_SELF_INFO[svc] ?? SHAPELESS_STUB_SELF_INFO; - return applySelfInfo(factory ? factory() : { _serviceName: svc }, info); - }; - for (const svc of CORE_SERVICE_NAMES) { if (!enabled(svc)) continue; if (NO_DEV_STUB_SERVICES.has(svc)) continue; + const factory = DEV_STUB_FACTORIES[svc]; + if (!factory) continue; // no implementation → the slot stays empty (see above) try { ctx.getService(svc); // Already registered by a real plugin — skip } catch { - ctx.registerService(svc, makeStub(svc)); + // No table entry ⇒ the implementation self-describes (the wrapped + // kernel fallbacks); applySelfInfo would refuse to overwrite anyway. + const info = DEV_STUB_SELF_INFO[svc]; + ctx.registerService(svc, info ? applySelfInfo(factory(), info) : factory()); stubNames.push(svc); } } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 48518416a1..3549e55dcd 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2660,6 +2660,80 @@ describe('HttpDispatcher', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // [#4093] /ui advertisement reads what /ui reads + // + // domains/ui.ts serves GET /ui/view/:object off the `protocol` service and + // 503s without it; the `ui` SLOT never enters that decision — nothing in + // the platform registers a `ui` service (plugin-dev's shapeless + // placeholder, retired in #4093, was its only occupant ever). Discovery + // used to gate `routes.ui` on that slot, which was wrong in both + // directions: a boot with the placeholder but no protocol advertised a + // route that could only 503, and a production boot with a working + // protocol hid a route that serves. Same predicate ⇒ same answer + // (the `hasMcp` rule). + // ═══════════════════════════════════════════════════════════════ + + describe('/ui discovery gates on the protocol service, not the vestigial ui slot (#4093)', () => { + const serveMap = (services: Record) => { + (kernel as any).getService = vi.fn().mockImplementation((n: string) => services[n] ?? null); + (kernel as any).services = new Map(Object.entries(services)); + }; + + it('advertises /ui and serves it when a protocol with getUiView is registered', async () => { + const protocol = { getUiView: vi.fn().mockResolvedValue({ object: 'account', type: 'list', view: {} }) }; + serveMap({ protocol }); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.routes.ui).toBe('/api/v1/ui'); + expect(info.services.ui.enabled).toBe(true); + expect(info.services.ui.status).toBe('available'); + expect(info.services.ui.handlerReady).toBe(true); + expect(info.services.ui.provider).toBe('metadata-protocol'); + + // The advertised route really answers — same predicate, same fact. + const served = await dispatcher.handleUi('/view/account', {}, { request: {} }); + expect(served.handled).toBe(true); + expect(served.response?.status).toBe(200); + expect(protocol.getUiView).toHaveBeenCalledWith({ object: 'account', type: 'list' }); + }); + + it('does not advertise /ui without a protocol, and names the actual remedy', async () => { + serveMap({}); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.routes.ui).toBeUndefined(); + expect(info.services.ui.enabled).toBe(false); + expect(info.services.ui.handlerReady).toBe(false); + // Not svcUnavailable's "Install a ui plugin" — no such plugin exists. + expect(info.services.ui.message).toContain('MetadataPlugin'); + }); + + it('a ui-slot occupant buys no route: the old dev-boot shape stays un-advertised and un-served', async () => { + // What plugin-dev used to register: a shapeless placeholder in the + // `ui` slot, no protocol anywhere. /ui could only 503 — discovery + // must say so instead of advertising it. + const placeholder = { _serviceName: 'ui', __serviceInfo: { status: 'stub', handlerReady: false, message: 'Dev placeholder' } }; + serveMap({ ui: placeholder }); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.routes.ui).toBeUndefined(); + expect(info.services.ui.enabled).toBe(false); + + const served = await dispatcher.handleUi('/view/account', {}, { request: {} }); + expect(served.handled).toBe(true); + expect(served.response?.status).toBe(503); + }); + + it('a wrong-shaped protocol (no getUiView) is not advertised — mirrors the domain 503', async () => { + serveMap({ protocol: { saveMetaItem: vi.fn() } }); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.routes.ui).toBeUndefined(); + expect(info.services.ui.enabled).toBe(false); + }); + }); + // ═══════════════════════════════════════════════════════════════ // i18n across server/dev/mock environments // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 894c818c1a..f40107d908 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -843,7 +843,7 @@ export class HttpDispatcher { const [ authSvc, searchSvc, realtimeSvc, filesSvc, analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc, - uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc, + protocolSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc, metadataSvc, dataSvc, ] = await Promise.all([ this.resolveService(CoreServiceName.enum.auth), @@ -855,7 +855,11 @@ export class HttpDispatcher { this.resolveService(CoreServiceName.enum.ai), this.resolveService(CoreServiceName.enum.notification), this.resolveService(CoreServiceName.enum.i18n), - this.resolveService(CoreServiceName.enum.ui), + // [#4093] What `/ui` actually reads (domains/ui.ts). The `ui` SLOT + // is vestigial — nothing in the platform registers it (plugin-dev's + // shapeless placeholder was its only occupant, now retired), and + // the domain never consulted it. + this.resolveService('protocol'), this.resolveService(CoreServiceName.enum.automation), this.resolveService(CoreServiceName.enum.cache), this.resolveService(CoreServiceName.enum.queue), @@ -909,7 +913,14 @@ export class HttpDispatcher { const hasAi = isServiceServeable(aiSvc); const hasNotification = isServiceServeable(notificationSvc); const hasI18n = isServiceServeable(i18nSvc); - const hasUi = !!uiSvc; + // Mirrors handleUiRequest's OWN guard byte for byte (domains/ui.ts): + // it 503s unless a `protocol` service with `getUiView` is resolvable — + // the `ui` slot never enters that decision. Gating here on slot + // presence was wrong in both directions: a dev boot with the protocol + // absent advertised a route that could only 503, and a production boot + // (nothing fills the vestigial slot) hid a route that serves fine. + // Same predicate ⇒ same answer (the `hasMcp` pattern below). + const hasUi = typeof protocolSvc?.getUiView === 'function'; const hasAutomation = isServiceServeable(automationSvc); const hasCache = !!cacheSvc; const hasQueue = !!queueSvc; @@ -1099,7 +1110,19 @@ export class HttpDispatcher { cache: hasCache ? svcAvailable(undefined, undefined, cacheSvc) : svcUnavailable('cache'), queue: hasQueue ? svcAvailable(undefined, undefined, queueSvc) : svcUnavailable('queue'), job: hasJob ? svcAvailable(undefined, undefined, jobSvc) : svcUnavailable('job'), - ui: hasUi ? svcAvailable(routes.ui, undefined, uiSvc) : svcUnavailable('ui'), + // [#4093] Reported from what serves it, like the route above: + // `/ui` is a dispatcher domain answered by the `protocol` + // service, so its self-description (none today — MetadataPlugin + // registers a real shim) is the honest source. The vestigial + // `ui` slot is not consulted, matching the domain. The + // unavailable message names the actual remedy — `svcUnavailable` + // would say "install a ui plugin", and no such plugin exists. + ui: hasUi + ? svcAvailable(routes.ui, 'metadata-protocol', protocolSvc) + : { + enabled: false, status: 'unavailable' as const, handlerReady: false, + message: 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', + }, workflow: hasWorkflow ? svcAvailable(routes.workflow, undefined, workflowSvc) : svcUnavailable('workflow'), // Honest entry (ADR-0076 D12, #2462): the registered realtime // service is an in-process event bus with NO mounted HTTP/WS From cd72b6b7cfb96a9a592aa50a7591fd0afcc1f527 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:48:49 +0000 Subject: [PATCH 2/2] docs(plugins): the plugin-dev catalogue entry lists data and ui among the deliberately empty slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift check flagged plugins/packages.mdx for this change, and the #4126 sentence it carries was made stale by it — the never-stubbed list grew. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- content/docs/plugins/packages.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index cc3f1210c5..47770e6bf8 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -346,7 +346,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern **Local Development Plugin** — one-line local stack: wires ObjectQL, the in-memory driver, auth, security, the HTTP server, REST and the dispatcher, then fills any still-unclaimed core service slot with an in-memory dev implementation. -- **Features**: Composes the real plugins above; registers dev implementations for unclaimed slots, each declaring what kind of fake it is (`__serviceInfo` — `degraded` when it really does the work in memory, `stub` when it fabricates). Slots that would fabricate an answer over HTTP get no implementation at all: `analytics` and the three `security.*` handles stay empty on purpose. +- **Features**: Composes the real plugins above; registers dev implementations for unclaimed slots, each declaring what kind of fake it is (`__serviceInfo` — `degraded` when it really does the work in memory, `stub` when it fabricates). Slots whose fake could only mislead get no implementation at all: `analytics`, `data`, `ui` and the three `security.*` handles stay empty on purpose — an empty slot is what production has, and every consumer answers it honestly. - **When to use**: Local development only. **`init()` throws under `NODE_ENV=production`** — set `OS_ALLOW_DEV_PLUGIN=1` only if you deliberately want the dev slate under a production `NODE_ENV`. - **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md)