# Realtime layer (Socket.IO) — Design

**Date:** 2026-05-08
**Status:** Draft (pending user review)
**Companion ADR:** ADR-016 (to be created during implementation; this spec is the long-form design that ADR-016 distills).
**Builds on:** ADR-015 (cross-feature events and background jobs). The bridge in §8 is a third consumer of the `IEventBus` shipped in ADR-015.

---

## 1. Context and motivation

This template currently has no convention for **realtime communication between server and browser**. tRPC covers request/response (`query` / `mutation`); the event bus from ADR-015 covers in-process cross-feature publish/subscribe. Neither delivers server state to a connected browser tab without polling.

Two distinct concerns drive this work:

- **Generic realtime seam.** Establish the abstraction now (interface, vendor-neutral adapter, DI binding, scaffolding) so individual features can adopt realtime on demand without each feature having to design its own socket integration. Same logic that motivated `IEventBus` and `IJobQueue` in ADR-015: define the seam once.
- **Live observability dashboard (deferred to a follow-up PR).** An admin view that streams live event/job traffic from the existing bus + queue. The dashboard is the first concrete consumer of the seam, but its UI work is large enough to ship separately. v1 instead carries a built-in `realtime-ping` channel as the proof-of-life.

The architectural principle from prior work (ADR-014 instrumentation, ADR-015 events/jobs) is preserved: the wire-protocol library (`socket.io`) is hidden behind an `IRealtimeBroadcaster` / `IRealtimeServer` interface. Feature packages MUST NOT import `socket.io` directly. Only the new `@repo/core-realtime` package and the apps' bootstrap layer touch the SDK.

---

## 2. Conceptual model and rules

### 2.1 What the realtime layer is *for*

| | Realtime broadcast | Realtime handler (inbound) |
|---|---|---|
| **Direction** | Server → connected clients | Client → server |
| **Caller** | A feature's use case (direct), or the bridge (forwarding bus events) | A connected client emits a payload |
| **Audience** | Subscribers to a named channel | The feature that registered the handler |
| **Transport** | Socket.IO (vendor-isolated behind `IRealtimeBroadcaster`) | Socket.IO (vendor-isolated; handler factory pattern) |
| **Why it exists** | Push state changes to browser tabs without polling | Receive ephemeral / high-frequency client signals (presence, cursors) |

### 2.2 The three rules

Mirroring ADR-015's rule pattern. These are the discipline; mechanical wiring follows from them.

- **Rule R0 — Realtime is for state delivery, not for replacing tRPC.** Persistent operations with request/response semantics belong on tRPC procedures. Use realtime when (a) the server needs to push without a request, or (b) the data is too high-frequency for HTTP.
- **Rule R1 — Channel descriptors are exported; handlers are private.** A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel. A feature's `realtime/handlers/<name>.handler.ts` is wired only inside that feature's own `bind-production` / `bind-dev-seed` and is never re-exported from any subpath. Mirrors ADR-015's E1 for events.
- **Rule R2 — Vendor isolation: `socket.io` lives in one package only.** Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. The single allowlist entry is `packages/core-realtime/src/socket-io-*.ts`. Apps' custom Node servers are the second allowlist entry (they instantiate the server). ESLint rule `no-direct-socket-io` enforces this — parallel to `no-direct-payload-jobs` from ADR-015.

These rules go verbatim into ADR-016 and into `AGENTS.md` § Per-Package Conventions.

### 2.3 The seam (server → client flow)

```
feature/use-case
     │
     │ realtime.broadcast(channelDescriptor, payload)
     ▼
IRealtimeBroadcaster                              ←── vendor-neutral
     │
     │ implements
     ▼
SocketIORealtimeBroadcaster                       ←── lives in core-realtime
     │
     │ this.io.to("ch:<name>").emit(name, payload)
     ▼
Socket.IO room "ch:<name>"
     │
     ▼
N connected sockets that subscribed (gate 2 cleared)
     │
     ▼
Browser tab receives `socket.on(name, payload)`
```

### 2.4 The seam (client → server flow)

```
Browser tab: socket.emit(name, payload, ack)
     │
     ▼
SocketIORealtimeServer (gate 3)
     │
     ├─ schema.safeParse(payload)
     ├─ authorize(descriptor, params, socket.data.user)   ← defense in depth
     ▼
handlerRegistry.invoke(name, validated, ctx)
     │
     │ ctx = { userId, roles }   (from socket.data.user)
     ▼
withSpan(withCapture(handlerFactory(deps)))(input, ctx)
     │
     ▼
Feature handler runs, throws or returns void
     │
     ▼
ack({ ok: true } | { ok: false, error })
```

