diff --git a/.changeset/ui-discovery-reads-the-protocol-service.md b/.changeset/ui-discovery-reads-the-protocol-service.md new file mode 100644 index 0000000000..14aa3f13a1 --- /dev/null +++ b/.changeset/ui-discovery-reads-the-protocol-service.md @@ -0,0 +1,11 @@ +--- +'@objectstack/runtime': patch +--- + +Discovery's `/ui` advertisement reads what `/ui` reads: the `protocol` service, not the vestigial `ui` slot (#4093). + +`domains/ui.ts` serves `GET /ui/view/:object` off the `protocol` service and 503s without it; the `ui` core-service slot never enters that decision, and nothing in the platform registers a `ui` service — plugin-dev's shapeless placeholder was its only occupant ever, and ADR-0115 retired it. Gating `routes.ui` on slot presence was therefore wrong in both directions: a dev boot with the placeholder but no protocol advertised a route that could only 503, and every boot without a placeholder — production always, and all dev boots post-ADR-0115 — hid a route that serves fine. + +`routes.ui` and `services.ui` now gate on `typeof protocol?.getUiView === 'function'` — the domain handler's own guard, byte for byte, the same rule the `mcp` advertisement follows. `services.ui` reports the serving implementation (provider `metadata-protocol`, honoring any `__serviceInfo` it declares), and the unavailable message names the actual remedy — register MetadataPlugin (`@objectstack/metadata-protocol`) — instead of "install a ui plugin", which names a plugin that does not exist. + +FROM → TO: `routes.ui` / `services.ui` may newly appear in deployments where the protocol service is registered (the route always served there; discovery just never said so) and newly disappear in protocol-less boots (it never worked there). No handler behavior changes. diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index cb067858b1..2fc8e8b36b 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 — closed by [ADR-0115](./0115-plugin-dev-assembly-not-stub-table.md) (#4137): the question "should the remaining fabricating stubs occupy slots at all" was answered wholesale, not per slot — the stub table is retired as a design, plugin-dev registers no implementations of its own, and every slot it filled is empty in dev exactly as in production. The one fabricator left standing is outside plugin-dev: the dispatcher's own `/auth` mock fallback, #4113. One discovery seam followed in #4146: `routes.ui`/`services.ui` now gate on the `protocol` service's own `getUiView` guard — what `domains/ui.ts` actually reads, the `hasMcp` rule — rather than on the vestigial `ui` slot, which nothing registers in any mode; slot-presence gating had advertised `/ui` for the placeholder in boots where it could only 503, and hidden it in every boot where the protocol really serves it.)* - `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/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0ccfae8c18..cb0bf25a54 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2681,6 +2681,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