From 3702a58577b6715eb976343758cc420ef346585d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:56:02 +0000 Subject: [PATCH 1/7] wip(#14646): one definition of a subscribable channel, wired into both discovery producers Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- packages/metadata-protocol/src/protocol.ts | 79 ++++++++-- packages/runtime/src/http-dispatcher.ts | 87 ++++++++--- .../src/realtime-service-plugin.ts | 11 ++ packages/spec/src/api/discovery.zod.ts | 139 ++++++++++++++++-- .../spec/src/contracts/realtime-service.ts | 32 ++++ 5 files changed, 308 insertions(+), 40 deletions(-) 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 { + 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 +1779,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 +1929,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/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/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 From 5ed1815d4aebf7c1dddd8d667b8055d4500fa98f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:10:49 +0000 Subject: [PATCH 2/7] test(#14646): pin both directions of the subscribable-channel definition; docs + changeset Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...scovery-subscribable-channel-definition.md | 20 +++ content/docs/kernel/services-checklist.mdx | 22 +-- .../protocol/kernel/realtime-protocol.mdx | 39 +++++- .../discovery-realtime-channel.pin.test.ts | 128 ++++++++++++++++++ .../objectql/src/protocol-discovery.test.ts | 15 +- packages/runtime/package.json | 1 + .../discovery-realtime-channel.pin.test.ts | 120 ++++++++++++++++ packages/runtime/src/http-dispatcher.test.ts | 9 +- packages/runtime/vitest.config.ts | 9 ++ .../src/no-channel-route.pin.test.ts | 51 +++++++ pnpm-lock.yaml | 7 +- 11 files changed, 404 insertions(+), 17 deletions(-) create mode 100644 .changeset/discovery-subscribable-channel-definition.md create mode 100644 packages/metadata-protocol/src/discovery-realtime-channel.pin.test.ts create mode 100644 packages/runtime/src/discovery-realtime-channel.pin.test.ts create mode 100644 packages/services/service-realtime/src/no-channel-route.pin.test.ts diff --git a/.changeset/discovery-subscribable-channel-definition.md b/.changeset/discovery-subscribable-channel-definition.md new file mode 100644 index 0000000000..d002d90ae6 --- /dev/null +++ b/.changeset/discovery-subscribable-channel-definition.md @@ -0,0 +1,20 @@ +--- +"@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. + +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..c88a066632 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -374,13 +374,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 +528,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 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/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/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 4d2908609a..78524f612e 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -76,9 +76,18 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => // 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/runtime/package.json b/packages/runtime/package.json index b34067c451..bbbfb0e5d1 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -68,6 +68,7 @@ "@objectstack/service-job": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-package": "workspace:*", + "@objectstack/service-realtime": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.10" }, 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..a3f821ac9e --- /dev/null +++ b/packages/runtime/src/discovery-realtime-channel.pin.test.ts @@ -0,0 +1,120 @@ +// 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'; +// The occupant a stock boot really registers (`RealtimeServicePlugin.init`). +// Imported rather than re-described: the fact under test is that the SHIPPED +// implementation names no channel route, and a hand-written stand-in could +// only re-state this file's own assumption about it. +import { InMemoryRealtimeAdapter } from '@objectstack/service-realtime'; + +const PREFIX = '/api/v1'; + +/** 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 = { + publish: async () => {}, + subscribe: async () => 'sub_1', + unsubscribe: async () => {}, + getChannelRoute: () => `${PREFIX}/realtime`, +}; + +describe('[#14646] discovery and the one definition of a subscribable channel (dispatcher producer)', () => { + it('does NOT advertise the shipped in-process bus as a channel', async () => { + const info = await dispatcherWithRealtime(new InMemoryRealtimeAdapter()) + .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 [new InMemoryRealtimeAdapter(), 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/vitest.config.ts b/packages/runtime/vitest.config.ts index 63648b24cd..b97984e242 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -143,6 +143,15 @@ export default defineConfig({ find: '@objectstack/service-package', replacement: path.resolve(__dirname, '../services/service-package/src/index.ts'), }, + // Dev-only: discovery-realtime-channel.pin.test.ts drives the REAL + // occupant `RealtimeServicePlugin` registers, so "the shipped realtime + // service names no channel route" (#14646) is proven against the actual + // adapter rather than against a stand-in written from this file's own + // assumption about it. + { + find: '@objectstack/service-realtime', + replacement: path.resolve(__dirname, '../services/service-realtime/src/index.ts'), + }, ], }, test: { 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/pnpm-lock.yaml b/pnpm-lock.yaml index b88409ec7e..12016f0e63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,7 +377,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/setup: dependencies: @@ -399,7 +399,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/studio: dependencies: @@ -2252,6 +2252,9 @@ importers: '@objectstack/service-package': specifier: workspace:* version: link:../services/service-package + '@objectstack/service-realtime': + specifier: workspace:* + version: link:../services/service-realtime typescript: specifier: ^6.0.3 version: 6.0.3 From d1036c8c70fb57a3254a6639434a3f97a0e6ceab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:42:49 +0000 Subject: [PATCH 3/7] chore(#14646): regenerate spec api-surface, export-origins and reference docs Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/references/api/discovery.mdx | 12 ++++++------ content/docs/references/api/protocol.mdx | 6 +++--- packages/spec/api-surface/api.json | 3 +++ packages/spec/export-origins/api.json | 3 +++ 4 files changed, 15 insertions(+), 9 deletions(-) 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/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)", From ff617529c257a97adc2388fb8c72cff1e5ed8e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:51:45 +0000 Subject: [PATCH 4/7] fix(#14646): annotate the dispatcher's realtime entry as ServiceInfo Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- packages/runtime/src/http-dispatcher.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index cd66c8ab2f..8d6c737bbb 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -14,6 +14,7 @@ import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } f import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { PrimaryDatasourceVerdict } from '@objectstack/objectql'; 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'; @@ -1612,7 +1613,12 @@ export class HttpDispatcher { // 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. - const realtimeEntry = realtimeSvc + // 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 { From 04b60ff7d77e90ed11cc0732c3a45691ceb5d5ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:16:13 +0000 Subject: [PATCH 5/7] docs(#14646): correct the two services-checklist claims this change falsified Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/kernel/services-checklist.mdx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index c88a066632..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 @@ -584,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`) --- From 85065168ab661eb39e18cee4b4b8e6cbddfad394 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:57:09 +0000 Subject: [PATCH 6/7] fix(#14646): re-measure the moved census control, and drop the runtime test's cross-package source dep The dogfood census pins per-file control counts exactly; the comment added to realtime-service-plugin.ts moved `RealtimeService` 10 -> 11 because the pattern matches inside `IRealtimeService`. Re-measured in the ledger rather than reworded in the file. The runtime pin's import of the real InMemoryRealtimeAdapter made @objectstack/runtime type-resolve @objectstack/service-realtime through dist. Measured against origin/main: this package's typecheck script already named tsconfig.test.json, so the program was already counted and the registry's re-baseline limb does not apply; the mandated `paths` remedy then billed 13 TS6059 from another package's file graph into this package's ledger, against a rootDir tsconfig.test.json states it will not widen. Edge removed instead. The claim about the shipped occupant stays pinned against the real class in service-realtime's own suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../test/authz-probe-blind-spot.census.ts | 13 ++++- packages/runtime/package.json | 1 - .../discovery-realtime-channel.pin.test.ts | 47 ++++++++++++++----- packages/runtime/vitest.config.ts | 9 ---- pnpm-lock.yaml | 7 +-- 5 files changed, 49 insertions(+), 28 deletions(-) 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/package.json b/packages/runtime/package.json index bbbfb0e5d1..b34067c451 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -68,7 +68,6 @@ "@objectstack/service-job": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-package": "workspace:*", - "@objectstack/service-realtime": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.10" }, diff --git a/packages/runtime/src/discovery-realtime-channel.pin.test.ts b/packages/runtime/src/discovery-realtime-channel.pin.test.ts index a3f821ac9e..771fa39f0c 100644 --- a/packages/runtime/src/discovery-realtime-channel.pin.test.ts +++ b/packages/runtime/src/discovery-realtime-channel.pin.test.ts @@ -27,14 +27,40 @@ 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'; -// The occupant a stock boot really registers (`RealtimeServicePlugin.init`). -// Imported rather than re-described: the fact under test is that the SHIPPED -// implementation names no channel route, and a hand-written stand-in could -// only re-state this file's own assumption about it. -import { InMemoryRealtimeAdapter } from '@objectstack/service-realtime'; 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. + * + * Nothing is lost by declaring it here, because 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`. That + * pin plus these compose to the stock-boot reading — this file pins that the + * PRODUCER derives its answer from whatever the occupant names, and that one + * pins what the shipped occupant names. + */ +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 = { @@ -52,16 +78,13 @@ function dispatcherWithRealtime(realtime: unknown): HttpDispatcher { * case has to be composed here. */ const mountedChannel: IRealtimeService = { - publish: async () => {}, - subscribe: async () => 'sub_1', - unsubscribe: async () => {}, + ...inProcessBus, getChannelRoute: () => `${PREFIX}/realtime`, }; describe('[#14646] discovery and the one definition of a subscribable channel (dispatcher producer)', () => { - it('does NOT advertise the shipped in-process bus as a channel', async () => { - const info = await dispatcherWithRealtime(new InMemoryRealtimeAdapter()) - .getDiscoveryInfo(PREFIX); + 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 @@ -108,7 +131,7 @@ describe('[#14646] discovery and the one definition of a subscribable channel (d // 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 [new InMemoryRealtimeAdapter(), mountedChannel, null]) { + 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); diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index b97984e242..63648b24cd 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -143,15 +143,6 @@ export default defineConfig({ find: '@objectstack/service-package', replacement: path.resolve(__dirname, '../services/service-package/src/index.ts'), }, - // Dev-only: discovery-realtime-channel.pin.test.ts drives the REAL - // occupant `RealtimeServicePlugin` registers, so "the shipped realtime - // service names no channel route" (#14646) is proven against the actual - // adapter rather than against a stand-in written from this file's own - // assumption about it. - { - find: '@objectstack/service-realtime', - replacement: path.resolve(__dirname, '../services/service-realtime/src/index.ts'), - }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12016f0e63..b88409ec7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,7 +377,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/setup: dependencies: @@ -399,7 +399,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/studio: dependencies: @@ -2252,9 +2252,6 @@ importers: '@objectstack/service-package': specifier: workspace:* version: link:../services/service-package - '@objectstack/service-realtime': - specifier: workspace:* - version: link:../services/service-realtime typescript: specifier: ^6.0.3 version: 6.0.3 From 3c22dddf2ec9e7233cfd5de8c7d68562cab28bf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 18:07:21 +0000 Subject: [PATCH 7/7] docs(#14646): record at both sites what the runtime pin stopped proving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured, one mutation and two pins: giving the shipped InMemoryRealtimeAdapter a getChannelRoute() reddens service-realtime's no-channel-route pin (2 of 3 cases) and leaves the runtime producer pin green on all 4. So the shipped- occupant claim is covered next door, not lost — and the runtime pin's stand-in is equivalent to the real adapter only on the two reads a producer performs, which was measured rather than assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...scovery-subscribable-channel-definition.md | 2 ++ .../discovery-realtime-channel.pin.test.ts | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.changeset/discovery-subscribable-channel-definition.md b/.changeset/discovery-subscribable-channel-definition.md index d002d90ae6..3fed65a35c 100644 --- a/.changeset/discovery-subscribable-channel-definition.md +++ b/.changeset/discovery-subscribable-channel-definition.md @@ -17,4 +17,6 @@ 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/packages/runtime/src/discovery-realtime-channel.pin.test.ts b/packages/runtime/src/discovery-realtime-channel.pin.test.ts index 771fa39f0c..5768284fa9 100644 --- a/packages/runtime/src/discovery-realtime-channel.pin.test.ts +++ b/packages/runtime/src/discovery-realtime-channel.pin.test.ts @@ -47,13 +47,28 @@ const PREFIX = '/api/v1'; * `tsconfig.test.json` states it will not widen — 13 `TS6059` billed to a * ledger `service-realtime` cannot see, the same shape PR #12570 measured. * - * Nothing is lost by declaring it here, because 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`. That - * pin plus these compose to the stock-boot reading — this file pins that the - * PRODUCER derives its answer from whatever the occupant names, and that one - * pins what the shipped occupant names. + * ⚠️ 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 () => {},