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