---

## 3. New package: `@repo/core-realtime`

A new package, parallel in shape to `@repo/core-events`. Pure interfaces + symbols + the Socket.IO adapters. Tagged `core` (Turborepo boundary) and `core` (eslint-plugin-boundaries).

### 3.1 Package shape

```
packages/core-realtime/
├── package.json                               name: @repo/core-realtime
├── tsconfig.json
├── vitest.config.ts
├── eslint.config.js
├── turbo.json                                 tags: ["core"]
├── AGENTS.md
└── src/
    ├── index.ts                               public barrel
    ├── symbols.ts                             CORE_REALTIME_SYMBOLS
    ├── realtime-channel.ts                    defineRealtimeChannel + descriptor + scope union
    ├── realtime-broadcaster.interface.ts      IRealtimeBroadcaster (server → client)
    ├── realtime-handler.interface.ts          IRealtimeHandler<T> + IInboundDescriptor
    ├── realtime-server.interface.ts           IRealtimeServer (lifecycle, used at boot only)
    ├── realtime-authenticator.interface.ts    IRealtimeAuthenticator (cookie/header → user)
    ├── realtime-handler-registry.ts           in-memory map of channel → wrapped handler
    ├── channel-template.ts                    matches "notifications.user.{userId}" → params
    ├── authorize.ts                           pure function: descriptor + params + user → bool
    ├── in-memory-realtime-broadcaster.ts      test/dev (no socket.io dep)
    ├── socket-io-realtime-server.ts           production (depends on socket.io)
    └── socket-io-realtime-broadcaster.ts      production (wraps io.to(...).emit(...))
```

### 3.2 Public exports (barrel)

```ts
// packages/core-realtime/src/index.ts
export type { RealtimeChannelDescriptor, ChannelScope } from "./realtime-channel";
export { defineRealtimeChannel } from "./realtime-channel";
export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
export type { IRealtimeHandler, IInboundDescriptor } from "./realtime-handler.interface";
export type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
export type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
export type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";
export { RealtimeHandlerRegistry } from "./realtime-handler-registry";
export { CORE_REALTIME_SYMBOLS } from "./symbols";
export { InMemoryRealtimeBroadcaster } from "./in-memory-realtime-broadcaster";
export { SocketIORealtimeServer } from "./socket-io-realtime-server";
export { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster";
```

### 3.3 Interfaces — exact shapes

```ts
// packages/core-realtime/src/realtime-channel.ts
import type { z } from "zod";

export type ChannelScope =
  | "public"
  | "authenticated"
  | { role: string }
  | { userScoped: true; template: string };

export type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
  readonly name: TName;
  readonly schema: TSchema;
  readonly scope: ChannelScope;
};

export function defineRealtimeChannel<TName extends string, TSchema extends z.ZodType>(
  name: TName,
  schema: TSchema,
  options: { scope: ChannelScope },
): RealtimeChannelDescriptor<TName, TSchema> {
  return { name, schema, scope: options.scope };
}
```

```ts
// packages/core-realtime/src/realtime-broadcaster.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export interface IRealtimeBroadcaster {
  broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void>;
}
```

```ts
// packages/core-realtime/src/realtime-handler.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export type RealtimeContext = {
  userId: string | null;
  roles: string[];
};

export type IRealtimeHandler<T> = (input: T, ctx: RealtimeContext) => Promise<void>;

export type IInboundDescriptor<TName extends string, TSchema extends z.ZodType> = {
  readonly descriptor: RealtimeChannelDescriptor<TName, TSchema>;
  readonly handler: IRealtimeHandler<z.infer<TSchema>>;
};
```

```ts
// packages/core-realtime/src/realtime-authenticator.interface.ts
export interface IRealtimeAuthenticator {
  authenticate(handshake: {
    cookies: Record<string, string>;
    headers: Record<string, string>;
  }): Promise<{ userId: string; roles: string[] } | null>;
}
```

```ts
// packages/core-realtime/src/realtime-handler-registry.ts
import type { IInboundDescriptor } from "./realtime-handler.interface";

export interface IRealtimeHandlerRegistry {
  register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
  getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
  list(): IInboundDescriptor<string, z.ZodType>[];
}

export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
  // implementation: a Map keyed by descriptor.name; details in the impl plan
}
```

```ts
// packages/core-realtime/src/realtime-server.interface.ts
import type { Server as HttpServer } from "node:http";
import type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
import type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";

export type IRealtimeServerOptions = {
  httpServer: HttpServer;
  authenticator: IRealtimeAuthenticator;
  registry: IRealtimeHandlerRegistry;
};

export interface IRealtimeServer {
  start(): Promise<void>;
  stop(): Promise<void>;
}
```

