diff --git a/.changeset/discovery-subscribable-channel-definition.md b/.changeset/discovery-subscribable-channel-definition.md new file mode 100644 index 0000000000..3fed65a35c --- /dev/null +++ b/.changeset/discovery-subscribable-channel-definition.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/runtime": minor +--- + +`/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + +**A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + +**The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + +**Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + +What to read instead, per case: + +- deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; +- asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + +Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + +New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index dce5ce4489..d9b763e938 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -207,6 +207,15 @@ The `services` map still reports a registered stub as it to `unavailable` — "something is in this slot, and it is a fake" says more than "install a plugin". +Exactly one slot answers `enabled` by a different question, and the exception is +declared rather than incidental: `realtime` is a **channel slot** +(`CHANNEL_SURFACE_SLOTS`, `@objectstack/spec/api`), so its `enabled` is +`isSubscribableChannel()` — `handlerReady: true` **and** a connectable route — instead of +"the slot is filled" (#14646). A registered realtime stub therefore reads +`{ enabled: false, status: "stub", handlerReady: false }`. `status` still carries the +"something is in this slot" half, which is why the entry is not collapsed to +`unavailable`. Every other slot follows the sentence above. + --- ## 2. data Service ✅ Implemented @@ -374,13 +383,19 @@ a self-declared stub answers as an empty one. `service-realtime` is an **in-process pub/sub bus**, not an HTTP/WS surface. The -dispatcher has no `/realtime` branch and no plugin mounts one, so `routes.realtime` -is **never advertised** — an advertised route would 404 (ADR-0076 D12, #2462), and -`features.websockets` is hardcoded `false` for the same reason. These six -`RealtimeProtocol` members are declared and unrouted; when the service is registered -both discovery builders report the slot `degraded` with a message saying the bus is -in-process only and no HTTP/WS surface is mounted. Re-advertising waits on a real -transport. +dispatcher has no `/realtime` branch and no plugin in the open framework mounts one, so +`routes.realtime` is **not advertised** — an advertised route would 404 (ADR-0076 D12, +#2462). These six `RealtimeProtocol` members are declared and unrouted; when the service +is registered both discovery builders report the slot `enabled: false` / `degraded`, with +a message saying the bus is in-process only and no HTTP/WS surface is mounted. + +`enabled: false` for a slot that IS registered is deliberate (#14646): `realtime` is the +one **channel slot**, so its `enabled` — and `capabilities.websockets` with it — is +`isSubscribableChannel()` (`@objectstack/spec/api`): `handlerReady: true` **and** a +connectable route. It used to read `true` beside that same "no surface is mounted" +message, and a client keying on it subscribed to nothing. Advertising resumes by itself +if an occupant ever names a mounted path (`IRealtimeService.getChannelRoute()`); under the +2026-09-04 ruling nothing in the open framework does. ### 8. notification — 7 methods · `@objectstack/service-messaging` @@ -522,7 +537,7 @@ a package that cannot be installed is a dead end, which is why | **ui** | Nothing registers the slot. `ViewProtocol`'s five declared-and-unrouted methods were **retired in v17** (#6239); view CRUD runs through `/api/v1/meta`, and `/api/v1/ui/view/:object` is served by the `protocol` service. | | **search** | Nothing ships. Contract and engine enum exist in `@objectstack/spec` only. | | **ai** | Nothing in this repo — `service-ai` (chat, completion, models, conversations) is Cloud/EE. | -| **realtime transport** | The service exists but no WebSocket/SSE route is mounted, so `routes.realtime` is deliberately never advertised. | +| **realtime transport** | The service exists but no WebSocket/SSE route is mounted, so `routes.realtime` is not advertised and `services.realtime.enabled` / `capabilities.websockets` are `false` — the one definition of a subscribable channel (#14646). | The `workflow` slot used to sit in this table ("nothing ships, no consumer"). It was retired outright in v17 (#4451, per ADR-0115 Evidence 5): the @@ -578,8 +593,11 @@ When a plugin registers a service, the discovery endpoint automatically updates: `__serviceInfo`, which is reported verbatim instead) - `routes.auth` → `"/api/v1/auth"` appears in routes - `features` flags follow for the slots that have one — `search`, `files`, - `analytics`, `ai`, `workflow`, `notifications`, `i18n` (`websockets` is hardcoded - `false`; there is no `features.auth`) + `analytics`, `ai`, `workflow`, `notifications`, `i18n` (`websockets` does **not** + follow slot presence: it is `isSubscribableChannel(services.realtime)` — `handlerReady: + true` **and** a connectable route — so registering a realtime service does not flip it, + and it reads `false` on every host the open framework ships, #14646; there is no + `features.auth`) --- diff --git a/content/docs/protocol/kernel/realtime-protocol.mdx b/content/docs/protocol/kernel/realtime-protocol.mdx index b7ef312e41..168967bcfd 100644 --- a/content/docs/protocol/kernel/realtime-protocol.mdx +++ b/content/docs/protocol/kernel/realtime-protocol.mdx @@ -102,7 +102,7 @@ ObjectStack supports two real-time protocols: ### Connection Endpoint -The discovery endpoint reports the realtime service honestly (ADR-0076 D12): because the in-process realtime service mounts **no** HTTP/WS surface today, **no `routes.realtime` entry is advertised** — an advertised route with no handler would 404. The service itself appears in `services.realtime` as `degraded` with `handlerReady: false` when registered: +The discovery endpoint reports the realtime service honestly (ADR-0076 D12): because the in-process realtime service mounts **no** HTTP/WS surface today, **no `routes.realtime` entry is advertised** — an advertised route with no handler would 404 — and `services.realtime` reports `enabled: false`: ```http GET /.well-known/objectstack @@ -116,17 +116,50 @@ GET /.well-known/objectstack }, "services": { "realtime": { - "enabled": true, + "enabled": false, "status": "degraded", "handlerReady": false, "message": "In-process event bus only — no HTTP/WS realtime surface is mounted" } + }, + "capabilities": { + "websockets": { "enabled": false } } } ``` +### What counts as a subscribable channel + +**A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel".** + +That is the whole definition, and it is computed in exactly one place — +`isSubscribableChannel()` in `@objectstack/spec/api`. Both discovery producers set +`services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the +fields a client reads and the predicate a client is told to use are the same computation +and cannot disagree. + +`realtime` is the one slot whose advertised capability *is* such a channel, so it is also +the one slot whose `enabled` answers that question. Everywhere else `enabled` keeps its +narrower meaning — `cache`, `queue` and `job` deliver their whole contract in-process, so +they are honestly enabled with no route at all and there is nothing to subscribe to there +either. + +The route comes from the occupant (`IRealtimeService.getChannelRoute()`), because no +producer in the open framework mounts a realtime transport and neither discovery builder +can honestly invent one. `@objectstack/service-realtime` is an in-process pub/sub bus and +names none, which is why every host the open framework ships answers `false`. + + + ⛔ **Do not key a subscription off `enabled` alone.** Until this was written down, + `/discovery` reported `enabled: true` for `realtime` *and* "no HTTP/WS realtime surface is + mounted" in the same entry — both true, because `enabled` meant "the slot is filled". A + client that read it as "a channel exists" subscribed to nothing and silently lost the + feature it was subscribing for: no error, no failed request, no signal at all. Poll, or + degrade, unless `handlerReady` is `true` **and** a `route` is present. + + - A WebSocket upgrade endpoint is part of the planned transport (`IRealtimeService.handleUpgrade()`) and is not yet served. When it lands, discovery will advertise `routes.realtime` again — until then clients must treat `services.realtime.handlerReady: false` as "no wire transport" (see #2462). + A WebSocket upgrade endpoint is part of the planned transport (`IRealtimeService.handleUpgrade()`) and is not yet served — realtime stays out of the open framework (maintainer ruling, 2026-09-04). If a host ever mounts one, its realtime service names the mounted path via `getChannelRoute()` and discovery advertises `routes.realtime`, `handlerReady: true` and `capabilities.websockets` in the same step (see #2462, #14646). ### Establishing Connection diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index bb4273fa11..0465e3254e 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -39,7 +39,7 @@ const result = ApiRoutesSchema.parse(data); | **datasources** | `string` | optional | e.g. /api/v1/datasources — base for the datasources/:name/external/* federation-admin family; absent when no host mounts it | | **email** | `string` | optional | e.g. /api/v1/email — base for the email/send endpoint; absent when no host mounts it | | **approvals** | `string` | optional | e.g. /api/v1/approvals | -| **realtime** | `string` | optional | e.g. /api/v1/realtime | +| **realtime** | `string` | optional | e.g. /api/v1/realtime — present only when a realtime transport is actually mounted | | **notifications** | `string` | optional | e.g. /api/v1/notifications | | **ai** | `string` | optional | e.g. /api/v1/ai | | **i18n** | `string` | optional | e.g. /api/v1/i18n | @@ -94,7 +94,7 @@ const result = ApiRoutesSchema.parse(data); | **datasources** | `string` | optional | e.g. /api/v1/datasources — base for the datasources/:name/external/* federation-admin family; absent when no host mounts it | | **email** | `string` | optional | e.g. /api/v1/email — base for the email/send endpoint; absent when no host mounts it | | **approvals** | `string` | optional | e.g. /api/v1/approvals | -| **realtime** | `string` | optional | e.g. /api/v1/realtime | +| **realtime** | `string` | optional | e.g. /api/v1/realtime — present only when a realtime transport is actually mounted | | **notifications** | `string` | optional | e.g. /api/v1/notifications | | **ai** | `string` | optional | e.g. /api/v1/ai | | **i18n** | `string` | optional | e.g. /api/v1/i18n | @@ -104,7 +104,7 @@ const result = ApiRoutesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | | +| **enabled** | `boolean` | ✅ | Whether the slot is filled by something this host delivers. NOT "a channel exists": subscribing requires handlerReady:true AND a connectable route (isSubscribableChannel). | | **status** | `Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>` | ✅ | available = fully operational, registered = route declared but handler unverified, unavailable = not installed, degraded = partial, stub = placeholder that returns 501 | | **handlerReady** | `boolean` | optional | Whether the HTTP handler is confirmed to be mounted. Omitted = readiness unknown/unverified; true = handler mounted; false = handler missing or stub (likely 501). | | **route** | `string` | optional | e.g. /api/v1/analytics | @@ -124,7 +124,7 @@ const result = ApiRoutesSchema.parse(data); | **export** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports chunked (multipart) uploads | | **transactionalBatch** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, /ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | -| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12). | +| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. Derived from isSubscribableChannel(services.realtime): handlerReady true AND a connectable route. | | **files** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | | **analytics** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the analytics / BI query surface | | **ai** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | @@ -214,7 +214,7 @@ Deployment posture a discovery response advertises. Deliberately three coarse bu | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | | +| **enabled** | `boolean` | ✅ | Whether the slot is filled by something this host delivers. NOT "a channel exists": subscribing requires handlerReady:true AND a connectable route (isSubscribableChannel). | | **status** | `Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>` | ✅ | available = fully operational, registered = route declared but handler unverified, unavailable = not installed, degraded = partial, stub = placeholder that returns 501 | | **handlerReady** | `boolean` | optional | Whether the HTTP handler is confirmed to be mounted. Omitted = readiness unknown/unverified; true = handler mounted; false = handler missing or stub (likely 501). | | **route** | `string` | optional | e.g. /api/v1/analytics | @@ -278,7 +278,7 @@ Well-known capability flags for frontend intelligent adaptation | **export** | `boolean` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `boolean` | ✅ | Whether the backend supports chunked (multipart) uploads | | **transactionalBatch** | `boolean` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, /ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | -| **websockets** | `boolean` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12). | +| **websockets** | `boolean` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. Derived from isSubscribableChannel(services.realtime): handlerReady true AND a connectable route. | | **files** | `boolean` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | | **analytics** | `boolean` | ✅ | Whether the backend serves the analytics / BI query surface | | **ai** | `boolean` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a1914be5d7..3c819c1499 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -970,7 +970,7 @@ Enable package response | **datasources** | `string` | optional | e.g. /api/v1/datasources — base for the datasources/:name/external/* federation-admin family; absent when no host mounts it | | **email** | `string` | optional | e.g. /api/v1/email — base for the email/send endpoint; absent when no host mounts it | | **approvals** | `string` | optional | e.g. /api/v1/approvals | -| **realtime** | `string` | optional | e.g. /api/v1/realtime | +| **realtime** | `string` | optional | e.g. /api/v1/realtime — present only when a realtime transport is actually mounted | | **notifications** | `string` | optional | e.g. /api/v1/notifications | | **ai** | `string` | optional | e.g. /api/v1/ai | | **i18n** | `string` | optional | e.g. /api/v1/i18n | @@ -980,7 +980,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | | +| **enabled** | `boolean` | ✅ | Whether the slot is filled by something this host delivers. NOT "a channel exists": subscribing requires handlerReady:true AND a connectable route (isSubscribableChannel). | | **status** | `Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>` | ✅ | available = fully operational, registered = route declared but handler unverified, unavailable = not installed, degraded = partial, stub = placeholder that returns 501 | | **handlerReady** | `boolean` | optional | Whether the HTTP handler is confirmed to be mounted. Omitted = readiness unknown/unverified; true = handler mounted; false = handler missing or stub (likely 501). | | **route** | `string` | optional | e.g. /api/v1/analytics | @@ -1000,7 +1000,7 @@ Enable package response | **export** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports chunked (multipart) uploads | | **transactionalBatch** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, /ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | -| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12). | +| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. Derived from isSubscribableChannel(services.realtime): handlerReady true AND a connectable route. | | **files** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | | **analytics** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the analytics / BI query surface | | **ai** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | diff --git a/packages/metadata-protocol/src/discovery-realtime-channel.pin.test.ts b/packages/metadata-protocol/src/discovery-realtime-channel.pin.test.ts new file mode 100644 index 0000000000..a992101c9d --- /dev/null +++ b/packages/metadata-protocol/src/discovery-realtime-channel.pin.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14646] `/discovery` stops advertising a realtime service that has no + * mounted surface — the `getDiscovery()` producer's half. This is the producer + * behind `GET /api/v1/discovery` on a REST host (`registerDiscoveryEndpoints` + * in `@objectstack/rest`), i.e. the document the showcase boot actually served + * when the defect was measured; `packages/runtime` carries the same pins for + * the dispatcher producer. + * + * The reported document said `enabled: true` for `realtime` and, in the same + * entry, "In-process event bus only — no HTTP/WS realtime surface is mounted". + * Both were true: `enabled` meant "the slot is filled", which for an in-process + * pub/sub bus says nothing about whether anything is listening on the wire. A + * client keying on it subscribes to nothing and silently loses the feature it + * subscribed for. + * + * Maintainer ruling A (2026-09-04): realtime stays out of open core, discovery + * retracts the claim, and "what counts as a subscribable channel" becomes ONE + * explicit definition — `isSubscribableChannel` (`@objectstack/spec/api`): + * `handlerReady: true` AND a connectable `route`. Both directions are pinned + * here on purpose: a fix pinned only from the negative end would be + * indistinguishable from "never advertise realtime", which is another hardcode + * rather than a definition. + */ + +import { describe, it, expect } from 'vitest'; +import { isSubscribableChannel } from '@objectstack/spec/api'; +import type { IRealtimeService } from '@objectstack/spec/contracts'; +import { ObjectStackProtocolImplementation } from './index.js'; + +/** Same minimal engine `discovery-schema-conformance.test.ts` uses. */ +function makeImpl(services: Map) { + const engine = { + registry: { getObject: (_n: string) => undefined, getRegisteredTypes: () => [] }, + }; + return new ObjectStackProtocolImplementation(engine as any, () => services); +} + +/** + * The shape `RealtimeServicePlugin` registers: an in-process pub/sub bus that + * names no channel route. (The authoritative reading — that the SHIPPED + * `InMemoryRealtimeAdapter` really names none — is pinned in + * `@objectstack/service-realtime`'s own suite and, against the real adapter, in + * `packages/runtime`; this package cannot import it without a dependency + * inversion.) + */ +const inProcessBus: IRealtimeService = { + publish: async () => {}, + subscribe: async () => 'sub_1', + unsubscribe: async () => {}, +}; + +/** An occupant that really mounts a client-facing channel and says where. */ +const mountedChannel: IRealtimeService = { + ...inProcessBus, + getChannelRoute: () => '/api/v1/realtime', +}; + +describe('[#14646] discovery and the one definition of a subscribable channel (getDiscovery producer)', () => { + it('does NOT advertise an in-process realtime bus as a channel', async () => { + const discovery: any = await makeImpl(new Map([['realtime', inProcessBus]])).getDiscovery(); + const realtime = discovery.services.realtime; + + expect(realtime.enabled).toBe(false); + // Informative, not collapsed to `unavailable`: something IS registered and + // works in-process — it just serves no wire (contrast the kernel-internal + // slots, whose in-process contract IS the whole capability, #4318). + expect(realtime.status).toBe('degraded'); + expect(realtime.handlerReady).toBe(false); + expect(realtime.route).toBeUndefined(); + expect(realtime.message).toContain('no HTTP/WS realtime surface is mounted'); + + expect(discovery.routes.realtime).toBeUndefined(); + expect(discovery.capabilities.websockets.enabled).toBe(false); + }); + + it('DOES advertise a realtime occupant that mounts a channel', async () => { + const discovery: any = await makeImpl(new Map([['realtime', mountedChannel]])).getDiscovery(); + const realtime = discovery.services.realtime; + + expect(realtime.enabled).toBe(true); + expect(realtime.status).toBe('available'); + expect(realtime.handlerReady).toBe(true); + expect(realtime.route).toBe('/api/v1/realtime'); + expect(realtime.message).toBeUndefined(); + + expect(discovery.routes.realtime).toBe('/api/v1/realtime'); + expect(discovery.capabilities.websockets.enabled).toBe(true); + }); + + it('reports an absent realtime slot as unavailable', async () => { + const discovery: any = await makeImpl(new Map()).getDiscovery(); + + expect(discovery.services.realtime.enabled).toBe(false); + expect(discovery.services.realtime.status).toBe('unavailable'); + expect(discovery.routes.realtime).toBeUndefined(); + expect(discovery.capabilities.websockets.enabled).toBe(false); + }); + + it('answers `enabled`, `routes.realtime` and `capabilities.websockets` with the SAME predicate', async () => { + for (const occupant of [inProcessBus, mountedChannel, undefined]) { + const services = new Map(); + if (occupant) services.set('realtime', occupant); + const discovery: any = await makeImpl(services).getDiscovery(); + + const verdict = isSubscribableChannel(discovery.services.realtime); + expect(discovery.services.realtime.enabled, 'services.realtime.enabled').toBe(verdict); + expect(discovery.capabilities.websockets.enabled, 'capabilities.websockets').toBe(verdict); + expect(discovery.routes.realtime !== undefined, 'routes.realtime').toBe(verdict); + } + }); + + it('leaves the kernel-internal slots alone — no route is not the same as no channel', async () => { + // The predicate is applied per slot, deliberately. `cache` delivers its + // whole contract in-process (#4318), so it stays honestly `enabled` with no + // route; only a slot whose advertised capability IS a channel answers + // `enabled` with `isSubscribableChannel`. Without this, "make discovery + // truthful" would have read as "enabled means a route exists" and quietly + // retracted three working services. + const discovery: any = await makeImpl(new Map([['cache', { get: async () => undefined }]])).getDiscovery(); + + expect(discovery.services.cache.enabled).toBe(true); + expect(discovery.services.cache.handlerReady).toBe(false); + expect(discovery.services.cache.route).toBeUndefined(); + expect(isSubscribableChannel(discovery.services.cache)).toBe(false); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 448267cdff..858ce0cce5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -71,7 +71,7 @@ import type { } from '@objectstack/spec/api'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; -import { readServiceSelfInfo, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; +import { readServiceSelfInfo, readChannelRoute, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, @@ -3428,6 +3428,19 @@ const SERVICE_CONFIG: Record // Check realtime — honest capabilities (ADR-0076 D12, #2462): the // realtime service is an in-process bus with NO HTTP surface, so it is - // registered/enabled but degraded, with no advertised route (a route - // would 404). - expect(discovery.services.realtime.enabled).toBe(true); + // registered but degraded, with no advertised route (a route would 404). + // + // [#14646] `enabled` is `false` here, and that is the substance of the + // change rather than a cosmetic flip: `realtime` is a CHANNEL SLOT, so its + // `enabled` is `isSubscribableChannel` — `handlerReady: true` AND a + // connectable route — not "the slot is filled". It used to read `true` + // beside this entry's own "no HTTP/WS surface is mounted" message, both + // true at once, and a console client keying on it subscribed to nothing. + // The slot is still REGISTERED, which is what `status: 'degraded'` and the + // message below go on saying. Full pins: + // `metadata-protocol/src/discovery-realtime-channel.pin.test.ts`. + expect(discovery.services.realtime.enabled).toBe(false); expect(discovery.services.realtime.status).toBe('degraded'); expect(discovery.services.realtime.handlerReady).toBe(false); expect(discovery.services.realtime.route).toBeUndefined(); diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index d2e30617c9..9e920afbb3 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -450,7 +450,18 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ reachable: 0, blindSpot: 0, populationRule: 'HTTP route mounts in this file', - controls: { RealtimeService: 10, 'async init(': 1 }, + // ⚠️ `RealtimeService` read 10 until #14646 added a comment to that file + // recording why its occupant names no discovery channel route. The pattern + // is a bare `/RealtimeService/g`, so it matches inside `IRealtimeService` + // and PROSE about the symbol moves the symbol's count exactly as code + // does — the mirror image of a retirement whose count goes UP because the + // codebase started documenting an absence. Re-measured here rather than + // reworded there: this control's job is to prove the file is still present + // and readable (the non-zero assertion), and shrinking a comment to hold a + // counter still is how the documentation gets worse to keep a number. + // Nothing else in the row moves — the file still mounts no HTTP route, so + // population / reachable / blindSpot / keys stay 0. + controls: { RealtimeService: 11, 'async init(': 1 }, // The designed-silence decision is the #2992 realtime-transport tripwire record. note: 'Tripwire only. Zero keys is the designed reading: no end-user realtime transport is wired.', }, diff --git a/packages/runtime/src/discovery-realtime-channel.pin.test.ts b/packages/runtime/src/discovery-realtime-channel.pin.test.ts new file mode 100644 index 0000000000..5768284fa9 --- /dev/null +++ b/packages/runtime/src/discovery-realtime-channel.pin.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14646] `/discovery` stops advertising a realtime service that has no + * mounted surface — the dispatcher producer's half. + * + * ## What was wrong, and why flipping a boolean would not have fixed it + * + * On a stock boot this document reported the `realtime` slot as + * `enabled: true` **and** carried the message "In-process event bus only — no + * HTTP/WS realtime surface is mounted", with no `routes.realtime` entry. Both + * statements were true: `enabled` meant "the slot is filled". So the field had + * two meanings, and a console client keying on it to subscribe would subscribe + * to nothing and silently lose its inbox bell — no error, no red, no signal. + * + * Maintainer ruling A (2026-09-04, director summon #14): realtime stays out of + * open core, discovery stops advertising an unmounted realtime service, and + * **"what counts as a subscribable channel" becomes ONE explicit definition**. + * That definition is `isSubscribableChannel` in `@objectstack/spec/api`: + * `handlerReady: true` AND a connectable `route`. This file pins it from both + * ends, because a fix pinned only from the negative end is indistinguishable + * from "never advertise realtime" — which would be a second hardcode, not a + * definition. + */ + +import { describe, it, expect } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { isSubscribableChannel } from '@objectstack/spec/api'; +import type { IRealtimeService } from '@objectstack/spec/contracts'; + +const PREFIX = '/api/v1'; + +/** + * The shape a stock boot registers: an in-process pub/sub bus that names no + * channel route. + * + * ⭐ Deliberately a stand-in rather than an import of the real + * `InMemoryRealtimeAdapter`, and the reason is structural rather than + * stylistic. Reaching for the real class made `@objectstack/runtime` + * type-resolve `@objectstack/service-realtime` through its `dist/*.d.ts`, and + * three of this repo's own ratchets refuse that from three directions: + * `check:type-source-resolution` reds on the dist-resolved type import; its + * registry is SHRINK-ONLY and its re-baseline limb is open only to a change + * that ONBOARDED the program (this package's `typecheck` script already named + * `tsconfig.test.json`, so it did not); and the mandated `paths` remedy pulls + * that package's file graph into a program whose `rootDir` is `./src`, which + * `tsconfig.test.json` states it will not widen — 13 `TS6059` billed to a + * ledger `service-realtime` cannot see, the same shape PR #12570 measured. + * + * ⚠️ What this file therefore STOPPED proving, stated because a pin that still + * passes while proving less is a real cost: the first case below no longer + * evaluates the SHIPPED adapter. Measured, one mutation, two pins — + * `InMemoryRealtimeAdapter` given a `getChannelRoute()` returning + * `/api/v1/realtime`: `no-channel-route.pin.test.ts` goes RED (2 of its 3 + * cases), and THIS file stays GREEN on all 4. So if the shipped occupant ever + * starts naming a channel, the red arrives next door and never here. + * + * That is the whole of the loss, and it is covered rather than merely moved: + * the claim about the SHIPPED occupant is not this file's to make. It is pinned + * against the real class, in the package that owns it, by + * `packages/services/service-realtime/src/no-channel-route.pin.test.ts` — the + * pin the mutation above proves has teeth. That one pins WHAT THE SHIPPED + * OCCUPANT NAMES; this one pins that the PRODUCER derives its answer from + * whatever an occupant names. Together they compose to the stock-boot reading, + * and neither can go green by accident of the other. + * + * The stand-in is equivalent to the real adapter only on the two reads either + * producer performs on an occupant — `readChannelRoute` and + * `readServiceSelfInfo` — and that equivalence was measured (both `undefined` + * on both objects) rather than assumed. It is a statement about today, which is + * exactly why the shipped-occupant claim lives next door instead of here. + */ +const inProcessBus: IRealtimeService = { + publish: async () => {}, + subscribe: async () => 'sub_1', + unsubscribe: async () => {}, +}; + +/** A dispatcher whose kernel resolves exactly the one slot under test. */ +function dispatcherWithRealtime(realtime: unknown): HttpDispatcher { + const kernel = { + context: { getService: () => null }, + getService: (name: string) => (name === 'realtime' ? realtime : null), + } as any; + return new HttpDispatcher(kernel); +} + +/** + * A realtime occupant that really mounts a client-facing channel: it names the + * path a host has put it at, which is the producer half of the definition + * (`IRealtimeService.getChannelRoute`). Nothing in the open framework is this + * — under ruling A nothing ever will be — which is exactly why the positive + * case has to be composed here. + */ +const mountedChannel: IRealtimeService = { + ...inProcessBus, + getChannelRoute: () => `${PREFIX}/realtime`, +}; + +describe('[#14646] discovery and the one definition of a subscribable channel (dispatcher producer)', () => { + it('does NOT advertise an in-process realtime bus as a channel', async () => { + const info = await dispatcherWithRealtime(inProcessBus).getDiscoveryInfo(PREFIX); + const realtime = info.services.realtime; + + // The retraction the ruling asks for: `enabled` no longer says "the + // slot is filled" for this slot, it says "there is a channel". + expect(realtime.enabled).toBe(false); + // …and the entry stays informative rather than collapsing to + // `unavailable`: something IS registered, it just serves no wire. + expect(realtime.status).toBe('degraded'); + expect(realtime.handlerReady).toBe(false); + expect(realtime.route).toBeUndefined(); + expect(realtime.message).toContain('no HTTP/WS realtime surface is mounted'); + + // Nothing to connect to, said in every place the document says it. + expect(info.routes.realtime).toBeUndefined(); + expect(info.capabilities.websockets.enabled).toBe(false); + }); + + it('DOES advertise a realtime occupant that mounts a channel', async () => { + const info = await dispatcherWithRealtime(mountedChannel).getDiscoveryInfo(PREFIX); + const realtime = info.services.realtime; + + expect(realtime.enabled).toBe(true); + expect(realtime.status).toBe('available'); + expect(realtime.handlerReady).toBe(true); + expect(realtime.route).toBe(`${PREFIX}/realtime`); + // The "no surface" sentence is only true while there is no surface. + expect(realtime.message).toBeUndefined(); + + expect(info.routes.realtime).toBe(`${PREFIX}/realtime`); + expect(info.capabilities.websockets.enabled).toBe(true); + }); + + it('reports an absent realtime slot as unavailable, not as a silent channel', async () => { + const info = await dispatcherWithRealtime(null).getDiscoveryInfo(PREFIX); + + expect(info.services.realtime.enabled).toBe(false); + expect(info.services.realtime.status).toBe('unavailable'); + expect(info.routes.realtime).toBeUndefined(); + expect(info.capabilities.websockets.enabled).toBe(false); + }); + + it('answers `enabled` and `capabilities.websockets` with the SAME predicate', async () => { + // The point of the definition. Two fields answering one question used + // to be two constants that happened to agree; now both are + // `isSubscribableChannel` over the same entry, so no composition can + // make them disagree — including one this file did not think of. + for (const occupant of [inProcessBus, mountedChannel, null]) { + const info = await dispatcherWithRealtime(occupant).getDiscoveryInfo(PREFIX); + const verdict = isSubscribableChannel(info.services.realtime); + expect(info.services.realtime.enabled, 'services.realtime.enabled').toBe(verdict); + expect(info.capabilities.websockets.enabled, 'capabilities.websockets').toBe(verdict); + // ADR-0076 D12's other half: advertise only what is mounted. + expect(info.routes.realtime !== undefined, 'routes.realtime').toBe(verdict); + } + }); +}); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index b19b97cec0..3380acba73 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2935,7 +2935,14 @@ describe('HttpDispatcher', () => { // No HTTP/WS surface exists — a discovery-advertised route would 404. expect(info.routes.realtime).toBeUndefined(); expect(info.capabilities.websockets.enabled).toBe(false); - expect(info.services.realtime.enabled).toBe(true); + // [#14646] `enabled` is `false`: `realtime` is a CHANNEL SLOT, so + // the field is `isSubscribableChannel` (handlerReady AND a route), + // not "the slot is filled". It read `true` beside this entry's own + // "no HTTP/WS surface is mounted" message — both true, one field, + // two meanings — and a client keying on it subscribed to nothing. + // `status: 'degraded'` still says the slot IS occupied. Full pins: + // `discovery-realtime-channel.pin.test.ts`. + expect(info.services.realtime.enabled).toBe(false); expect(info.services.realtime.status).toBe('degraded'); expect(info.services.realtime.handlerReady).toBe(false); // …and a /realtime request indeed has no handler diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index ef14d48865..8d6c737bbb 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -13,7 +13,8 @@ import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } f import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { PrimaryDatasourceVerdict } from '@objectstack/objectql'; -import { readServiceSelfInfo, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; +import { readServiceSelfInfo, readChannelRoute, isSubscribableChannel, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; +import type { ServiceInfo } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; import { resolveRuntimeVersion } from './runtime-version.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -1485,6 +1486,13 @@ export class HttpDispatcher { // advertising on mere service presence would still over-promise when a // wrong-shaped service is registered. Same predicate ⇒ same answer. const hasMcp = typeof mcpSvc?.handleHttpRequest === 'function'; + // [#14646] The realtime slot's route is the one its OCCUPANT names + // (`IRealtimeService.getChannelRoute`), because this dispatcher has no + // `/realtime` branch to mirror and never will under the 2026-09-04 + // ruling — so unlike every predicate above there is no domain guard to + // read. `undefined` on every host the open framework ships: + // `service-realtime` is an in-process pub/sub bus and names no channel. + const realtimeChannelRoute = realtimeSvc ? readChannelRoute(realtimeSvc) : undefined; // Routes are only exposed when a plugin provides the service const routes = { @@ -1501,12 +1509,18 @@ export class HttpDispatcher { // `workflow` removed (#4451, v17): the slot retired — nothing // ever registered it and this dispatcher never had a /workflow // branch, so the advertisement could never come true. - // Never advertised (ADR-0076 D12, #2462): service-realtime is an - // in-process pub/sub bus — the dispatcher has no /realtime branch - // and no plugin mounts one, so an advertised route would 404. - // Re-add only when a real HTTP/WS surface exists (and then it must - // pass through the shouldDenyAnonymous gate, #2567). - realtime: undefined, + // Advertised only when the occupant names a mounted channel + // (ADR-0076 D12, #2462): service-realtime is an in-process + // event bus — this dispatcher has no /realtime branch and no + // plugin in the open framework mounts one, so an advertised + // route would 404. [#14646] The hardcoded `undefined` this + // replaces was right for every host that ships and unfalsifiable + // for every other: it made "discovery never advertises realtime" + // indistinguishable from "discovery advertises the realtime that + // is mounted", which is the difference the fix has to keep. A + // host that really mounts one must still pass the + // shouldDenyAnonymous gate (#2567). + realtime: realtimeChannelRoute, notifications: hasNotification ? `${prefix}/notifications` : undefined, ai: hasAi ? `${prefix}/ai` : undefined, i18n: hasI18n ? `${prefix}/i18n` : undefined, @@ -1588,6 +1602,45 @@ export class HttpDispatcher { // Self-description of the registered realtime service, if any (D12). const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : undefined; + // [#14646] The realtime entry, built here rather than inline below + // because `capabilities.websockets` is the SAME question and must be + // the same answer — it is derived from this object further down. + // + // `realtime` is the one CHANNEL SLOT (`CHANNEL_SURFACE_SLOTS`, + // `@objectstack/spec/api`): `enabled` is `isSubscribableChannel` + // evaluated on the entry a consumer reads — `handlerReady: true` AND a + // connectable route — not "the slot is filled". Under the old reading + // this document said `enabled: true` beside "no HTTP/WS realtime + // surface is mounted", both true, and a console client keying on + // `enabled` subscribed to nothing and silently lost its bell. + // Annotated `ServiceInfo` rather than left to inference: the two arms + // below are structurally different objects (only one carries `route`), + // and an inferred union of them is not the declared wire shape — a + // consumer reading `services.realtime.route` off this document would be + // told the key does not exist. + const realtimeEntry: ServiceInfo = realtimeSvc + ? (() => { + const handlerReady = realtimeChannelRoute !== undefined; + return { + enabled: isSubscribableChannel({ handlerReady, route: realtimeChannelRoute }), + // A mounted channel makes an unmarked occupant plainly + // `available`; without one the honest report is `degraded` + // — for THIS slot the advertised capability IS the missing + // surface (contrast the kernel-internal slots, #4318). + status: realtimeSelf?.status + ?? (handlerReady ? ('available' as const) : ('degraded' as const)), + handlerReady, + route: realtimeChannelRoute, + // The "no surface" sentence is only true while there is no + // surface. + message: realtimeSelf?.message + ?? (handlerReady + ? undefined + : 'In-process event bus only — no HTTP/WS realtime surface is mounted'), + }; + })() + : svcUnavailable('realtime'); + // Self-description of whatever fills the `metadata` slot (D12, #4089). const metadataSelf = metadataSvc ? readServiceSelfInfo(metadataSvc) : undefined; @@ -1732,10 +1785,15 @@ export class HttpDispatcher { // mounted; then the guard applies and the question answers // itself (#7602 option 2). search: { enabled: false }, - // No WS/HTTP realtime surface is mounted anywhere — a mere - // in-process realtime service must not advertise websockets - // (ADR-0076 D12, #2462). - websockets: { enabled: false }, + // [#14646] Derived, not stated. This flag and `services.realtime` + // answer one question — "is there a channel to subscribe to?" + // — so it is the shared predicate applied to that very entry, + // and a host that mounts a transport flips both in one step. A + // literal `false` beside an entry that said `enabled: true` is + // not agreement, it is two places to forget. False on every host + // the open framework ships: a mere in-process realtime service + // must not advertise websockets (ADR-0076 D12, #2462). + websockets: { enabled: isSubscribableChannel(realtimeEntry) }, files: { enabled: hasFiles }, analytics: { enabled: hasAnalytics }, ai: { enabled: hasAi }, @@ -1877,16 +1935,11 @@ export class HttpDispatcher { // it could only ever report `unavailable`. // Honest entry (ADR-0076 D12, #2462): the registered realtime // service is an in-process event bus with NO mounted HTTP/WS - // surface — report it degraded with handlerReady:false (or as + // surface — reported degraded with handlerReady:false (or as // the stub it declares itself to be), never as an available - // HTTP capability with a route that would 404. - realtime: realtimeSvc ? { - enabled: true, - status: realtimeSelf?.status ?? ('degraded' as const), - handlerReady: false, - message: realtimeSelf?.message - ?? 'In-process event bus only — no HTTP/WS realtime surface is mounted', - } : svcUnavailable('realtime'), + // HTTP capability with a route that would 404. [#14646] Built + // above, because `capabilities.websockets` is derived from it. + realtime: realtimeEntry, // Presence-gated for the same reason `analytics` is (#4058). notification: notificationRegistered ? svcAvailable(routes.notifications, undefined, notificationSvc) : svcUnavailable('notification'), ai: aiRegistered ? svcAvailable(routes.ai, undefined, aiSvc) : svcUnavailable('ai'), diff --git a/packages/services/service-realtime/src/no-channel-route.pin.test.ts b/packages/services/service-realtime/src/no-channel-route.pin.test.ts new file mode 100644 index 0000000000..4e6c1d753d --- /dev/null +++ b/packages/services/service-realtime/src/no-channel-route.pin.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14646] The shipped realtime occupant names NO channel route — the fact + * discovery reports. + * + * `realtime` is a channel slot (`CHANNEL_SURFACE_SLOTS`, `@objectstack/spec/api`): + * both discovery producers derive `services.realtime.enabled`, its + * `route`/`handlerReady` and `capabilities.websockets` from + * `isSubscribableChannel` over the route the occupant names via + * `IRealtimeService.getChannelRoute()`. This adapter is an in-process pub/sub + * bus with no wire surface, so it names none and discovery advertises no + * channel — the retraction maintainer ruling A (2026-09-04) asks for, computed + * from this implementation instead of hardcoded in two builders. + * + * ⛔ This pin is what makes that a decision rather than an omission. Adding + * `getChannelRoute()` here would flip `/discovery` to advertising a realtime + * channel platform-wide — and under the ruling realtime stays out of open core, + * so a route named here with nothing serving it is exactly the + * `declared ≠ enforced` defect the card closed. If a transport ever ships, this + * pin is the place the decision is re-taken, in the open. + */ + +import { describe, it, expect } from 'vitest'; +import { isSubscribableChannel, readChannelRoute } from '@objectstack/spec/api'; +import { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js'; + +describe('[#14646] the in-process realtime bus advertises no subscribable channel', () => { + it('names no channel route', () => { + const adapter = new InMemoryRealtimeAdapter(); + + expect(typeof (adapter as { getChannelRoute?: unknown }).getChannelRoute).not.toBe('function'); + expect(readChannelRoute(adapter)).toBeUndefined(); + }); + + it('is therefore not a subscribable channel in discovery terms', () => { + const route = readChannelRoute(new InMemoryRealtimeAdapter()); + + // Exactly the entry a producer would build from this occupant. + expect(isSubscribableChannel({ handlerReady: route !== undefined, route })).toBe(false); + }); + + it('serves no HTTP upgrade either — no transport, by ADR-0096 D4 as well', () => { + // `handleUpgrade` is deliberately unimplemented platform-wide until the + // identity-admission requirement on `IRealtimeService` is satisfied + // (#2992). Both facts point one way; the channel route is the one + // discovery reads, because SSE mounts a plain GET and never upgrades. + const adapter = new InMemoryRealtimeAdapter(); + expect(typeof (adapter as { handleUpgrade?: unknown }).handleUpgrade).not.toBe('function'); + }); +}); diff --git a/packages/services/service-realtime/src/realtime-service-plugin.ts b/packages/services/service-realtime/src/realtime-service-plugin.ts index 53ceb14c6f..7276ba5680 100644 --- a/packages/services/service-realtime/src/realtime-service-plugin.ts +++ b/packages/services/service-realtime/src/realtime-service-plugin.ts @@ -61,6 +61,17 @@ export class RealtimeServicePlugin implements Plugin { } async init(ctx: PluginContext): Promise { + // [#14646] The occupant registered here deliberately does NOT implement + // `IRealtimeService.getChannelRoute()`. That absence is the fact discovery + // reports: `realtime` is a CHANNEL SLOT, so `services.realtime.enabled`, + // its `route`/`handlerReady` and `capabilities.websockets` are all + // `isSubscribableChannel(...)` over the route an occupant names, and this + // adapter is an in-process pub/sub bus with no wire surface. Discovery + // therefore advertises no channel — the retraction the 2026-09-04 ruling + // asks for, computed from the implementation rather than hardcoded in two + // builders. ⛔ Do not implement `getChannelRoute` to "fix" a client that + // wants a channel: realtime stays out of open core, and naming a route + // nothing serves is the `declared ≠ enforced` defect that ruling closed. const realtime = new InMemoryRealtimeAdapter(this.options.memory); ctx.registerService('realtime', realtime); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index c3bf12734a..9319ef1d27 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -150,6 +150,7 @@ "BulkResponse (type)", "BulkResponseParsed (type)", "BulkResponseSchema (const)", + "CHANNEL_SURFACE_SLOTS (const)", "CacheControl (type)", "CacheControlSchema (const)", "CacheDirective (type)", @@ -1059,8 +1060,10 @@ "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", "identityFreeEndpointGateFailure (function)", + "isSubscribableChannel (function)", "makeApiErrorSchema (function)", "normalizeEndpointPath (function)", + "readChannelRoute (function)", "readServiceSelfInfo (function)", "resolveDiscoveryEnvironment (function)", "resolveObjectSortability (function)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 8b30d5cd81..4980e2d401 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -150,6 +150,7 @@ "BulkResponse": "src/api/contract.zod.ts#BulkResponse (type)", "BulkResponseParsed": "src/api/contract.zod.ts#BulkResponseParsed (type)", "BulkResponseSchema": "src/api/contract.zod.ts#BulkResponseSchema (const)", + "CHANNEL_SURFACE_SLOTS": "src/api/discovery.zod.ts#CHANNEL_SURFACE_SLOTS (const)", "CacheControl": "src/api/http-cache.zod.ts#CacheControl (type)", "CacheControlSchema": "src/api/http-cache.zod.ts#CacheControlSchema (const)", "CacheDirective": "src/api/http-cache.zod.ts#CacheDirective (type)", @@ -1059,8 +1060,10 @@ "getAuthEndpointUrl": "src/api/auth-endpoints.zod.ts#getAuthEndpointUrl (function)", "getDefaultRouteRegistrations": "src/api/plugin-rest-api.zod.ts#getDefaultRouteRegistrations (function)", "identityFreeEndpointGateFailure": "src/api/endpoint-publish-gate.ts#identityFreeEndpointGateFailure (function)", + "isSubscribableChannel": "src/api/discovery.zod.ts#isSubscribableChannel (function)", "makeApiErrorSchema": "src/api/contract.zod.ts#makeApiErrorSchema (function)", "normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)", + "readChannelRoute": "src/api/discovery.zod.ts#readChannelRoute (function)", "readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)", "resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)", "resolveObjectSortability": "src/api/sortability.zod.ts#resolveObjectSortability (function)", diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index c4fb3dcb9c..1663a8376c 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -37,8 +37,21 @@ export type ServiceStatus = z.input; * Reports per-service availability so clients can adapt their UI accordingly. */ export const ServiceInfoSchema = lazySchema(() => z.object({ - /** Whether the service is enabled and available */ - enabled: z.boolean(), + /** + * Whether the slot is filled by something this host delivers. + * + * ⛔ **Not** the predicate for "there is a channel I can subscribe to" — see + * {@link isSubscribableChannel}, which is `handlerReady: true` AND a + * connectable `route`. For a slot whose advertised capability IS such a + * channel ({@link CHANNEL_SURFACE_SLOTS}) this field is set to that + * predicate's value, so the two cannot disagree; for every other slot it + * still answers the narrower "is the slot filled" question (a kernel-internal + * contract like `cache` is honestly enabled with no route at all). + */ + enabled: z.boolean().describe( + 'Whether the slot is filled by something this host delivers. NOT "a channel exists": ' + + 'subscribing requires handlerReady:true AND a connectable route (isSubscribableChannel).' + ), /** Current operational status */ status: ServiceStatus, /** @@ -148,6 +161,97 @@ export function readServiceSelfInfo(svc: unknown): ServiceSelfInfo | undefined { return undefined; } +// ============================================================================ +// What counts as a SUBSCRIBABLE CHANNEL (#14646, maintainer ruling A 2026-09-04) +// ============================================================================ + +/** + * **A subscribable channel exists only where discovery reports + * `handlerReady: true` together with a connectable `route` for that slot; + * `enabled` never means "there is a channel".** + * + * That sentence is the whole definition, and this function is the only place + * it is computed — read it, do not re-derive it. + * + * ## Why it had to be written down + * + * `/discovery` reported the `realtime` slot as `enabled: true` **and** + * "In-process event bus only — no HTTP/WS realtime surface is mounted" at the + * same time, both true: `enabled` meant "the slot is filled", which for an + * in-process pub/sub bus says nothing about whether anything is listening on + * the wire. A client keying on it subscribes to nothing and silently loses the + * feature it was subscribing for — no error, no red, no signal. The defect was + * not a wrong value in a field; it was a field with two meanings, so flipping + * the boolean would have left the next half-mounted service to reproduce it. + * + * ## How it is enforced, rather than merely documented + * + * For a slot in {@link CHANNEL_SURFACE_SLOTS} — a slot whose advertised + * capability *is* the channel — **both discovery producers set `enabled` to + * the value of this predicate**, so the field a consumer reads and the + * predicate a consumer is told to use are the same computation and cannot + * disagree. `capabilities.websockets` is derived from the same call rather + * than stated as a constant, for the same reason. + * + * Nothing changes for the other slots. `cache`/`queue`/`job` are kernel-internal + * contracts fully delivered in-process (#4318): they are honestly `enabled` + * with no route, and they advertise no channel to subscribe to either — which + * is exactly why the predicate is applied per slot instead of to `enabled` + * globally. + * + * @param info the discovery entry for the slot (a `ServiceInfo`, or the parts + * of one being assembled) + */ +export function isSubscribableChannel( + info?: { handlerReady?: boolean; route?: string } | null, +): boolean { + return info?.handlerReady === true + && typeof info.route === 'string' + && info.route.length > 0; +} + +/** + * The slots whose advertised capability IS a subscribable channel, i.e. the + * slots whose `enabled` is {@link isSubscribableChannel} rather than "the slot + * is filled". + * + * `realtime` is the only member and, under the 2026-09-04 ruling, the open + * framework mounts no transport for it — so on a stock boot the predicate is + * false and discovery advertises nothing to subscribe to. A slot joins this set + * when the thing it promises a client is a connection, not an in-process + * contract. + */ +export const CHANNEL_SURFACE_SLOTS: ReadonlySet = new Set(['realtime']); + +/** + * The producer half of the definition: the route a channel-slot occupant says + * it is mounted at, or `undefined` when it names none. + * + * The occupant is asked because **no open-core producer mounts a realtime + * transport** — the dispatcher has no `/realtime` branch and no plugin mounts + * one (ADR-0076 D12, #2462), so neither discovery builder can honestly supply + * a route out of its own route table the way it does for its own domains. Only + * an implementation that actually serves a transport knows where a host put it, + * and a transport is not necessarily a WebSocket upgrade (SSE mounts a plain + * GET), so the question is asked as "where is your channel", never as "do you + * implement `handleUpgrade`". + * + * Read via {@link IRealtimeService.getChannelRoute}. Absence is the answer on + * every host that ships today: `@objectstack/service-realtime` is an in-process + * bus and does not implement it, so it advertises no channel — the retraction + * the ruling asks for, computed rather than hardcoded. + * + * ⛔ An occupant that returns a route without serving one at that path + * re-creates the very `declared ≠ enforced` gap this closes. + */ +export function readChannelRoute(svc: unknown): string | undefined { + if (!svc || typeof svc !== 'object') return undefined; + const getter = (svc as { getChannelRoute?: unknown }).getChannelRoute; + if (typeof getter !== 'function') return undefined; + const route = (getter as () => unknown).call(svc); + return typeof route === 'string' && route.length > 0 ? route : undefined; +} + /** * API Routes Schema * The "Map" for the frontend to know where to send requests. @@ -244,7 +348,16 @@ export const ApiRoutesSchema = lazySchema(() => z.object({ approvals: z.string().optional().describe('e.g. /api/v1/approvals'), /** Base URL for Realtime (WebSocket/SSE) */ - realtime: z.string().optional().describe('e.g. /api/v1/realtime'), + /** + * Where clients connect for realtime push, when a host mounts one. + * + * Advertised only when the `realtime` occupant names a mounted channel route + * (see {@link readChannelRoute}); absent on every host the open framework + * ships, whose realtime service is an in-process bus (ADR-0076 D12, #2462). + */ + realtime: z.string().optional().describe( + 'e.g. /api/v1/realtime — present only when a realtime transport is actually mounted' + ), /** Base URL for Notification Service */ notifications: z.string().optional().describe('e.g. /api/v1/notifications'), @@ -585,16 +698,22 @@ export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ * Whether the backend mounts a realtime push surface (WebSocket or SSE) * clients can subscribe to. * - * `false` on every host today, and that is a measured fact rather than a - * placeholder: `service-realtime` is an **in-process pub/sub bus**, the - * dispatcher has no `/realtime` branch and no plugin mounts one (ADR-0076 - * D12, #2462), which is exactly why `ApiRoutesSchema.realtime` is never - * advertised either. A producer that one day mounts a real WS/SSE surface - * flips this — and must also pass the anonymous-access gate (#2567). + * `false` on every host the open framework ships, and that is a measured + * fact rather than a placeholder: `service-realtime` is an **in-process + * pub/sub bus**, the dispatcher has no `/realtime` branch and no plugin + * mounts one (ADR-0076 D12, #2462), which is exactly why + * `ApiRoutesSchema.realtime` is not advertised either. + * + * [#14646] Both producers now **derive** this from + * {@link isSubscribableChannel} applied to `services.realtime` rather than + * stating a literal `false`. Two constants agreeing is not agreement — it is + * two places to forget — and this flag and that entry answer the very same + * question, so a host that one day mounts a real WS/SSE surface flips both in + * one step (and must also pass the anonymous-access gate, #2567). */ websockets: z.boolean().describe( 'Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. ' - + 'False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12).' + + 'Derived from isSubscribableChannel(services.realtime): handlerReady true AND a connectable route.' ), /** * Whether a file-storage surface is served at all (upload / download / diff --git a/packages/spec/src/contracts/realtime-service.ts b/packages/spec/src/contracts/realtime-service.ts index ea8032ade4..aa2df48bde 100644 --- a/packages/spec/src/contracts/realtime-service.ts +++ b/packages/spec/src/contracts/realtime-service.ts @@ -126,6 +126,38 @@ export interface IRealtimeService { */ handleUpgrade?(request: Request): Promise; + /** + * The path clients connect to for this service's **mounted** channel, or + * `undefined`/absent when nothing is mounted. + * + * [#14646] This is the producer half of the one definition of a + * subscribable channel (`isSubscribableChannel`, `@objectstack/spec/api`): + * discovery advertises `services.realtime` — `route`, `handlerReady`, + * `enabled` — and `capabilities.websockets` from this one answer, because + * no open-core producer mounts a realtime transport and therefore neither + * discovery builder can supply the route out of its own route table. Only + * an implementation that really serves a transport knows where a host put + * it. + * + * Deliberately about the *channel*, not about a handshake: SSE mounts a + * plain GET and never upgrades, so gating on {@link handleUpgrade} would + * have refused a legitimate transport. + * + * ⛔ Return a route ONLY when requests to it are actually served. A route + * named here is advertised verbatim, so naming an unserved one re-creates + * the `declared ≠ enforced` gap ADR-0076 D12 exists to close — the same + * defect, one layer up, that made discovery advertise a realtime service + * with no surface in the first place. + * + * Not implemented by `@objectstack/service-realtime`: it is an in-process + * pub/sub bus with no wire surface (maintainer ruling A, 2026-09-04 — + * realtime stays out of open core), so on a stock boot discovery reports + * `enabled: false` and there is nothing to subscribe to. + * + * @returns the mounted channel path (e.g. `/api/v1/realtime`), or `undefined` + */ + getChannelRoute?(): string | undefined; + /** * Subscribe to metadata events (convenience method) * @param filter - Subscription filter