Skip to content
Draft
22 changes: 22 additions & 0 deletions .changeset/discovery-subscribable-channel-definition.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 28 additions & 10 deletions content/docs/kernel/services-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -374,13 +383,19 @@ a self-declared stub answers as an empty one.

<Callout type="warn">
`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.
</Callout>

### 8. notification — 7 methods · `@objectstack/service-messaging`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`)

---

Expand Down
39 changes: 36 additions & 3 deletions content/docs/protocol/kernel/realtime-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.

<Callout type="warn">
⛔ **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.
</Callout>

<Callout type="info">
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).
</Callout>

### Establishing Connection
Expand Down
12 changes: 6 additions & 6 deletions content/docs/references/api/discovery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
Expand All @@ -124,7 +124,7 @@ const result = ApiRoutesSchema.parse(data);
| **export** | `{ enabled: boolean; features?: Record<string, boolean>; description?: string }` | ✅ | Whether the backend supports async export |
| **chunkedUpload** | `{ enabled: boolean; features?: Record<string, boolean>; description?: string }` | ✅ | Whether the backend supports chunked (multipart) uploads |
| **transactionalBatch** | `{ enabled: boolean; features?: Record<string, boolean>; 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<string, boolean>; 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<string, boolean>; 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<string, boolean>; description?: string }` | ✅ | Whether a file-storage surface (upload/download/attachments) is served |
| **analytics** | `{ enabled: boolean; features?: Record<string, boolean>; description?: string }` | ✅ | Whether the backend serves the analytics / BI query surface |
| **ai** | `{ enabled: boolean; features?: Record<string, boolean>; description?: string }` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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) |
Expand Down
Loading
Loading