The bus is generic over the schema's inferred type, so `realtime.broadcast(presenceChannel, payload)` infers `payload`'s type from `presenceChannel.schema` and rejects mismatches at compile time.

---

## 4. Per-feature folder layout

A feature that uses realtime has these additional directories (all optional; absent if unused):

```
packages/<feature>/src/
├── realtime/
│   ├── <name>.channel.ts                      ← channel descriptor (publisher-side public)
│   └── handlers/
│       └── on-<name>.handler.ts               ← inbound handler (private, never re-exported)
└── ...
```

Top-level `realtime/`, **not** under `integrations/`. Rationale:

- Channel descriptors are descriptor-shaped (`{ name, schema, scope }`) — same shape as event descriptors from ADR-015, which also live at top level (`events/`).
- tRPC's `integrations/api/` is router-shaped (procedures aggregated into a tree, mounted on a wire). Realtime has no router — channels are flat descriptors, the Socket.IO server lives once in `core-realtime`, no per-feature transport code.
- Consistency with `events/` and `jobs/` (ADR-015) outweighs consistency with tRPC's `integrations/api/`.

The existing `integrations/api/` and `integrations/cms/` patterns are unchanged.

---

## 5. File shapes

### 5.1 Channel descriptor (publisher-side)

```ts
// packages/blog/src/realtime/article-feed.channel.ts
import { z } from "zod";
import { defineRealtimeChannel } from "@repo/core-realtime";

export const articleFeedSchema = z.object({
  id: z.string(),
  slug: z.string(),
  title: z.string(),
  publishedAt: z.string().datetime(),
}).strict();

export type ArticleFeedPayload = z.infer<typeof articleFeedSchema>;

export const articleFeedChannel = defineRealtimeChannel(
  "blog.article.feed",
  articleFeedSchema,
  { scope: "public" },
);
```

Public re-export through the feature's root barrel (anchor-injected by the generator):

```ts
// packages/blog/src/index.ts
// <gen:realtime-channels>
export {
  articleFeedChannel,
  articleFeedSchema,
  type ArticleFeedPayload,
} from "./realtime/article-feed.channel";
```

### 5.2 Direct broadcast (publisher use case)

```ts
// packages/blog/src/application/use-cases/publish-article.use-case.ts
import type { IRealtimeBroadcaster } from "@repo/core-realtime";
import { articleFeedChannel } from "../../realtime/article-feed.channel";

export const publishArticleUseCase =
  (
    articles: IArticlesRepository,
    bus: IEventBus,
    realtime: IRealtimeBroadcaster,
  ) =>
  async (input: PublishArticleInput): Promise<PublishArticleOutput> => {
    const article = await articles.publish(input.id);
    await bus.publish(articlePublishedEvent, article);                 // bus (durable consumers)
    await realtime.broadcast(articleFeedChannel, article);             // direct broadcaster
    return publishArticleOutputSchema.parse(article);
  };
```

`bus` and `realtime` are both factory deps. Hybrid pattern: bus handles durable consumers (notification job, search index, audit log); broadcaster handles the live UI fan-out.

### 5.3 Inbound handler (consumer-side)

```ts
// packages/blog/src/realtime/handlers/on-presence-ping.handler.ts
import type { PresencePingPayload } from "../presence-ping.channel";
import type { IPresenceService } from "../../application/services/presence.service.interface";
import type { RealtimeContext } from "@repo/core-realtime";

export type IOnPresencePingHandler = ReturnType<typeof onPresencePingHandler>;

export const onPresencePingHandler =
  (presence: IPresenceService) =>
  async (input: PresencePingPayload, ctx: RealtimeContext): Promise<void> => {
    if (!ctx.userId) throw new Error("authenticated channel reached non-authed handler");
    await presence.markActive(ctx.userId, input.articleId, input.at);
  };
```

Handler factories follow the same shape as use cases (`(deps) => async (input, ctx) => result`). For `scope: "authenticated"` channels, `ctx.userId` is non-null (gate 2 + 3 ensure this); the runtime check is defensive, matching the `xOutputSchema.parse(...)` discipline use cases use.

### 5.4 Handler binding (consumer's `bind-*`)

```ts
// packages/blog/src/di/bind-production.ts (excerpt, anchor-injected by generator)
import { presencePingChannel } from "../realtime/presence-ping.channel";
import { onPresencePingHandler } from "../realtime/handlers/on-presence-ping.handler";

export function bindProductionBlog(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
  realtime: IRealtimeBroadcaster,
  realtimeRegistry: IRealtimeHandlerRegistry,
): void {
  // ... existing bindings ...

  // <gen:realtime-handlers>
  const wrappedOnPresencePing = withSpan(
    tracer,
    { name: "blog.onPresencePing", op: "realtime-handler" },
    withCapture(
      logger,
      { feature: "blog", layer: "realtime-handler", name: "blog.onPresencePing" },
      onPresencePingHandler(presenceService),
    ),
  );
  realtimeRegistry.register({
    descriptor: presencePingChannel,
    handler: wrappedOnPresencePing,
  });
}
```

The handler is wrapped in the **same span+capture sandwich** as use cases (R41–R44 from ADR-014), with two new tag values: `op: "realtime-handler"`, `layer: "realtime-handler"`. The `// <gen:realtime-handlers>` anchor marks where `gen realtime handler` inserts new blocks.

**Note on binder signature growth:** `bindProductionX` now takes seven args `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. This is consistent with how ADR-015 grew the signature from three to five. Future expansion may warrant collapsing into a single `BindContext` parameter; deferred.

---

## 6. Topology: custom Node server

`apps/web-next/server.ts` replaces `next dev` / `next start` as the boot entry.

### 6.1 What runs in one process

```
apps/web-next/server.ts (Node http server, port 3000)
    ├── Next.js handler (page routes, API routes, tRPC)
    └── Socket.IO server (mounted on the same http server)
        ├── Cookie-auth middleware (gate 1)
        ├── Channel registry (subscribe → gate 2)
        └── Handler dispatch (inbound message → gate 3)
```

Single process, single port. The Vercel adapter for Next.js serverless is no longer used for `web-next`; deployment is now any Node host (Render / Railway / Fly / self-hosted). `cms` and `web-tanstack` are unchanged — they can stay on their existing runtimes until they need realtime.

### 6.2 Boot sequence

```ts
// apps/web-next/server.ts (sketch)
import { createServer } from "node:http";
import next from "next";
import { Server as SocketIOServer } from "socket.io";
import { SocketIORealtimeServer, SocketIORealtimeBroadcaster, RealtimeHandlerRegistry } from "@repo/core-realtime";
import { bindAll } from "./src/server/bind-production";

const app = next({ dev: process.env.NODE_ENV !== "production" });
await app.prepare();

const httpServer = createServer((req, res) => app.getRequestHandler()(req, res));
const io = new SocketIOServer(httpServer, { /* CORS etc */ });

const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();

await bindAll({ realtime: broadcaster, realtimeRegistry: registry });
//             ^ bindAll passes broadcaster + registry into every per-feature binder

const realtimeServer = new SocketIORealtimeServer({
  io,
  authenticator: realtimeAuthenticatorImpl(),    // delegates to auth feature
  registry,
});
await realtimeServer.start();

httpServer.listen(3000);
```

`bindAll` gains two new resolution steps mirroring ADR-015's `resolveEventsAndJobs*`:

- `resolveRealtime()` — picks broadcaster impl by env (in-memory in tests, Socket.IO otherwise). Idempotent + cached per process.
- `bindRealtimeBridge(bus, broadcaster, allowlist)` — explicit allowlist of bus events forwarded onto realtime channels. Empty in v1 (dashboard PR adds the first entries).

---

## 7. Auth + authorization (the four checkpoints)

### 7.1 Lifecycle gates

| Gate | When | What it does |
|---|---|---|
| 1 | Connect | Read cookie, validate session via `IRealtimeAuthenticator`, attach `{ userId, roles } | null` to `socket.data.user`. |
| 2 | Subscribe | Match `requestedName` against registered descriptors (template-aware), apply `authorize(descriptor, params, user)`, on success `socket.join("ch:<name>")`. |
| 3 | Inbound message | Validate via Zod, re-apply `authorize` (defense in depth), invoke wrapped handler with `ctx`. |
| 4 | Broadcast | **No gate.** `io.to("ch:<name>").emit(...)` fans out to whoever cleared gate 2. Subscribe is the single source of truth. |

### 7.2 Authenticator implementation lives at the app layer

`core-realtime` defines `IRealtimeAuthenticator`. The implementation lives in `apps/web-next/server.ts` and delegates to the auth feature's `IAuthenticationService.validateSession()`:

```ts
const authenticator: IRealtimeAuthenticator = {
  authenticate: async ({ cookies }) => {
    const sessionId = cookies[SESSION_COOKIE];
    if (!sessionId) return null;
    const authService = authContainer.get<IAuthenticationService>(
      AUTH_SYMBOLS.IAuthenticationService,
    );
    const session = await authService.validateSession(sessionId);
    return session
      ? { userId: session.userId, roles: session.roles ?? [] }
      : null;
  },
};
```

`core-realtime` never imports the auth feature. The boundary stays clean (feature → core only); the app composes them.

### 7.3 The `authorize` function

```ts
// packages/core-realtime/src/authorize.ts
import type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel";

export async function authorize(
  descriptor: RealtimeChannelDescriptor<string, z.ZodType>,
  params: Record<string, string>,
  user: { userId: string; roles: string[] } | null,
): Promise<boolean> {
  const scope = descriptor.scope;

  if (scope === "public") return true;
  if (scope === "authenticated") return user !== null;

  if (typeof scope === "object" && "role" in scope) {
    return user !== null && user.roles.includes(scope.role);
  }
  if (typeof scope === "object" && "userScoped" in scope) {
    return user !== null && params.userId === user.userId;
  }

  return false;
}
```

Pure function, no DB hit, no side effects. Each gate-2 / gate-3 invocation is constant-time.

### 7.4 Channel-template matching

Plain channels (`"blog.article.feed"`) match by exact equality. Templated channels (`"notifications.user.{userId}"`) match by parameterized lookup:

- Client subscribes to `"notifications.user.user_42"`.
- Server iterates registered descriptors; finds the one whose template parses against the requested name.
- Yields `params = { userId: "user_42" }`.
- `authorize` reads `params.userId` for the `userScoped` scope check.

---

## 8. Bus ↔ realtime — the hybrid pattern

The repo already has `IEventBus` (ADR-015). Realtime is layered alongside it, not on top of it:

- **Direct broadcast** (most cases): feature use case takes `realtime: IRealtimeBroadcaster` and calls `realtime.broadcast(channel, payload)`. The broadcaster has no knowledge of the bus.
- **Bridge**: a single `bindRealtimeBridge(bus, broadcaster, allowlist)` step subscribes to allowlisted bus events and forwards them onto realtime channels. Lives in `apps/web-next/src/server/bind-production.ts` next to the existing `bindAll` flow.

### 8.1 Why both, not one

| Use case | Bus only | Realtime only | Both (hybrid) |
|---|---|---|---|
| Welcome email after signup | ✓ — durable, retryable | ✗ — clients shouldn't trigger emails | |
| Article published → search index | ✓ | | |
| Live cursor positions in editor | ✗ — too high-frequency | ✓ | |
| Article published → live feed update | | | ✓ — durable consumers AND realtime fan-out |
| Comment posted → other readers see it | | | ✓ — moderation/notifications via bus, fan-out via bridge |

A single API would force one of the wrong shapes onto the other use case. Two APIs, mutually independent, cover the full matrix.

### 8.2 Bridge wiring

```ts
// apps/web-next/src/server/bind-production.ts
function bindRealtimeBridge(bus: IEventBus, broadcaster: IRealtimeBroadcaster): void {
  // v1 ships with empty allowlist. First entries land in the dashboard PR:
  // bus.subscribe(userSignedUpEvent, "realtime-bridge", payload =>
  //   broadcaster.broadcast(adminEventStreamChannel, { kind: "user.signed-up", payload }));
}
```

Each bridge entry is explicit, by design. There is no auto-bridge: bus events have no auth posture, realtime channels do; the mapping is where you assign it.

---

## 9. Boundary rules and ESLint

Three rule additions:

- **`no-direct-socket-io`** — blocks `import "socket.io"` and `import "socket.io-client"` outside `packages/core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`. Parallel to `no-direct-payload-jobs` (ADR-015).
- **`no-realtime-handler-reexport`** — blocks any export from `**/realtime/handlers/**` outside that file's own feature `bind-*`. Parallel to `no-handler-reexport` (ADR-015 R-E1).
- **Boundary tag for `core-realtime`**: `["core"]` in `packages/core-realtime/turbo.json`. The `feature` allowlist already includes `["core", "feature", "tooling"]` post-ADR-015; no further change.

The `IRealtimeAuthenticator` impl in `apps/web-next/server.ts` imports from the `auth` feature. This crosses no boundary (apps are tagged `app` and may import from `core | core-composition | feature | tooling`).

---

## 10. Testing

Three layers, mirroring the existing patterns.

### 10.1 Unit tests

Feature handlers tested via direct factory injection — no realtime server, no Socket.IO, no sockets:

```ts
const presence = new MockPresenceService();
const handler = onPresencePingHandler(presence);
await handler(
  { articleId: "a1", at: "2026-05-08T12:00:00.000Z" },
  { userId: "user_1", roles: [] },
);
expect(presence.markActiveCalls).toHaveLength(1);
```

Use cases that broadcast inject `RecordingRealtimeBroadcaster` (§10.2) and assert against the recorded broadcasts.

### 10.2 `RecordingRealtimeBroadcaster` (in `core-testing/instrumentation/`)

Same shape as `RecordingEventBus`, same local-type-alias pattern (the cycle lesson from ADR-015 — core-testing must not import core-realtime as a runtime dep, or the typecheck graph cycles):

```ts
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts
import type { z } from "zod";

// Local type aliases (mirror @repo/core-realtime's contracts).
type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
  readonly name: TName;
  readonly schema: TSchema;
};

interface IRealtimeBroadcaster {
  broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void>;
}

export class RecordingRealtimeBroadcaster implements IRealtimeBroadcaster {
  readonly broadcasts: { channel: string; payload: unknown }[] = [];
  async broadcast<T>(descriptor, payload: T): Promise<void> {
    descriptor.schema.parse(payload);
    this.broadcasts.push({ channel: descriptor.name, payload });
  }
}
```

### 10.3 Integration test: `realtime-ping`

The proof-of-life. Brings up the Socket.IO server in-process, uses `socket.io-client` to connect with a seeded session cookie, exercises connect → subscribe → emit → broadcast end-to-end:

```ts
// apps/web-next/src/__tests__/realtime-ping.test.ts (sketch)
describe("e2e: realtime-ping exercises all four checkpoints", () => {
  it("authenticated client emits ping, receives pong on server-broadcast pong channel", async () => {
    await bindAllDevSeed({ ... });
    const httpServer = createServer();
    const realtime = await startRealtimeServer({ httpServer, authenticator, registry });
    httpServer.listen(0);
    const port = (httpServer.address() as AddressInfo).port;

    const sessionId = await seedTestSession();    // dev-seed Auth feature
    const client = io(`http://localhost:${port}`, {
      extraHeaders: { Cookie: `${SESSION_COOKIE}=${sessionId}` },
    });
    await once(client, "connect");

    const pongs: unknown[] = [];
    client.on("realtime.pong", (p) => pongs.push(p));
    await emitWithAck(client, "subscribe", "realtime.pong");

    await emitWithAck(client, "realtime.ping", { at: new Date().toISOString() });
    await once(client, "realtime.pong");

    expect(pongs).toHaveLength(1);
  });
});
```

The `realtime-ping` channel pair lives inside `core-realtime` itself — not in any feature, not user-visible. Stripped or env-gated in production.

### 10.4 Out of v1 scope

- Load testing with N concurrent sockets
- Multi-instance fanout (sticky sessions, Redis adapter)
- Replay tests (pre-recorded socket sessions replayed against the server)

---

## 11. Generators

Two new generators, three new anchors per feature. Same protocol as ADR-015.

### 11.1 New anchors (added to all five existing features + `feature` template)

| File | Anchor | Used by |
|---|---|---|
| `src/index.ts` | `// <gen:realtime-channels>` | `gen realtime channel` |
| `src/di/symbols.ts` | `// <gen:realtime-handler-symbols>` | `gen realtime handler` |
| `src/di/bind-production.ts` | `// <gen:realtime-handlers>` | `gen realtime handler` |
| `src/di/bind-dev-seed.ts` | `// <gen:realtime-handlers>` | `gen realtime handler` |

The CI guard at `packages/core-eslint/anchors.test.js` extends to assert these anchors stay present in every feature.

### 11.2 `pnpm turbo gen realtime channel`

```bash
pnpm turbo gen realtime --args channel <feature> <slug> <scope>
```

- Adds: `packages/<feature>/src/realtime/<slug>.channel.ts` + test
- Modifies: `src/index.ts` at `<gen:realtime-channels>` to re-export the descriptor

The `<scope>` arg accepts: `public | authenticated | role:<name> | user-scoped`.

### 11.3 `pnpm turbo gen realtime handler`

```bash
pnpm turbo gen realtime --args handler <feature> <channel-slug>
```

- Adds: `packages/<feature>/src/realtime/handlers/on-<channel>.handler.ts` + test
- Modifies: `src/di/symbols.ts` at `<gen:realtime-handler-symbols>` (handler symbol)
- Modifies: both `bind-production.ts` and `bind-dev-seed.ts` at `<gen:realtime-handlers>` (registers wrapped handler with the registry)

### 11.4 Notably absent

- **No generator for outbound broadcasts.** A use case that wants to broadcast adds `realtime: IRealtimeBroadcaster` to its factory deps and calls `realtime.broadcast(channel, payload)`. Pattern is identical to adding `bus: IEventBus` — manual edit, no scaffold.
- **No generator for bridge entries.** Each bridge mapping is an explicit, hand-edited line in `apps/web-next/src/server/bind-production.ts`. There aren't enough of these for a generator to pay off.
- **No generator for new core packages** (the `core-realtime` package itself was hand-built). The user has flagged a future capability for this — see follow-up #2.

---

## 12. Proof-of-life: `realtime-ping`

A minimal channel-pair built into `core-realtime` itself, exercised by the integration test in §10.3. Not exposed through any feature; not user-visible. Validates that the four checkpoints + the cookie-auth path all integrate cleanly.

```ts
// packages/core-realtime/src/realtime-ping.ts (or equivalent)
const pingSchema = z.object({ at: z.string().datetime() }).strict();
const pongSchema = z.object({ at: z.string().datetime(), echo: z.string() }).strict();

export const realtimePingChannel = defineRealtimeChannel(
  "realtime.ping",
  pingSchema,
  { scope: "authenticated" },
);

export const realtimePongChannel = defineRealtimeChannel(
  "realtime.pong",
  pongSchema,
  { scope: "authenticated" },
);

export const realtimePingHandler =
  (broadcaster: IRealtimeBroadcaster) =>
  async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
    await broadcaster.broadcast(realtimePongChannel, {
      at: input.at,
      echo: ctx.userId ?? "anonymous",
    });
  };
```

`SocketIORealtimeServer.start()` registers the ping handler unconditionally in v1 (env-gated `REALTIME_PING_DISABLED=true` to strip in production once the dashboard or another consumer ships).

---

## 13. v1 scope vs deferred

### In v1

- `@repo/core-realtime` package (interfaces, `SocketIORealtimeServer`, `SocketIORealtimeBroadcaster`, `InMemoryRealtimeBroadcaster`, `RealtimeHandlerRegistry`)
- Custom Node server `apps/web-next/server.ts` replacing `next start` / `next dev`
- `bindAll` extension: `resolveRealtime()` + `bindRealtimeBridge(bus, broadcaster, allowlist)` (allowlist empty)
- Cookie-session auth via `IRealtimeAuthenticator`
- Four scope kinds: `"public"`, `"authenticated"`, `{ role }`, `{ userScoped }`
- Two generators: `gen realtime channel`, `gen realtime handler`
- Three new anchors per feature + CI guard extension
- `RecordingRealtimeBroadcaster` test helper
- `realtime-ping` channel + `apps/web-next/src/__tests__/realtime-ping.test.ts` integration smoke
- ADR-016 documenting the design
- ESLint rules: `no-direct-socket-io`, `no-realtime-handler-reexport`

### Deferred

- Live observability dashboard + `admin-realtime` feature
- Concrete bridge consumers in feature packages (first one lands with the dashboard PR)
- Custom Node server for `cms` and `web-tanstack` apps
- DB-backed roles / permissions (see §14)
- `{ permission }` and `{ check: (user, params) => bool }` scope kinds (see §14)
- Production-mode e2e test with multiple connected sockets
- Multi-instance fanout (Redis adapter / sticky sessions)
- Generator for new core packages (separate brainstorm; see follow-up #2)

---

## 14. Future evolution: DB-backed roles + permissions

### 14.1 The migration is non-breaking

The design is structured so that introducing a roles/permissions DB layer doesn't require any change to `core-realtime` or to the realtime layer itself. The seam is `IRealtimeAuthenticator`:

- Today: `authenticate()` returns `{ userId, roles: [] }` (empty array, no role system yet)
- After the DB layer ships: `authenticate()` returns `{ userId, roles: ["admin", "editor"], permissions: [...] }` from a role + permissions lookup

The realtime server reads whatever the auth feature decides to expose. Same pattern as `IUsersRepository` — the data source is irrelevant to the consumer.

### 14.2 What lands when the DB layer ships

In the `auth` feature:

```
packages/auth/src/
├── application/
│   ├── repositories/
│   │   ├── users.repository.interface.ts        existing
│   │   └── roles.repository.interface.ts        NEW
│   └── services/
│       ├── authentication.service.interface.ts  existing
│       └── authorization.service.interface.ts   NEW (hasPermission, getRoles)
├── entities/models/
│   ├── user.ts                                  existing
│   ├── role.ts                                  NEW { name, permissions: string[] }
│   └── permission.ts                            NEW (typed string union)
├── infrastructure/repositories/
│   ├── users.repository.ts                      existing
│   └── roles.repository.ts                      NEW (Payload-backed)
└── integrations/cms/collections/
    └── roles.ts                                 NEW (Payload Roles collection)
```

The app-level `IRealtimeAuthenticator` impl widens to also load roles and permissions:

```ts
authenticate: async ({ cookies }) => {
  const session = await authService.validateSession(cookies[SESSION_COOKIE]);
  if (!session) return null;
  const roles = await authzService.getRolesForUser(session.userId);
  return {
    userId: session.userId,
    roles: roles.map(r => r.name),
    // future: also returning permissions for { permission } scope
  };
};
```

Two DB hits at connect (validate session + load roles), then nothing per-message — the snapshot lives on `socket.data.user`.

### 14.3 Scope union extends additively

```ts
// future shape — additive, no breaking change to existing channels
type ChannelScope =
  | "public"
  | "authenticated"
  | { role: string }                                   // unchanged
  | { permission: string }                             // NEW
  | { permissions: string[]; mode: "all" | "any" }     // NEW
  | { userScoped: true; template: string }             // unchanged
  | { check: (user, params) => Promise<boolean> };     // NEW (escape hatch)
```

`authorize()` gains one branch per new scope kind. Already-defined channels keep working unchanged.

### 14.4 Staleness and the refresh path

The connect-time snapshot is stale by definition: a user demoted while connected keeps the old roles until reconnect. For most apps this is acceptable. If it isn't, the existing event bus is the escape hatch:

- Auth feature publishes `auth.user.role-changed` on the bus when roles mutate
- A small `RealtimeAuthRefresher` (lives next to `bindRealtimeBridge` in the app layer) finds connected sockets for that user and re-fetches their roles
- No realtime-protocol change, no DB hit per message — just an event-driven invalidation triggered by the bus

This falls out for free from already having ADR-015's bus + the `userScoped` channel concept.

### 14.5 What does NOT change

- `core-realtime` package — zero edits
- Generators, anchors, CI guard — zero edits
- Existing channels with `{ role: "admin" }` — keep working

---

## 15. Open follow-ups (out of v1 plan scope)

Tracked here so they aren't forgotten:

1. **Production-mode e2e test.** §10.3 runs in dev-seed mode (`InMemoryEventBus` + in-process Socket.IO + dev-seeded session). A parallel test against a real Postgres-backed Payload would prove the cookie-auth path more thoroughly. Out of scope for v1 because it requires a Payload test fixture.

2. **Generic core-package generator.** The user has flagged this as a future capability: a `turbo gen core-package` with template variants (interface-and-adapters / utility / policy-only) so future core packages don't need to be hand-built. Saved as a project memory; brainstorm + spec separately after this lands.

3. **Bridge wildcard subscription.** v1 requires explicit `bus.subscribe(eventX, ...)` per bridged event. A wildcard `bus.subscribe("*", ...)` API on the bus would let the bridge forward every event without enumeration. Useful for the dashboard. Out of v1 because the bus doesn't expose a wildcard today; the dashboard PR would add it if needed.

4. **Multi-instance fanout.** When `apps/web-next` scales horizontally, broadcasts in one process don't reach sockets connected to another. Solved by Socket.IO's Redis adapter (sticky sessions for connection affinity, Redis pub/sub for cross-instance broadcast). Out of v1 because v1 ships single-instance.

5. **Generator for outbound broadcasts.** Currently a manual edit. If the pattern proliferates, a `gen realtime broadcast <feature> <channel-slug>` could automate the use-case dep + call-site addition. Out of v1 because the broadcast call itself is one line.

---

## 16. Self-review check

This spec was self-reviewed against the brainstorming dialogue on the date of writing. Key points verified:

- Every conversational decision (motivation, topology, framework choice, bus integration model, direction in v1, auth model, folder location, dashboard deferral, proof-of-life replacement) maps to a section above.
- All file paths are repo-relative; no "TBD" or placeholder text.
- The interface signatures in §3.3 are internally consistent with the file shapes in §5 and the `authorize` function in §7.3.
- Tests precede implementation per the TDD foundation (ADR-011); §10.3's integration test is the proof-of-life, written before the production code.
- v1 scope (§13) excludes everything the dialogue deferred; nothing shipped without a clear consumer or test exercise.
- The future-evolution section (§14) shows the DB-backed roles/permissions migration is non-breaking, addressing the user's question on 2026-05-08.
- ESLint boundary additions (§9) parallel ADR-015 patterns exactly.
