# Realtime layer (Socket.IO) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship a vendor-isolated bidirectional realtime layer over Socket.IO, with cookie-session auth, four channel scope kinds, a hybrid bus-bridge / direct-broadcaster model, two scaffolding generators, and a `realtime-ping` integration smoke test. Defers the observability dashboard, DB-backed roles/permissions, and multi-instance fanout to follow-up PRs.

**Architecture:** Mirrors the ADR-015 pattern. A new `@repo/core-realtime` package holds vendor-neutral interfaces (`IRealtimeBroadcaster`, `IRealtimeServer`, `IRealtimeAuthenticator`, `RealtimeChannelDescriptor`) plus a `SocketIORealtimeServer` adapter. Feature packages use the interfaces only — `socket.io` is ESLint-blocked outside `core-realtime` and the apps' custom Node servers. Auth runs at four lifecycle gates (connect / subscribe / inbound / broadcast); broadcast skips the gate because subscribe already filters who's in the room. Bus events forward to realtime channels through an explicit per-app allowlist (empty in v1; populated when the dashboard PR ships).

**Tech Stack:** TypeScript, Node 22, `socket.io` ^4, `socket.io-client` ^4, Zod, Vitest, ESLint, Turborepo, Inversify, Next.js, Payload CMS.

**Spec:** `docs/superpowers/specs/2026-05-08-realtime-design.md` — read first, especially §3 (interfaces), §7 (auth), §13 (v1 scope).

---

## Phase 0 — Read first

- [ ] **Step 1: Read the spec end-to-end**

Open `docs/superpowers/specs/2026-05-08-realtime-design.md` in your editor. The plan below maps to its sections roughly 1:1. The four checkpoints in §7, the authorize function in §7.3, the file shapes in §5, and the v1 scope in §13 are load-bearing references.

- [ ] **Step 2: Read ADR-015 (events and jobs)**

Open `docs/decisions/adr-015-events-and-jobs.md`. The realtime layer reuses the same patterns: vendor-isolated interface in core, recording test helper in core-testing, custom ESLint rule against direct vendor imports, anchor protocol for generators, span+capture sandwich at bind time. Familiarity halves the cognitive load.

- [ ] **Step 3: Skim the navigation feature for the canonical shape**

Open `packages/navigation/src/di/bind-production.ts` and `packages/navigation/src/di/bind-dev-seed.ts`. They show the post-ADR-015 binder shape `(config, tracer, logger, bus, queue)`. The realtime layer extends this signature to `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`.

---

## File map (summary)

```
packages/core-realtime/                          ← new package, tag: core
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── eslint.config.js
├── turbo.json
├── AGENTS.md
└── src/
    ├── index.ts
    ├── symbols.ts
    ├── realtime-channel.ts
    ├── realtime-broadcaster.interface.ts
    ├── realtime-handler.interface.ts
    ├── realtime-server.interface.ts
    ├── realtime-authenticator.interface.ts
    ├── realtime-handler-registry.ts
    ├── channel-template.ts
    ├── authorize.ts
    ├── in-memory-realtime-broadcaster.ts
    ├── socket-io-realtime-broadcaster.ts
    ├── socket-io-realtime-server.ts
    └── realtime-ping.ts                          ← proof-of-life

packages/core-testing/src/instrumentation/
└── recording-realtime-broadcaster.ts             ← test helper

packages/core-eslint/
├── rules/
│   ├── no-direct-socket-io.js                    ← new rule
│   └── no-realtime-handler-reexport.js           ← new rule
└── anchors.test.js                                ← extend with new anchors

packages/<feature>/src/                            ← all 5 features
├── index.ts                                       ← + // <gen:realtime-channels>
├── di/
│   ├── symbols.ts                                 ← + // <gen:realtime-handler-symbols>
│   ├── bind-production.ts                         ← signature ext + // <gen:realtime-handlers>
│   └── bind-dev-seed.ts                           ← same
└── (eventually) realtime/                         ← optional, generator-driven

apps/web-next/
├── server.ts                                      ← NEW: replaces `next start` / `next dev`
├── package.json                                   ← gain socket.io + scripts change
└── src/server/bind-production.ts                  ← + resolveRealtime() + bindRealtimeBridge()

turbo/generators/
├── config.ts                                      ← + realtime generator
├── lib/anchor-validate.ts                         ← already exists from ADR-015
└── templates/realtime/
    ├── channel/
    │   ├── channel.ts.hbs
    │   └── channel.test.ts.hbs
    └── handler/
        ├── handler.ts.hbs
        └── handler.test.ts.hbs

docs/
├── decisions/adr-016-realtime-layer.md           ← NEW
├── guides/realtime.md                             ← NEW
├── architecture/vertical-feature-spec.md          ← § 13 update
├── architecture/dependency-flow.md                ← bindAll diagram update
└── superpowers/specs/2026-05-08-realtime-design.md  ← already exists
```

---

## Phase 1 — Scaffold `@repo/core-realtime` package

### Task 1: Create the package skeleton

**Files:**
- Create: `packages/core-realtime/package.json`
- Create: `packages/core-realtime/tsconfig.json`
- Create: `packages/core-realtime/vitest.config.ts`
- Create: `packages/core-realtime/eslint.config.js`
- Create: `packages/core-realtime/turbo.json`
- Create: `packages/core-realtime/AGENTS.md`
- Create: `packages/core-realtime/src/index.ts` (empty for now; populated in Phase 2)

- [ ] **Step 1: Write `package.json`**

```json
{
  "name": "@repo/core-realtime",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "build": "tsc --noEmit",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "socket.io": "^4.7.0",
    "zod": "^3.23.0"
  },
  "peerDependencies": {
    "payload": "^3.0.0"
  },
  "peerDependenciesMeta": {
    "payload": { "optional": true }
  },
  "devDependencies": {
    "@repo/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/node": "^22.0.0",
    "typescript": "^5.8.0",
    "vitest": "^3.0.0"
  }
}
```

(No core-testing devDep — same lesson from ADR-015 Task 56. The setup file resolves via the workspace symlinks; we don't need it in deps.)

- [ ] **Step 2: Write `tsconfig.json`**

```json
{
  "extends": "@repo/core-typescript/tsconfig.base.json",
  "compilerOptions": {
    "rootDir": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src/**/*", "*.config.ts"]
}
```

- [ ] **Step 3: Write `vitest.config.ts`**

```ts
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";

export default mergeConfig(nodeVitestConfig, {
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
});
```

- [ ] **Step 4: Write `eslint.config.js`**

```js
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;
```

- [ ] **Step 5: Write `turbo.json` (boundary tag)**

```json
{
  "$schema": "https://turborepo.dev/schema.json",
  "extends": ["//"],
  "tags": ["core"]
}
```

- [ ] **Step 6: Write a stub `AGENTS.md`** (will be filled in once all the components land — keep this lean for now)

```markdown
# @repo/core-realtime

Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.

See `docs/superpowers/specs/2026-05-08-realtime-design.md` for the full design and `docs/decisions/adr-016-realtime-layer.md` for the rationale (added during implementation).

## Public exports

- `IRealtimeBroadcaster` — server → client broadcasts
- `IRealtimeServer` — lifecycle, used at app boot only
- `IRealtimeAuthenticator` — connect-time identity attachment (cookie / header → user)
- `IRealtimeHandlerRegistry` + `RealtimeHandlerRegistry` — inbound handler registration
- `defineRealtimeChannel`, `RealtimeChannelDescriptor`, `ChannelScope`
- `InMemoryRealtimeBroadcaster` (test/dev), `SocketIORealtimeBroadcaster`, `SocketIORealtimeServer` (production)
- `CORE_REALTIME_SYMBOLS`

## Boundary

Tagged `core`. The only place in the repo where `import "socket.io"` is allowed is `src/socket-io-*.ts` here, plus `apps/*/server.ts`. Enforced by the ESLint rule `core-eslint/no-direct-socket-io`.
```

- [ ] **Step 7: Write `src/index.ts`** (empty barrel — populated in Phase 2)

```ts
// Public barrel — populated as components land in Phase 2.
export {};
```

- [ ] **Step 8: Verify the package installs and lints**

Run: `pnpm install && pnpm --filter @repo/core-realtime lint typecheck`
Expected: PASS (zero TS errors, zero ESLint errors).

- [ ] **Step 9: Commit**

```bash
git add packages/core-realtime/
git commit -m "chore(core-realtime): scaffold package"
```

### Task 2: Tag `@repo/core-realtime` in the boundary map

**Files:**
- Modify: `packages/core-eslint/base.js` (or wherever the boundaries plugin tag map lives — check via `grep -n "boundaries" packages/core-eslint/*.js`)

- [ ] **Step 1: Find the tag map**

Run: `grep -n "tag\|boundaries" packages/core-eslint/base.js | head`
Expected: a configuration that lists each package and its tag (`core | core-composition | feature | tooling | app`).

- [ ] **Step 2: Add `@repo/core-realtime` as `core`**

Add to the appropriate section:

```js
{
  type: "core",
  pattern: "packages/core-realtime",
  mode: "folder",
}
```

- [ ] **Step 3: Verify**

Run: `pnpm turbo boundaries`
Expected: PASS — no new violations.

- [ ] **Step 4: Commit**

```bash
git add packages/core-eslint/
git commit -m "chore(core-eslint,turbo): tag @repo/core-realtime as core"
```

---

## Phase 2 — Interfaces + descriptors (TDD foundation)

### Task 3: Define `RealtimeChannelDescriptor` + `defineRealtimeChannel`

**Files:**
- Create: `packages/core-realtime/src/realtime-channel.ts`
- Create: `packages/core-realtime/src/realtime-channel.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-realtime/src/realtime-channel.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineRealtimeChannel } from "@/realtime-channel";

describe("defineRealtimeChannel", () => {
  it("returns a descriptor with name, schema, and scope", () => {
    const ch = defineRealtimeChannel(
      "test.channel",
      z.object({ id: z.string() }).strict(),
      { scope: "public" },
    );
    expect(ch.name).toBe("test.channel");
    expect(ch.scope).toBe("public");
    expect(() => ch.schema.parse({ id: "x" })).not.toThrow();
  });

  it("preserves all four scope shapes", () => {
    expect(defineRealtimeChannel("a", z.object({}), { scope: "public" }).scope).toBe("public");
    expect(defineRealtimeChannel("a", z.object({}), { scope: "authenticated" }).scope).toBe("authenticated");
    expect(defineRealtimeChannel("a", z.object({}), { scope: { role: "admin" } }).scope).toEqual({ role: "admin" });
    expect(
      defineRealtimeChannel("a", z.object({}), { scope: { userScoped: true, template: "x.{id}" } }).scope,
    ).toEqual({ userScoped: true, template: "x.{id}" });
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement**

```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 };
}
```

- [ ] **Step 4: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — both tests green.

- [ ] **Step 5: Commit**

```bash
git add packages/core-realtime/src/realtime-channel.ts packages/core-realtime/src/realtime-channel.test.ts
git commit -m "feat(core-realtime): RealtimeChannelDescriptor + defineRealtimeChannel"
```

### Task 4: Define `IRealtimeBroadcaster` interface + symbol

**Files:**
- Create: `packages/core-realtime/src/realtime-broadcaster.interface.ts`
- Create: `packages/core-realtime/src/symbols.ts`

- [ ] **Step 1: Write the interface**

```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>;
}
```

- [ ] **Step 2: Write the symbols registry**

```ts
// packages/core-realtime/src/symbols.ts
export const CORE_REALTIME_SYMBOLS = {
  IRealtimeBroadcaster: Symbol.for("core-realtime:IRealtimeBroadcaster"),
  IRealtimeServer: Symbol.for("core-realtime:IRealtimeServer"),
  IRealtimeAuthenticator: Symbol.for("core-realtime:IRealtimeAuthenticator"),
  IRealtimeHandlerRegistry: Symbol.for("core-realtime:IRealtimeHandlerRegistry"),
} as const;
```

- [ ] **Step 3: Verify typecheck**

Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/core-realtime/src/realtime-broadcaster.interface.ts packages/core-realtime/src/symbols.ts
git commit -m "feat(core-realtime): IRealtimeBroadcaster interface + symbol registry"
```

### Task 5: Define `IRealtimeHandler` + `IInboundDescriptor` types

**Files:**
- Create: `packages/core-realtime/src/realtime-handler.interface.ts`

- [ ] **Step 1: Write the types**

```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>>;
};
```

- [ ] **Step 2: Verify typecheck**

Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-realtime/src/realtime-handler.interface.ts
git commit -m "feat(core-realtime): IRealtimeHandler + IInboundDescriptor types"
```

### Task 6: Define `IRealtimeAuthenticator` interface

**Files:**
- Create: `packages/core-realtime/src/realtime-authenticator.interface.ts`

- [ ] **Step 1: Write the interface**

```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>;
}
```

- [ ] **Step 2: Verify typecheck**

Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-realtime/src/realtime-authenticator.interface.ts
git commit -m "feat(core-realtime): IRealtimeAuthenticator interface"
```

### Task 7: Implement channel-template matcher

**Files:**
- Create: `packages/core-realtime/src/channel-template.ts`
- Create: `packages/core-realtime/src/channel-template.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-realtime/src/channel-template.test.ts
import { describe, it, expect } from "vitest";
import { matchChannelTemplate } from "@/channel-template";

describe("matchChannelTemplate", () => {
  it("matches a plain channel name exactly", () => {
    expect(matchChannelTemplate("blog.feed", "blog.feed")).toEqual({ params: {} });
    expect(matchChannelTemplate("blog.feed", "blog.other")).toBeNull();
  });

  it("matches a templated channel and extracts params", () => {
    expect(
      matchChannelTemplate("notifications.user.{userId}", "notifications.user.user_42"),
    ).toEqual({ params: { userId: "user_42" } });
  });

  it("returns null when a templated channel doesn't match the shape", () => {
    expect(
      matchChannelTemplate("notifications.user.{userId}", "notifications.user"),
    ).toBeNull();
    expect(
      matchChannelTemplate("notifications.user.{userId}", "blog.feed"),
    ).toBeNull();
  });

  it("supports multiple placeholders", () => {
    expect(
      matchChannelTemplate(
        "rooms.{roomId}.user.{userId}",
        "rooms.r1.user.u1",
      ),
    ).toEqual({ params: { roomId: "r1", userId: "u1" } });
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/core-realtime/src/channel-template.ts
const PLACEHOLDER = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;

export function matchChannelTemplate(
  template: string,
  candidate: string,
): { params: Record<string, string> } | null {
  // No placeholders: exact match.
  if (!template.includes("{")) {
    return template === candidate ? { params: {} } : null;
  }

  // Build a regex from the template, replacing {name} with named groups.
  const names: string[] = [];
  const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); // escape regex specials
  // The above escapes `{` and `}` too — restore them around placeholders.
  const pattern = escaped.replace(/\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/g, (_m, name) => {
    names.push(name);
    return `([^.]+)`;
  });
  const re = new RegExp(`^${pattern}$`);
  const match = candidate.match(re);
  if (!match) return null;
  const params: Record<string, string> = {};
  names.forEach((name, i) => {
    params[name] = match[i + 1]!;
  });
  return { params };
}
```

- [ ] **Step 4: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — all four cases.

- [ ] **Step 5: Commit**

```bash
git add packages/core-realtime/src/channel-template.ts packages/core-realtime/src/channel-template.test.ts
git commit -m "feat(core-realtime): channel-template matcher (placeholder extraction)"
```

### Task 8: Implement the `authorize` function

**Files:**
- Create: `packages/core-realtime/src/authorize.ts`
- Create: `packages/core-realtime/src/authorize.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-realtime/src/authorize.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { authorize } from "@/authorize";
import { defineRealtimeChannel } from "@/realtime-channel";

const schema = z.object({}).strict();

describe("authorize", () => {
  describe("public", () => {
    const ch = defineRealtimeChannel("a", schema, { scope: "public" });
    it("allows anonymous", async () => {
      expect(await authorize(ch, {}, null)).toBe(true);
    });
    it("allows authenticated", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
    });
  });

  describe("authenticated", () => {
    const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" });
    it("rejects anonymous", async () => {
      expect(await authorize(ch, {}, null)).toBe(false);
    });
    it("allows any user", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
    });
  });

  describe("{ role }", () => {
    const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } });
    it("rejects anonymous", async () => {
      expect(await authorize(ch, {}, null)).toBe(false);
    });
    it("rejects user without role", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe(false);
    });
    it("allows user with role", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] })).toBe(true);
    });
  });

  describe("{ userScoped }", () => {
    const ch = defineRealtimeChannel("a", schema, {
      scope: { userScoped: true, template: "notifications.user.{userId}" },
    });
    it("rejects anonymous", async () => {
      expect(await authorize(ch, { userId: "u1" }, null)).toBe(false);
    });
    it("rejects user requesting someone else's channel", async () => {
      expect(
        await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }),
      ).toBe(false);
    });
    it("allows user requesting own channel", async () => {
      expect(
        await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }),
      ).toBe(true);
    });
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/core-realtime/src/authorize.ts
import type { z } from "zod";
import type { 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;
}
```

- [ ] **Step 4: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — all 9 cases.

- [ ] **Step 5: Commit**

```bash
git add packages/core-realtime/src/authorize.ts packages/core-realtime/src/authorize.test.ts
git commit -m "feat(core-realtime): authorize function (4 scope kinds)"
```

### Task 9: Implement `RealtimeHandlerRegistry`

**Files:**
- Create: `packages/core-realtime/src/realtime-handler-registry.ts`
- Create: `packages/core-realtime/src/realtime-handler-registry.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-realtime/src/realtime-handler-registry.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
import { defineRealtimeChannel } from "@/realtime-channel";

const ch = defineRealtimeChannel(
  "test.ch",
  z.object({ x: z.number() }).strict(),
  { scope: "authenticated" },
);

describe("RealtimeHandlerRegistry", () => {
  it("registers and retrieves a handler by channel name", () => {
    const reg = new RealtimeHandlerRegistry();
    const handler = vi.fn();
    reg.register({ descriptor: ch, handler });
    const got = reg.getInboundDescriptor("test.ch");
    expect(got).not.toBeNull();
    expect(got!.descriptor.name).toBe("test.ch");
    expect(got!.handler).toBe(handler);
  });

  it("returns null for unknown channel name", () => {
    const reg = new RealtimeHandlerRegistry();
    expect(reg.getInboundDescriptor("unknown")).toBeNull();
  });

  it("list() returns all registered descriptors", () => {
    const reg = new RealtimeHandlerRegistry();
    reg.register({ descriptor: ch, handler: vi.fn() });
    expect(reg.list()).toHaveLength(1);
    expect(reg.list()[0]!.descriptor.name).toBe("test.ch");
  });

  it("re-registering the same channel replaces the previous entry", () => {
    const reg = new RealtimeHandlerRegistry();
    const h1 = vi.fn();
    const h2 = vi.fn();
    reg.register({ descriptor: ch, handler: h1 });
    reg.register({ descriptor: ch, handler: h2 });
    expect(reg.getInboundDescriptor("test.ch")!.handler).toBe(h2);
    expect(reg.list()).toHaveLength(1);
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/core-realtime/src/realtime-handler-registry.ts
import type { z } from "zod";
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 {
  private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();

  register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
    this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
  }

  getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
    return this.entries.get(channelName) ?? null;
  }

  list(): IInboundDescriptor<string, z.ZodType>[] {
    return Array.from(this.entries.values());
  }
}
```

- [ ] **Step 4: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — all 4 cases.

- [ ] **Step 5: Commit**

```bash
git add packages/core-realtime/src/realtime-handler-registry.ts packages/core-realtime/src/realtime-handler-registry.test.ts
git commit -m "feat(core-realtime): RealtimeHandlerRegistry"
```

### Task 10: Implement `InMemoryRealtimeBroadcaster` (test/dev impl)

**Files:**
- Create: `packages/core-realtime/src/in-memory-realtime-broadcaster.ts`
- Create: `packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster";
import { defineRealtimeChannel } from "@/realtime-channel";

const ch = defineRealtimeChannel(
  "a.b",
  z.object({ x: z.number() }).strict(),
  { scope: "public" },
);

describe("InMemoryRealtimeBroadcaster", () => {
  it("validates payload via the descriptor schema", async () => {
    const b = new InMemoryRealtimeBroadcaster();
    await expect(
      b.broadcast(ch, { x: "not a number" } as never),
    ).rejects.toThrow();
  });

  it("delivers to subscribers in order", async () => {
    const b = new InMemoryRealtimeBroadcaster();
    const got: number[] = [];
    b.subscribe(ch, async (p) => { got.push(p.x); });
    await b.broadcast(ch, { x: 1 });
    await b.broadcast(ch, { x: 2 });
    expect(got).toEqual([1, 2]);
  });

  it("does nothing when no subscribers", async () => {
    const b = new InMemoryRealtimeBroadcaster();
    await b.broadcast(ch, { x: 1 });
    // does not throw
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.

- [ ] **Step 3: Implement**

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

type Listener<T> = (payload: T) => Promise<void> | void;

export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster {
  private readonly listeners = new Map<string, Listener<unknown>[]>();

  async broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    const arr = this.listeners.get(descriptor.name) ?? [];
    for (const l of arr) await l(payload);
  }

  // Test-friendly: lets unit tests subscribe directly without a Socket.IO server.
  subscribe<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    listener: Listener<T>,
  ): void {
    const arr = this.listeners.get(descriptor.name) ?? [];
    arr.push(listener as Listener<unknown>);
    this.listeners.set(descriptor.name, arr);
  }
}
```

- [ ] **Step 4: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-realtime/src/in-memory-realtime-broadcaster.ts packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts
git commit -m "feat(core-realtime): InMemoryRealtimeBroadcaster (test/dev impl)"
```

### Task 11: Define `IRealtimeServer` interface

**Files:**
- Create: `packages/core-realtime/src/realtime-server.interface.ts`

- [ ] **Step 1: Write the interface**

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

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

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

- [ ] **Step 2: Verify typecheck**

Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-realtime/src/realtime-server.interface.ts
git commit -m "feat(core-realtime): IRealtimeServer interface"
```

### Task 12: Implement `SocketIORealtimeBroadcaster`

**Files:**
- Create: `packages/core-realtime/src/socket-io-realtime-broadcaster.ts`
- Create: `packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts`

- [ ] **Step 1: Write the failing test (with a stub Socket.IO server)**

```ts
// packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { SocketIORealtimeBroadcaster } from "@/socket-io-realtime-broadcaster";
import { defineRealtimeChannel } from "@/realtime-channel";

const ch = defineRealtimeChannel(
  "a.b",
  z.object({ x: z.number() }).strict(),
  { scope: "public" },
);

describe("SocketIORealtimeBroadcaster", () => {
  it("emits to the channel's room with the channel name as event", async () => {
    const emit = vi.fn();
    const to = vi.fn(() => ({ emit }));
    const io = { to } as never;
    const b = new SocketIORealtimeBroadcaster(io);
    await b.broadcast(ch, { x: 1 });
    expect(to).toHaveBeenCalledWith("ch:a.b");
    expect(emit).toHaveBeenCalledWith("a.b", { x: 1 });
  });

  it("validates payload before emitting", async () => {
    const emit = vi.fn();
    const to = vi.fn(() => ({ emit }));
    const io = { to } as never;
    const b = new SocketIORealtimeBroadcaster(io);
    await expect(
      b.broadcast(ch, { x: "not a number" } as never),
    ).rejects.toThrow();
    expect(emit).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/core-realtime/src/socket-io-realtime-broadcaster.ts
import type { Server as IOServer } from "socket.io";
import type { z } from "zod";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export class SocketIORealtimeBroadcaster implements IRealtimeBroadcaster {
  constructor(private readonly io: IOServer) {}

  async broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    this.io.to(`ch:${descriptor.name}`).emit(descriptor.name, payload);
  }
}
```

- [ ] **Step 4: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-realtime/src/socket-io-realtime-broadcaster.ts packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts
git commit -m "feat(core-realtime): SocketIORealtimeBroadcaster"
```

### Task 13: Implement `SocketIORealtimeServer`

**Files:**
- Create: `packages/core-realtime/src/socket-io-realtime-server.ts`
- Create: `packages/core-realtime/src/socket-io-realtime-server.test.ts`

This is the largest single component. The test exercises the full lifecycle (connect → subscribe → inbound) against a real `socket.io-server` + `socket.io-client` pair on a transient port.

- [ ] **Step 1: Add `socket.io-client` to devDependencies**

Edit `packages/core-realtime/package.json`:

```json
"devDependencies": {
  "@repo/core-eslint": "workspace:*",
  "@repo/core-typescript": "workspace:*",
  "@types/node": "^22.0.0",
  "socket.io-client": "^4.7.0",
  "typescript": "^5.8.0",
  "vitest": "^3.0.0"
}
```

Run: `pnpm install`

- [ ] **Step 2: Write the failing integration test**

```ts
// packages/core-realtime/src/socket-io-realtime-server.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient, type Socket as ClientSocket } from "socket.io-client";
import type { AddressInfo } from "node:net";
import { SocketIORealtimeServer } from "@/socket-io-realtime-server";
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
import { defineRealtimeChannel } from "@/realtime-channel";

const pingChannel = defineRealtimeChannel(
  "test.ping",
  z.object({ at: z.string() }).strict(),
  { scope: "authenticated" },
);

describe("SocketIORealtimeServer", () => {
  let httpServer: HttpServer;
  let io: IOServer;
  let server: SocketIORealtimeServer;
  let port: number;

  beforeEach(async () => {
    httpServer = createServer();
    io = new IOServer(httpServer);
    const registry = new RealtimeHandlerRegistry();

    let received: { input: unknown; ctx: unknown } | null = null;
    registry.register({
      descriptor: pingChannel,
      handler: async (input, ctx) => {
        received = { input, ctx };
      },
    });

    server = new SocketIORealtimeServer({
      httpServer,
      io,
      authenticator: {
        authenticate: async ({ cookies }) => {
          if (cookies.session === "valid") return { userId: "u1", roles: [] };
          return null;
        },
      },
      registry,
    });
    await server.start();

    await new Promise<void>((resolve) => httpServer.listen(0, resolve));
    port = (httpServer.address() as AddressInfo).port;

    // expose received via a closure for the assertions
    (server as unknown as { received: typeof received }).received = received;
  });

  afterEach(async () => {
    await server.stop();
    httpServer.close();
  });

  it("rejects subscribe to authenticated channel from anonymous socket", async () => {
    const client = ioClient(`http://localhost:${port}`);
    await new Promise<void>((r) => client.on("connect", () => r()));

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("forbidden");
    client.disconnect();
  });

  it("allows subscribe + invokes handler with ctx for authenticated socket", async () => {
    const client = ioClient(`http://localhost:${port}`, {
      extraHeaders: { Cookie: "session=valid" },
    });
    await new Promise<void>((r) => client.on("connect", () => r()));

    const subAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );
    expect(subAck.ok).toBe(true);

    const sentAt = new Date().toISOString();
    const ack = await new Promise<{ ok: boolean }>((r) =>
      client.emit("test.ping", { at: sentAt }, r),
    );

    expect(ack.ok).toBe(true);
    client.disconnect();
  });

  it("rejects unknown channel subscribe", async () => {
    const client = ioClient(`http://localhost:${port}`);
    await new Promise<void>((r) => client.on("connect", () => r()));

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "does.not.exist", r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("unknown_channel");
    client.disconnect();
  });

  it("rejects malformed inbound input", async () => {
    const client = ioClient(`http://localhost:${port}`, {
      extraHeaders: { Cookie: "session=valid" },
    });
    await new Promise<void>((r) => client.on("connect", () => r()));
    await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("test.ping", { at: 123 } as never, r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("invalid_input");
    client.disconnect();
  });
});
```

- [ ] **Step 3: Run; expect FAIL**

Run: `pnpm --filter @repo/core-realtime test socket-io-realtime-server`
Expected: FAIL.

- [ ] **Step 4: Implement**

```ts
// packages/core-realtime/src/socket-io-realtime-server.ts
import type { Server as HttpServer } from "node:http";
import type { Server as IOServer, Socket } from "socket.io";
import { authorize } from "./authorize";
import { matchChannelTemplate } from "./channel-template";
import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";

function parseCookies(header: string): Record<string, string> {
  const out: Record<string, string> = {};
  for (const part of header.split(";")) {
    const [k, ...rest] = part.trim().split("=");
    if (k) out[k] = decodeURIComponent(rest.join("="));
  }
  return out;
}

export class SocketIORealtimeServer implements IRealtimeServer {
  private readonly httpServer: HttpServer;
  private readonly io: IOServer;
  private readonly opts: IRealtimeServerOptions;

  constructor(opts: IRealtimeServerOptions) {
    this.opts = opts;
    this.httpServer = opts.httpServer;
    this.io = opts.io;
  }

  async start(): Promise<void> {
    const { authenticator, registry } = this.opts;

    // Gate 1: connect — read cookie, authenticate, attach user.
    this.io.use(async (socket, next) => {
      const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
      socket.data.user = await authenticator.authenticate({
        cookies,
        headers: socket.handshake.headers as Record<string, string>,
      });
      next();
    });

    this.io.on("connection", (socket: Socket) => {
      // Gate 2: subscribe.
      socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
        // Find a registered descriptor whose name (or template) matches requestedName.
        let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null;
        for (const entry of registry.list()) {
          const m = matchChannelTemplate(entry.descriptor.name, requestedName);
          if (m) {
            matched = { descriptor: entry.descriptor, params: m.params };
            break;
          }
        }
        if (!matched) {
          ack?.({ ok: false, error: "unknown_channel" });
          return;
        }

        const allowed = await authorize(
          matched.descriptor as never,
          matched.params,
          socket.data.user ?? null,
        );
        if (!allowed) {
          ack?.({ ok: false, error: "forbidden" });
          return;
        }

        socket.join(`ch:${requestedName}`);
        ack?.({ ok: true });
      });

      // Gate 3: inbound — one listener per registered channel.
      for (const entry of registry.list()) {
        socket.on(entry.descriptor.name, async (payload: unknown, ack?: (r: unknown) => void) => {
          const parsed = entry.descriptor.schema.safeParse(payload);
          if (!parsed.success) {
            ack?.({ ok: false, error: "invalid_input" });
            return;
          }
          const allowed = await authorize(entry.descriptor, {}, socket.data.user ?? null);
          if (!allowed) {
            ack?.({ ok: false, error: "forbidden" });
            return;
          }
          try {
            await entry.handler(parsed.data, {
              userId: socket.data.user?.userId ?? null,
              roles: socket.data.user?.roles ?? [],
            });
            ack?.({ ok: true });
          } catch {
            ack?.({ ok: false, error: "handler_error" });
          }
        });
      }
    });
  }

  async stop(): Promise<void> {
    this.io.close();
  }
}
```

- [ ] **Step 5: Run; expect PASS**

Run: `pnpm --filter @repo/core-realtime test socket-io-realtime-server`
Expected: PASS — all 4 cases.

- [ ] **Step 6: Commit**

```bash
git add packages/core-realtime/src/socket-io-realtime-server.ts packages/core-realtime/src/socket-io-realtime-server.test.ts packages/core-realtime/package.json pnpm-lock.yaml
git commit -m "feat(core-realtime): SocketIORealtimeServer (4 lifecycle gates)"
```

### Task 14: Public barrel

**Files:**
- Modify: `packages/core-realtime/src/index.ts`

- [ ] **Step 1: Replace the empty barrel with the full surface**

```ts
// packages/core-realtime/src/index.ts
export type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel";
export { defineRealtimeChannel } from "./realtime-channel";
export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
export type { IRealtimeHandler, IInboundDescriptor, RealtimeContext } 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 { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster";
export { SocketIORealtimeServer } from "./socket-io-realtime-server";
export { authorize } from "./authorize";
export { matchChannelTemplate } from "./channel-template";
```

- [ ] **Step 2: Verify**

Run: `pnpm --filter @repo/core-realtime typecheck test`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-realtime/src/index.ts
git commit -m "feat(core-realtime): public barrel"
```

---

## Phase 3 — Recording test helper

### Task 15: `RecordingRealtimeBroadcaster` in core-testing

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts`
- Create: `packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts`
- Modify: `packages/core-testing/src/instrumentation/index.ts`

Critical: use the **local-type-alias pattern** from ADR-015 — do NOT import `IRealtimeBroadcaster` from `@repo/core-realtime`, that causes a build-graph cycle (core-testing is `tooling`-tagged, core-realtime is `core`-tagged, and turbo's typecheck graph would walk the dep both ways).

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { RecordingRealtimeBroadcaster } from "@/instrumentation/recording-realtime-broadcaster";

const ch = {
  name: "test.ch" as const,
  schema: z.object({ x: z.number() }).strict(),
  scope: "public" as const,
};

describe("RecordingRealtimeBroadcaster", () => {
  it("records every broadcast call after schema validation", async () => {
    const b = new RecordingRealtimeBroadcaster();
    await b.broadcast(ch, { x: 1 });
    await b.broadcast(ch, { x: 2 });
    expect(b.broadcasts).toEqual([
      { channel: "test.ch", payload: { x: 1 } },
      { channel: "test.ch", payload: { x: 2 } },
    ]);
  });

  it("rejects payloads that fail schema validation", async () => {
    const b = new RecordingRealtimeBroadcaster();
    await expect(b.broadcast(ch, { x: "wrong" } as never)).rejects.toThrow();
    expect(b.broadcasts).toHaveLength(0);
  });
});
```

- [ ] **Step 2: Run; expect FAIL**

Run: `pnpm --filter @repo/core-testing test recording-realtime`
Expected: FAIL.

- [ ] **Step 3: Implement (with local type aliases — DO NOT import from @repo/core-realtime)**

```ts
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts
// Local type aliases mirroring @repo/core-realtime's contracts. Kept inline to
// avoid a build-graph cycle between core-testing (tooling) and core-realtime (core).
// Same pattern recording-event-bus + recording-job-queue use.
import type { z } from "zod";

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: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    this.broadcasts.push({ channel: descriptor.name, payload });
  }
}
```

- [ ] **Step 4: Add to the instrumentation barrel**

Modify `packages/core-testing/src/instrumentation/index.ts`:

```ts
export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger";
export { RecordingJobQueue } from "./recording-job-queue";
export { RecordingEventBus } from "./recording-event-bus";
export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster";
```

- [ ] **Step 5: Run; expect PASS**

Run: `pnpm --filter @repo/core-testing test`
Expected: PASS — every existing test plus the 2 new cases.

- [ ] **Step 6: Verify boundaries still pass**

Run: `pnpm turbo boundaries`
Expected: PASS — no new cycle (we're using local type aliases, no actual import of `@repo/core-realtime`).

- [ ] **Step 7: Commit**

```bash
git add packages/core-testing/src/instrumentation/
git commit -m "feat(core-testing): RecordingRealtimeBroadcaster (local-type-alias pattern)"
```

---

## Phase 4 — ESLint rules

### Task 16: Write `no-direct-socket-io` rule

**Files:**
- Create: `packages/core-eslint/rules/no-direct-socket-io.js`
- Create: `packages/core-eslint/rules/no-direct-socket-io.test.js`

Mirrors `no-direct-payload-jobs` from ADR-015. Read that rule first for pattern: `packages/core-eslint/rules/no-direct-payload-jobs.js`.

- [ ] **Step 1: Write the failing test**

```js
// packages/core-eslint/rules/no-direct-socket-io.test.js
import { RuleTester } from "eslint";
import rule from "./no-direct-socket-io.js";

const tester = new RuleTester({
  languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});

tester.run("no-direct-socket-io", rule, {
  valid: [
    // Allowed inside core-realtime
    { code: 'import { Server } from "socket.io";', filename: "/repo/packages/core-realtime/src/socket-io-realtime-server.ts" },
    // Allowed in app servers
    { code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/server.ts" },
    // Allowed elsewhere when not importing socket.io
    { code: 'import { foo } from "bar";', filename: "/repo/packages/blog/src/foo.ts" },
  ],
  invalid: [
    {
      code: 'import { Server } from "socket.io";',
      filename: "/repo/packages/blog/src/foo.ts",
      errors: [{ messageId: "noDirectSocketIO" }],
    },
    {
      code: 'import { io } from "socket.io-client";',
      filename: "/repo/packages/blog/src/ui/Component.tsx",
      errors: [{ messageId: "noDirectSocketIOClient" }],
    },
  ],
});
```

- [ ] **Step 2: Implement**

```js
// packages/core-eslint/rules/no-direct-socket-io.js
const ALLOWED = [
  /\/packages\/core-realtime\/src\//,
  /\/apps\/[^/]+\/server\.ts$/,
];

export default {
  meta: {
    type: "problem",
    docs: { description: "Block direct socket.io imports outside core-realtime + app servers" },
    messages: {
      noDirectSocketIO: 'Import from "@repo/core-realtime" instead of "socket.io". Direct imports allowed only in packages/core-realtime/src/ and apps/*/server.ts.',
      noDirectSocketIOClient: 'Use the realtime helpers from "@repo/core-realtime" / "@repo/core-testing/instrumentation" instead of "socket.io-client".',
    },
    schema: [],
  },
  create(context) {
    const filename = context.filename ?? context.getFilename();
    const allowed = ALLOWED.some((re) => re.test(filename));
    if (allowed) return {};

    return {
      ImportDeclaration(node) {
        const source = node.source.value;
        if (source === "socket.io") {
          context.report({ node, messageId: "noDirectSocketIO" });
        } else if (source === "socket.io-client") {
          context.report({ node, messageId: "noDirectSocketIOClient" });
        }
      },
    };
  },
};
```

- [ ] **Step 3: Wire into the ESLint config**

Find where `no-direct-payload-jobs` is registered (probably `packages/core-eslint/base.js`). Add the same registration for `no-direct-socket-io`. Apply the rule to all packages by default.

- [ ] **Step 4: Run rule's own tests**

Run: `pnpm --filter @repo/core-eslint test`
Expected: PASS.

- [ ] **Step 5: Run workspace lint to confirm no new violations**

Run: `pnpm lint`
Expected: PASS — no feature package imports `socket.io` yet.

- [ ] **Step 6: Commit**

```bash
git add packages/core-eslint/
git commit -m "feat(core-eslint): rule no-direct-socket-io"
```

### Task 17: Write `no-realtime-handler-reexport` rule

**Files:**
- Create: `packages/core-eslint/rules/no-realtime-handler-reexport.js`
- Create: `packages/core-eslint/rules/no-realtime-handler-reexport.test.js`

Parallel to ADR-015's `no-handler-reexport`. Read that rule first; this is the same pattern targeting `realtime/handlers/` instead of `events/handlers/`.

- [ ] **Step 1: Write the failing test**

```js
// packages/core-eslint/rules/no-realtime-handler-reexport.test.js
import { RuleTester } from "eslint";
import rule from "./no-realtime-handler-reexport.js";

const tester = new RuleTester({
  languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});

tester.run("no-realtime-handler-reexport", rule, {
  valid: [
    // Importing a handler from inside a feature's bind-* file is allowed.
    {
      code: 'import { onPingHandler } from "../realtime/handlers/on-ping.handler";',
      filename: "/repo/packages/blog/src/di/bind-production.ts",
    },
    // Re-exporting a channel descriptor is allowed.
    {
      code: 'export { presenceChannel } from "./realtime/presence.channel";',
      filename: "/repo/packages/blog/src/index.ts",
    },
  ],
  invalid: [
    // Re-exporting a handler from any non-bind file is forbidden.
    {
      code: 'export { onPingHandler } from "./realtime/handlers/on-ping.handler";',
      filename: "/repo/packages/blog/src/index.ts",
      errors: [{ messageId: "noRealtimeHandlerReexport" }],
    },
    {
      code: 'export * from "./handlers/on-ping.handler";',
      filename: "/repo/packages/blog/src/realtime/index.ts",
      errors: [{ messageId: "noRealtimeHandlerReexport" }],
    },
  ],
});
```

- [ ] **Step 2: Implement (mirror the existing `no-handler-reexport` rule)**

Read `packages/core-eslint/rules/no-handler-reexport.js` and adapt: change the path matcher from `events/handlers/` to `realtime/handlers/`, and the message ID accordingly.

- [ ] **Step 3: Wire into ESLint config**

Add to `packages/core-eslint/base.js` next to `no-handler-reexport`.

- [ ] **Step 4: Run rule's tests + workspace lint**

Run: `pnpm --filter @repo/core-eslint test && pnpm lint`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/
git commit -m "feat(core-eslint): rule no-realtime-handler-reexport"
```

---

## Phase 5 — Anchor retrofit + CI guard

### Task 18: Retrofit anchors into `auth`

**Files:**
- Modify: `packages/auth/src/index.ts`
- Modify: `packages/auth/src/di/symbols.ts`
- Modify: `packages/auth/src/di/bind-production.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.ts`

Three new anchors. Same protocol as ADR-015. The anchors are inert until generators target them.

- [ ] **Step 1: Add `// <gen:realtime-channels>` to `src/index.ts`**

Place at the end of the file (mirror where `<gen:events>` sits — should be the last line).

- [ ] **Step 2: Add `// <gen:realtime-handler-symbols>` to `src/di/symbols.ts`**

Place inside the `AUTH_SYMBOLS` object next to the existing `<gen:event-handler-symbols>` and `<gen:job-symbols>` lines.

- [ ] **Step 3: Add `// <gen:realtime-handlers>` to both `bind-production.ts` and `bind-dev-seed.ts`**

Place at the end of the function body, just before the closing `}` and after the existing `<gen:event-handlers>` and `<gen:jobs>` anchor comments.

- [ ] **Step 4: Verify**

Run: `pnpm --filter @repo/auth typecheck lint test`
Expected: PASS — anchors are comments, no behavior change.

- [ ] **Step 5: Commit**

```bash
git add packages/auth/
git commit -m "chore(auth): add // <gen:realtime-*> anchor comments"
```

### Task 19: Retrofit anchors into `blog`

Repeat Task 18 for `packages/blog/`.

Commit: `chore(blog): add // <gen:realtime-*> anchor comments`

### Task 20: Retrofit anchors into `media`

Repeat Task 18 for `packages/media/`.

Commit: `chore(media): add // <gen:realtime-*> anchor comments`

### Task 21: Retrofit anchors into `marketing-pages`

Repeat Task 18 for `packages/marketing-pages/`.

Commit: `chore(marketing-pages): add // <gen:realtime-*> anchor comments`

### Task 22: Retrofit anchors into `navigation`

Repeat Task 18 for `packages/navigation/`.

Commit: `chore(navigation): add // <gen:realtime-*> anchor comments`

### Task 23: Extend the anchor-presence CI guard

**Files:**
- Modify: `packages/core-eslint/anchors.test.js`

- [ ] **Step 1: Add new anchors to the assertion table**

Append to the `ANCHORS` map in the test file:

```js
"src/index.ts": [
  "// <gen:events>",
  "// <gen:realtime-channels>",        // NEW
],
"src/di/symbols.ts": [
  "// <gen:event-handler-symbols>",
  "// <gen:job-symbols>",
  "// <gen:realtime-handler-symbols>", // NEW
],
"src/di/bind-production.ts": [
  "// <gen:event-handlers>",
  "// <gen:jobs>",
  "// <gen:realtime-handlers>",        // NEW
],
"src/di/bind-dev-seed.ts": [
  "// <gen:event-handlers>",
  "// <gen:jobs>",
  "// <gen:realtime-handlers>",        // NEW
],
```

- [ ] **Step 2: Run the guard**

Run: `pnpm --filter @repo/core-eslint test`
Expected: PASS — every feature has every anchor.

- [ ] **Step 3: Commit**

```bash
git add packages/core-eslint/anchors.test.js
git commit -m "test(core-eslint): extend anchor-presence guard for realtime anchors"
```

### Task 24: Add anchors to the `feature` generator template

**Files:**
- Modify: `turbo/generators/templates/feature/src/index.ts.hbs`
- Modify: `turbo/generators/templates/feature/src/di/symbols.ts.hbs`
- Modify: `turbo/generators/templates/feature/src/di/bind-production.ts.hbs`
- Modify: `turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs`

Add the same three new anchors to the `feature` generator's templates so newly-scaffolded features ship with them. Same shape as Task 18 — exact placement, just in `.hbs` files.

- [ ] **Step 1: Edit each template**

Add the same anchor comments at the same positions (end of file / inside symbols object / end of bind function body).

- [ ] **Step 2: Verify**

Run: `cd turbo/generators && npx tsc --noEmit`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add turbo/generators/templates/feature/
git commit -m "chore(generators): add // <gen:realtime-*> anchors to feature template"
```

---

## Phase 6 — Per-feature binder signature extension

### Task 25: Extend `bindProductionAuth` and `bindDevSeedAuth` signatures

**Files:**
- Modify: `packages/auth/src/di/bind-production.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.test.ts`
- Modify: `packages/auth/package.json`

Per the spec §5.4, every feature binder grows from 5-arg `(config, tracer, logger, bus, queue)` to 7-arg `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. Body does NOT yet use `realtime` / `realtimeRegistry` — Phase 8 generators inject usage. Accept-and-forward, mirror the ADR-015 Phase 6 pattern.

- [ ] **Step 1: Add `@repo/core-realtime` to `packages/auth/package.json` dependencies**

```json
"dependencies": {
  "@repo/core-events": "workspace:*",
  "@repo/core-realtime": "workspace:*",
  "@repo/core-shared": "workspace:*",
  ...
}
```

Run: `pnpm install`

- [ ] **Step 2: Modify `bind-production.ts`**

Add imports:

```ts
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";
```

Extend signature:

```ts
export function bindProductionAuth(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
  realtime: IRealtimeBroadcaster,
  realtimeRegistry: IRealtimeHandlerRegistry,
): void {
  // existing body unchanged
  // before the closing brace, just below the existing void bus/queue lines:
  void realtime;
  void realtimeRegistry;
  // <gen:event-handlers>
  // <gen:jobs>
  // <gen:realtime-handlers>
}
```

- [ ] **Step 3: Modify `bind-dev-seed.ts` similarly**

Same imports + signature extension. Same `void` no-ops.

- [ ] **Step 4: Update `bind-dev-seed.test.ts`**

Import `RecordingRealtimeBroadcaster` and `RealtimeHandlerRegistry`, pass them through:

```ts
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
import { RealtimeHandlerRegistry } from "@repo/core-realtime";

// inside each test:
await bindDevSeedAuth(
  noop.tracer,
  noop.logger,
  new RecordingEventBus(),
  new RecordingJobQueue(),
  new RecordingRealtimeBroadcaster(),
  new RealtimeHandlerRegistry(),
);
```

- [ ] **Step 5: Verify**

Run: `pnpm --filter @repo/auth typecheck lint test`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add packages/auth/ pnpm-lock.yaml
git commit -m "feat(auth): bind binders accept (realtime, realtimeRegistry) params"
```

### Task 26: Extend `blog` binder signatures

Repeat Task 25 for `packages/blog/`.

Commit: `feat(blog): bind binders accept (realtime, realtimeRegistry) params`

### Task 27: Extend `media` binder signatures

Repeat Task 25 for `packages/media/`.

Commit: `feat(media): bind binders accept (realtime, realtimeRegistry) params`

### Task 28: Extend `marketing-pages` binder signatures

Repeat Task 25 for `packages/marketing-pages/`.

Commit: `feat(marketing-pages): bind binders accept (realtime, realtimeRegistry) params`

### Task 29: Extend `navigation` binder signatures

Repeat Task 25 for `packages/navigation/`.

Commit: `feat(navigation): bind binders accept (realtime, realtimeRegistry) params`

### Task 30: Update default DI module fallbacks

**Files:**
- Modify: `packages/<feature>/src/di/module.ts` for each feature whose `module.ts` resolves use cases via `.toDynamicValue()`.

ADR-015 added `new InMemoryEventBus()` to `signUpUseCase`'s default fallback in `packages/auth/src/di/module.ts` for the same reason. Repeat for any use case that now takes `realtime` as a factory dep — but in v1, **no use case takes `realtime` yet** (Phase 8 generators inject it). Skip this task in v1; it will land alongside the first feature that uses `realtime` directly. Confirm by running `grep -rn 'signUpUseCase\|getArticlesUseCase' packages/*/src/di/module.ts` and verifying no factory call has more than 5 arguments. If any do, add `new InMemoryRealtimeBroadcaster()` per-resolution.

This task may be a no-op; that's fine. Mark it done after the verification step.

- [ ] **Verify and confirm no-op (or fix if needed)**

Run: `grep -rn 'UseCase(' packages/*/src/di/module.ts | grep -v test`
Expected: every factory call has ≤ 5 args. If any has 6+ that include `realtime`, bind a fresh `InMemoryRealtimeBroadcaster` from `@repo/core-realtime` per resolution.

- [ ] **Commit (only if any module.ts changed)**

```bash
git add packages/*/src/di/module.ts
git commit -m "feat(auth,blog,...): default DI module supplies in-memory realtime broadcaster"
```

---

## Phase 7 — Custom Node server + bindAll wiring

### Task 31: Replace `next dev` / `next start` with a custom server in apps/web-next

**Files:**
- Create: `apps/web-next/server.ts`
- Modify: `apps/web-next/package.json` (scripts, dependencies)

- [ ] **Step 1: Add socket.io to apps/web-next dependencies**

```json
"dependencies": {
  "@repo/auth": "workspace:*",
  "@repo/blog": "workspace:*",
  "@repo/core-api": "workspace:*",
  "@repo/core-cms": "workspace:*",
  "@repo/core-events": "workspace:*",
  "@repo/core-realtime": "workspace:*",
  "@repo/core-shared": "workspace:*",
  ...
  "socket.io": "^4.7.0"
}
```

Update scripts:

```json
"scripts": {
  "build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
  "dev": "tsx server.ts",
  "start": "node --import tsx server.ts",
  "lint": "eslint .",
  ...
}
```

Add `tsx` to devDependencies if not present.

Run: `pnpm install`

- [ ] **Step 2: Write `server.ts`**

```ts
// apps/web-next/server.ts
// SERVER-ONLY entry. Boots Next.js + Socket.IO on the same Node http server.
import "reflect-metadata";
import { createServer } from "node:http";
import next from "next";
import { Server as IOServer } from "socket.io";
import {
  RealtimeHandlerRegistry,
  SocketIORealtimeBroadcaster,
  SocketIORealtimeServer,
  type IRealtimeAuthenticator,
} from "@repo/core-realtime";
import { SESSION_COOKIE } from "@repo/auth";
import { authContainer } from "@repo/auth/di/container";
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
import type { IAuthenticationService } from "@repo/auth";
import { bindAll } from "./src/server/bind-production";

const dev = process.env.NODE_ENV !== "production";
const port = Number(process.env.PORT ?? 3000);

const app = next({ dev });
const handle = app.getRequestHandler();

await app.prepare();

const httpServer = createServer((req, res) => handle(req, res));
const io = new IOServer(httpServer);

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

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

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 as { roles?: string[] }).roles ?? [] }
      : null;
  },
};

const realtimeServer = new SocketIORealtimeServer({
  httpServer,
  io,
  authenticator,
  registry,
});
await realtimeServer.start();

httpServer.listen(port, () => {
  console.log(`> Ready on http://localhost:${port}`);
});
```

> **Note:** the `IAuthenticationService.validateSession` signature today returns a session with `userId`. The cast `(session as { roles?: string[] }).roles ?? []` defensively reads roles if the session shape grows them later (per spec §14). When the DB-backed roles ship, replace this with the typed accessor.

- [ ] **Step 3: Verify the server boots**

Run: `pnpm --filter @repo/web-next dev`
Expected: `> Ready on http://localhost:3000`. `curl http://localhost:3000` returns the Next.js home page. Stop with Ctrl+C.

- [ ] **Step 4: Commit**

```bash
git add apps/web-next/server.ts apps/web-next/package.json pnpm-lock.yaml
git commit -m "feat(web-next): custom Node server hosting Next.js + Socket.IO on port 3000"
```

### Task 32: Extend `bindAll()` in apps/web-next

**Files:**
- Modify: `apps/web-next/src/server/bind-production.ts`
- Modify: `apps/web-next/src/server/bind-production.test.ts`

`bindAll` currently takes no args. The custom server constructs the broadcaster + registry first (because they need the http server), then calls `bindAll(deps)`. This is a signature change.

- [ ] **Step 1: Modify the function signatures + thread realtime through every feature binder**

```ts
// apps/web-next/src/server/bind-production.ts (excerpt)
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";

type BindAllDeps = {
  realtime: IRealtimeBroadcaster;
  realtimeRegistry: IRealtimeHandlerRegistry;
};

let bound = false;

export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
  if (bound) return;
  bound = true;
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = await resolveEventsAndJobsProduction();
  const resolvedConfig = await config;
  const { realtime, realtimeRegistry } = deps;

  bindProductionAuth(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionBlog(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);

  bindRealtimeBridge(bus, realtime); // empty allowlist in v1, see § next task
}

export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
  if (bound) return;
  bound = true;
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = resolveEventsAndJobsDevSeed();
  const { realtime, realtimeRegistry } = deps;

  await bindDevSeedAuth(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedBlog(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedMarketingPages(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedNavigation(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry);

  bindRealtimeBridge(bus, realtime);
}

export async function bindAll(deps: BindAllDeps): Promise<void> {
  if (process.env.USE_DEV_SEED === "true") {
    await bindAllDevSeed(deps);
    return;
  }
  if (process.env.NODE_ENV === "production") {
    await bindAllProduction(deps);
    return;
  }
  await bindAllDevSeed(deps);
}
```

- [ ] **Step 2: Add the bridge stub (empty allowlist in v1)**

```ts
// apps/web-next/src/server/bind-production.ts (continued)
function bindRealtimeBridge(_bus: IEventBus, _broadcaster: IRealtimeBroadcaster): void {
  // v1 ships with an empty allowlist. The dashboard PR adds the first entries here.
  // Example shape (commented out so v1 doesn't try to use it):
  // bus.subscribe(userSignedUpEvent, "realtime-bridge", async (payload) =>
  //   broadcaster.broadcast(adminEventStreamChannel, { kind: "user.signed-up", payload }),
  // );
}
```

- [ ] **Step 3: Update the test file**

The existing tests already mock the per-feature binders. Update them to:
- Pass `{ realtime, realtimeRegistry }` to `bindAll`/`bindAllProduction`/`bindAllDevSeed` calls
- Use `RecordingRealtimeBroadcaster` + `RealtimeHandlerRegistry` for the recording argument

Add new test cases asserting `realtime` and `realtimeRegistry` are forwarded as the 6th + 7th positional args to each per-feature binder.

- [ ] **Step 4: Verify**

Run: `pnpm --filter @repo/web-next typecheck lint test`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/web-next/src/server/
git commit -m "feat(web-next): bindAll wires (realtime, realtimeRegistry) through every feature"
```

---

## Phase 8 — Generators

### Task 33: Generator templates for `gen realtime channel`

**Files:**
- Create: `turbo/generators/templates/realtime/channel/channel.ts.hbs`
- Create: `turbo/generators/templates/realtime/channel/channel.test.ts.hbs`

- [ ] **Step 1: Write `channel.ts.hbs`**

```hbs
// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.ts
import { z } from "zod";
import { defineRealtimeChannel } from "@repo/core-realtime";

export const {{camelCase channelSlug}}Schema = z.object({}).strict();

export type {{pascalCase channelSlug}}Payload = z.infer<typeof {{camelCase channelSlug}}Schema>;

export const {{camelCase channelSlug}}Channel = defineRealtimeChannel(
  "{{kebabCase feature}}.{{kebabCase channelSlug}}",
  {{camelCase channelSlug}}Schema,
  { scope: {{{scopeLiteral}}} },
);
```

- [ ] **Step 2: Write `channel.test.ts.hbs`**

```hbs
// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.test.ts
import { describe, it, expect } from "vitest";
import {
  {{camelCase channelSlug}}Channel,
  {{camelCase channelSlug}}Schema,
} from "@/realtime/{{kebabCase channelSlug}}.channel";

describe("{{camelCase channelSlug}}Channel", () => {
  it("has the expected wire name", () => {
    expect({{camelCase channelSlug}}Channel.name).toBe(
      "{{kebabCase feature}}.{{kebabCase channelSlug}}",
    );
  });

  it("validates an empty payload (stub schema)", () => {
    expect(() => {{camelCase channelSlug}}Schema.parse({})).not.toThrow();
  });
});
```

- [ ] **Step 3: Commit**

```bash
git add turbo/generators/templates/realtime/channel/
git commit -m "feat(turbo-gen): templates for gen realtime channel"
```

### Task 34: Generator templates for `gen realtime handler`

**Files:**
- Create: `turbo/generators/templates/realtime/handler/handler.ts.hbs`
- Create: `turbo/generators/templates/realtime/handler/handler.test.ts.hbs`

- [ ] **Step 1: Write `handler.ts.hbs`**

```hbs
// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.ts
import type { {{pascalCase channelSlug}}Payload } from "../{{kebabCase channelSlug}}.channel";
import type { RealtimeContext } from "@repo/core-realtime";

export type IOn{{pascalCase channelSlug}}Handler = ReturnType<typeof on{{pascalCase channelSlug}}Handler>;

export const on{{pascalCase channelSlug}}Handler =
  () =>
  async (_input: {{pascalCase channelSlug}}Payload, _ctx: RealtimeContext): Promise<void> => {
    // TODO: implement the reaction. Inject deps via the factory's constructor
    // and use them here. The handler is wrapped in span+capture at bind time,
    // so just throwing on failure is the right shape.
  };
```

- [ ] **Step 2: Write `handler.test.ts.hbs`**

```hbs
// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.test.ts
import { describe, it, expect } from "vitest";
import { on{{pascalCase channelSlug}}Handler } from "@/realtime/handlers/on-{{kebabCase channelSlug}}.handler";

describe("on{{pascalCase channelSlug}}Handler", () => {
  it("returns a function (factory shape)", () => {
    const handler = on{{pascalCase channelSlug}}Handler();
    expect(typeof handler).toBe("function");
  });

  it("does not throw on a valid stub input", async () => {
    const handler = on{{pascalCase channelSlug}}Handler();
    await expect(
      handler({} as never, { userId: "u1", roles: [] }),
    ).resolves.toBeUndefined();
  });
});
```

- [ ] **Step 3: Commit**

```bash
git add turbo/generators/templates/realtime/handler/
git commit -m "feat(turbo-gen): templates for gen realtime handler"
```

### Task 35: Wire `gen realtime` into `turbo/generators/config.ts`

**Files:**
- Modify: `turbo/generators/config.ts`

Read the existing event/job generator code in `config.ts` for the pattern. The realtime generator follows the same shape: a single `realtime` generator with a `mode` prompt that branches between `channel` and `handler`.

- [ ] **Step 1: Add the `realtime` generator definition**

Inside the existing `generator(plop)` function, after `plop.setGenerator("job", ...)`:

```ts
plop.setGenerator("realtime", {
  description: "Scaffold a realtime channel descriptor or inbound handler",
  prompts: [
    {
      type: "input",
      name: "mode",
      message: "Mode: channel | handler",
      validate(input: string) {
        if (!["channel", "handler"].includes(input)) return "Must be 'channel' or 'handler'";
        return true;
      },
    },
    {
      type: "input",
      name: "feature",
      message: "Feature (kebab-case, must exist):",
      validate(input: string) {
        if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
        if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
          return `packages/${input}/src does not exist`;
        }
        return true;
      },
    },
    {
      type: "input",
      name: "channelSlug",
      message: "Channel slug (kebab-case, e.g. 'presence-ping'):",
      validate(input: string) {
        if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
        return true;
      },
    },
    {
      type: "input",
      name: "scope",
      message: "Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):",
      validate(input: string, answers: { mode: string }) {
        if (answers.mode === "handler") return true;
        if (input === "public" || input === "authenticated" || input === "user-scoped") return true;
        if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true;
        return "Must be public | authenticated | role:NAME | user-scoped";
      },
    },
  ],
  actions(answers) {
    const a = answers as { mode: string; feature: string; channelSlug: string; scope: string };
    if (a.mode === "channel") return realtimeChannelActions(a);
    if (a.mode === "handler") return realtimeHandlerActions(a);
    throw new Error(`Unknown mode: ${a.mode}`);
  },
});
```

- [ ] **Step 2: Implement `realtimeChannelActions`**

```ts
function realtimeChannelActions(a: { feature: string; channelSlug: string; scope: string }): PlopTypes.ActionType[] {
  const indexPath = `packages/${a.feature}/src/index.ts`;
  const scopeLiteral = renderScopeLiteral(a.scope);
  return [
    () => {
      assertAnchors(process.cwd(), indexPath, ["// <gen:realtime-channels>"]);
      return `Anchors verified in ${indexPath}`;
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
      templateFile: "templates/realtime/channel/channel.ts.hbs",
      data: { ...a, scopeLiteral },
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.test.ts`,
      templateFile: "templates/realtime/channel/channel.test.ts.hbs",
      data: { ...a, scopeLiteral },
    },
    {
      type: "modify",
      path: indexPath,
      pattern: /\/\/ <gen:realtime-channels>/,
      template: `// <gen:realtime-channels>\nexport {\n  {{camelCase channelSlug}}Channel,\n  {{camelCase channelSlug}}Schema,\n  type {{pascalCase channelSlug}}Payload,\n} from "./realtime/{{kebabCase channelSlug}}.channel";`,
      data: a,
    },
    () => printChannelNextSteps(a),
  ];
}

function renderScopeLiteral(scope: string): string {
  if (scope === "public") return `"public"`;
  if (scope === "authenticated") return `"authenticated"`;
  if (scope.startsWith("role:")) return `{ role: "${scope.slice("role:".length)}" }`;
  if (scope === "user-scoped") return `{ userScoped: true, template: "TODO_TEMPLATE" }`;
  throw new Error(`unknown scope: ${scope}`);
}

function printChannelNextSteps(a: { feature: string; channelSlug: string }): string {
  return [
    "─────────────────────────────────────────────────────────────",
    `Realtime channel ${a.feature}.${a.channelSlug} scaffolded.`,
    "",
    "Next steps (manual):",
    `  1. Fill in the Zod schema in packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
    `  2. To broadcast: inject 'realtime: IRealtimeBroadcaster' into a use case factory and call`,
    `       realtime.broadcast(${a.channelSlug}Channel, payload)`,
    `  3. To receive client-emitted messages on this channel: pnpm turbo gen realtime`,
    `     and pick "handler" mode with the same channel slug.`,
    `  4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
  ].join("\n");
}
```

- [ ] **Step 3: Implement `realtimeHandlerActions`**

```ts
function realtimeHandlerActions(a: { feature: string; channelSlug: string }): PlopTypes.ActionType[] {
  const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
  const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
  const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
  return [
    () => {
      assertAnchors(process.cwd(), symbolFile, ["// <gen:realtime-handler-symbols>"]);
      assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
      assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
      return "All required anchors present";
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
      templateFile: "templates/realtime/handler/handler.ts.hbs",
      data: a,
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.test.ts`,
      templateFile: "templates/realtime/handler/handler.test.ts.hbs",
      data: a,
    },
    {
      type: "modify",
      path: symbolFile,
      pattern: /\/\/ <gen:realtime-handler-symbols>/,
      template: `// <gen:realtime-handler-symbols>\n  IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`,
    },
    {
      type: "modify",
      path: bindProdFile,
      pattern: /\/\/ <gen:realtime-handlers>/,
      template: realtimeHandlerBindBlock(a),
    },
    {
      type: "modify",
      path: bindDevFile,
      pattern: /\/\/ <gen:realtime-handlers>/,
      template: realtimeHandlerBindBlock(a),
    },
    () => printHandlerNextSteps(a),
  ];
}

function realtimeHandlerBindBlock(a: { feature: string; channelSlug: string }): string {
  const handlerFn = `on${pascalCase(a.channelSlug)}Handler`;
  const channelConst = `${camel(a.channelSlug)}Channel`;
  return `// <gen:realtime-handlers>
  // ${handlerFn} — generated by gen realtime handler. Edit the handler file (not this block).
  const wrapped${pascalCase(a.channelSlug)} = withSpan(
    tracer,
    { name: "${a.feature}.${handlerFn}", op: "realtime-handler" },
    withCapture(
      logger,
      { feature: "${a.feature}", layer: "realtime-handler", name: "${a.feature}.${handlerFn}" },
      ${handlerFn}(),
    ),
  );
  realtimeRegistry.register({ descriptor: ${channelConst}, handler: wrapped${pascalCase(a.channelSlug)} });`;
}

function printHandlerNextSteps(a: { feature: string; channelSlug: string }): string {
  return [
    "─────────────────────────────────────────────────────────────",
    `Realtime handler on-${a.channelSlug} scaffolded in ${a.feature}.`,
    "",
    "Next steps (manual):",
    `  1. Implement the handler body in packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
    `  2. Add the imports to bind-production.ts AND bind-dev-seed.ts:`,
    `       import { ${camel(a.channelSlug)}Channel } from "../realtime/${a.channelSlug}.channel";`,
    `       import { on${pascalCase(a.channelSlug)}Handler } from "../realtime/handlers/on-${a.channelSlug}.handler";`,
    `  3. If the handler needs deps, extend the factory signature and pass them in the bind block.`,
    `  4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
  ].join("\n");
}
```

- [ ] **Step 4: Smoke test channel mode**

Run from project root:

```bash
pnpm turbo gen realtime --args channel auth ping authenticated
```

Verify:
- `packages/auth/src/realtime/ping.channel.ts` exists
- `packages/auth/src/realtime/ping.channel.test.ts` exists
- `packages/auth/src/index.ts` re-exports the channel at the anchor
- `pnpm --filter @repo/auth typecheck lint test` PASSES

Then revert the smoke output:

```bash
rm -rf packages/auth/src/realtime/
git restore packages/auth/src/index.ts
```

- [ ] **Step 5: Smoke test handler mode**

Re-create the channel + run handler:

```bash
pnpm turbo gen realtime --args channel auth ping authenticated
pnpm turbo gen realtime --args handler auth ping
```

Verify (and add the manual imports to bind files as per the printed instructions, then revert).

- [ ] **Step 6: Commit (after both smoke tests pass + revert)**

```bash
git add turbo/generators/config.ts
git commit -m "feat(turbo-gen): wire gen realtime (channel + handler modes)"
```

---

## Phase 9 — Proof-of-life: realtime-ping

### Task 36: Implement `realtime-ping` channel pair inside core-realtime

**Files:**
- Create: `packages/core-realtime/src/realtime-ping.ts`

The proof-of-life lives inside core-realtime itself, not in a feature, so the integration test in apps/web-next can drive it without polluting any feature's surface.

- [ ] **Step 1: Implement**

```ts
// packages/core-realtime/src/realtime-ping.ts
import { z } from "zod";
import { defineRealtimeChannel } from "./realtime-channel";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";

const pingSchema = z.object({ at: z.string().datetime() }).strict();
const pongSchema = z.object({ at: z.string().datetime(), echo: z.string() }).strict();

export type PingPayload = z.infer<typeof pingSchema>;
export type PongPayload = z.infer<typeof pongSchema>;

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

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

export function realtimePingInboundDescriptor(
  broadcaster: IRealtimeBroadcaster,
): IInboundDescriptor<typeof realtimePingChannel.name, typeof pingSchema> {
  return {
    descriptor: realtimePingChannel,
    handler: async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
      await broadcaster.broadcast(realtimePongChannel, {
        at: input.at,
        echo: ctx.userId ?? "anonymous",
      });
    },
  };
}
```

- [ ] **Step 2: Add to the public barrel**

```ts
// packages/core-realtime/src/index.ts (append)
export {
  realtimePingChannel,
  realtimePongChannel,
  realtimePingInboundDescriptor,
  type PingPayload,
  type PongPayload,
} from "./realtime-ping";
```

- [ ] **Step 3: Verify typecheck**

Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/core-realtime/src/realtime-ping.ts packages/core-realtime/src/index.ts
git commit -m "feat(core-realtime): realtime-ping proof-of-life channel pair"
```

### Task 37: Register `realtime-ping` in apps/web-next bindAll

**Files:**
- Modify: `apps/web-next/src/server/bind-production.ts` (the bindAll variants)

- [ ] **Step 1: Register the inbound descriptor with the realtime registry**

In both `bindAllProduction` and `bindAllDevSeed`, after the per-feature binders run:

```ts
import { realtimePingInboundDescriptor } from "@repo/core-realtime";

// inside bindAllProduction and bindAllDevSeed, after feature binders, before bridge:
realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
```

- [ ] **Step 2: Add an env-gate for production**

```ts
if (process.env.REALTIME_PING_DISABLED !== "true") {
  realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
}
```

This lets ops disable the smoke channel post-merge if they want a leaner production surface.

- [ ] **Step 3: Verify**

Run: `pnpm --filter @repo/web-next typecheck lint test`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/web-next/src/server/bind-production.ts
git commit -m "feat(web-next): register realtime-ping inbound (env-gateable)"
```

### Task 38: Integration test: realtime-ping over real Socket.IO

**Files:**
- Create: `apps/web-next/src/__tests__/realtime-ping.test.ts`

This is the v1 proof-of-life — equivalent to ADR-015's `sign-up-welcome-email.test.ts`.

- [ ] **Step 1: Add `socket.io-client` to apps/web-next devDependencies**

```json
"devDependencies": {
  ...
  "socket.io-client": "^4.7.0",
}
```

Run: `pnpm install`

- [ ] **Step 2: Write the test**

```ts
// apps/web-next/src/__tests__/realtime-ping.test.ts
// e2e proof-of-life: connect → subscribe → emit ping → receive pong via the
// production-shaped binder + Socket.IO server, with cookie-session auth
// against a seeded test session.
import "reflect-metadata";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient } from "socket.io-client";
import type { AddressInfo } from "node:net";
import {
  RealtimeHandlerRegistry,
  SocketIORealtimeBroadcaster,
  SocketIORealtimeServer,
  realtimePingInboundDescriptor,
  type IRealtimeAuthenticator,
} from "@repo/core-realtime";

describe("e2e: realtime-ping exercises all four checkpoints", () => {
  let httpServer: HttpServer;
  let realtimeServer: SocketIORealtimeServer;
  let port: number;

  beforeEach(async () => {
    httpServer = createServer();
    const io = new IOServer(httpServer);
    const broadcaster = new SocketIORealtimeBroadcaster(io);
    const registry = new RealtimeHandlerRegistry();
    registry.register(realtimePingInboundDescriptor(broadcaster));

    const authenticator: IRealtimeAuthenticator = {
      authenticate: async ({ cookies }) =>
        cookies.session === "valid-session"
          ? { userId: "user_test", roles: [] }
          : null,
    };

    realtimeServer = new SocketIORealtimeServer({
      httpServer,
      io,
      authenticator,
      registry,
    });
    await realtimeServer.start();

    await new Promise<void>((r) => httpServer.listen(0, r));
    port = (httpServer.address() as AddressInfo).port;
  });

  afterEach(async () => {
    await realtimeServer.stop();
    await new Promise<void>((r) => httpServer.close(() => r()));
  });

  it("authenticated client gets pong after ping", async () => {
    const client = ioClient(`http://localhost:${port}`, {
      extraHeaders: { Cookie: "session=valid-session" },
    });
    await new Promise<void>((r) => client.on("connect", () => r()));

    const subAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "realtime.pong", r),
    );
    expect(subAck.ok).toBe(true);

    const pongs: { at: string; echo: string }[] = [];
    client.on("realtime.pong", (p) => pongs.push(p));

    const sentAt = "2026-05-08T12:00:00.000Z";
    const pingAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("realtime.ping", { at: sentAt }, r),
    );
    expect(pingAck.ok).toBe(true);

    // Pong arrives synchronously after handler runs.
    await new Promise<void>((r) => setImmediate(r));

    expect(pongs).toHaveLength(1);
    expect(pongs[0]).toEqual({ at: sentAt, echo: "user_test" });

    client.disconnect();
  });

  it("anonymous client cannot subscribe to pong (authenticated scope)", async () => {
    const client = ioClient(`http://localhost:${port}`);
    await new Promise<void>((r) => client.on("connect", () => r()));

    const subAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "realtime.pong", r),
    );

    expect(subAck.ok).toBe(false);
    expect(subAck.error).toBe("forbidden");
    client.disconnect();
  });
});
```

- [ ] **Step 3: Run; expect PASS**

Run: `pnpm --filter @repo/web-next test realtime-ping`
Expected: PASS — both cases.

- [ ] **Step 4: Commit**

```bash
git add apps/web-next/src/__tests__/realtime-ping.test.ts apps/web-next/package.json pnpm-lock.yaml
git commit -m "test(web-next): e2e realtime-ping (4 checkpoints)"
```

---

## Phase 10 — Documentation

### Task 39: Write `docs/decisions/adr-016-realtime-layer.md`

**Files:**
- Create: `docs/decisions/adr-016-realtime-layer.md`

- [ ] **Step 1: Read the format of recent ADRs**

Open `docs/decisions/adr-015-events-and-jobs.md`. Same Status / Context / Decision / Alternatives / Consequences / Notes-from-execution / Out-of-scope / Related sections.

- [ ] **Step 2: Write ADR-016**

Distill the spec's §1, §2 (rules), §3 (package), §6 (topology), §7 (auth), §8 (hybrid), §13 (v1 scope). Mirror the prose density of ADR-015. Each rule (R0/R1/R2) gets one paragraph. Cross-link the spec at `docs/superpowers/specs/2026-05-08-realtime-design.md`.

- [ ] **Step 3: Commit**

```bash
git add docs/decisions/adr-016-realtime-layer.md
git commit -m "docs(adr-016): realtime layer (Socket.IO)"
```

### Task 40: Write `docs/guides/realtime.md`

**Files:**
- Create: `docs/guides/realtime.md`

- [ ] **Step 1: Read the events-and-jobs guide for the format**

Open `docs/guides/events-and-jobs.md`. The realtime guide follows the same shape: three sections (declare a channel, broadcast from a use case, receive client messages with a handler), each with full code samples.

- [ ] **Step 2: Write the guide**

Cover:
- "Declare a channel" — runs `gen realtime channel`, fills the schema, picks scope
- "Broadcast from a use case" — adds `realtime: IRealtimeBroadcaster` to the factory, calls `realtime.broadcast(channel, payload)`
- "Receive client messages" — runs `gen realtime handler`, fills handler body, adds imports to bind files
- Cross-reference: ADR-016, the design spec, and `docs/decisions/adr-015-events-and-jobs.md` for the bridge pattern

- [ ] **Step 3: Commit**

```bash
git add docs/guides/realtime.md
git commit -m "docs(guide): realtime walkthrough"
```

### Task 41: Update `AGENTS.md`

**Files:**
- Modify: `AGENTS.md`

- [ ] **Step 1: Add a new section under Per-Package Conventions**

Insert after "Cross-feature events and background jobs (Plan 10, ADR-015)" — same shape, three-rule summary:

```markdown
### Realtime layer (ADR-016)

Three rules:

- **R0:** Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures.
- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-* files (ESLint-enforced via `no-realtime-handler-reexport`).
- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this.

`@repo/core-realtime` provides `IRealtimeBroadcaster` (server → client), `IRealtimeHandlerRegistry` (client → server), and the `SocketIORealtimeServer` adapter. `apps/web-next/server.ts` replaces `next start`/`next dev` with a custom Node http server hosting both Next.js and Socket.IO on port 3000.

Use the generators: `pnpm turbo gen realtime channel`, `pnpm turbo gen realtime handler`. They insert at three fixed `// <gen:realtime-*>` anchor comments per feature.

See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.
```

- [ ] **Step 2: Add a row to the Specification & Guides list**

```markdown
- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive
```

- [ ] **Step 3: Commit**

```bash
git add AGENTS.md
git commit -m "docs(agents): realtime layer section + guide reference"
```

### Task 42: Update `CLAUDE.md`

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 1: Update Quick Start**

Add to the command block:

```bash
pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler
```

- [ ] **Step 2: Update Read First**

Add: `- docs/guides/realtime.md — Socket.IO channels, broadcasts, handlers`

- [ ] **Step 3: Update Key Conventions**

Add three short bullets matching R0/R1/R2 from the AGENTS.md section above.

- [ ] **Step 4: Commit**

```bash
git add CLAUDE.md
git commit -m "docs(claude): realtime generator + R0/R1/R2 conventions"
```

### Task 43: Update `docs/guides/scaffolding-a-feature.md`

**Files:**
- Modify: `docs/guides/scaffolding-a-feature.md`

- [ ] **Step 1: Extend the "Adding events and jobs" section to cover realtime**

Append `pnpm turbo gen realtime channel` and `pnpm turbo gen realtime handler` to the generator list. Note that newly-generated features include the three new realtime anchors.

- [ ] **Step 2: Commit**

```bash
git add docs/guides/scaffolding-a-feature.md
git commit -m "docs(scaffolding): realtime generator reference"
```

### Task 44: Update `docs/architecture/dependency-flow.md` and `docs/architecture/vertical-feature-spec.md`

**Files:**
- Modify: `docs/architecture/dependency-flow.md`
- Modify: `docs/architecture/vertical-feature-spec.md`

- [ ] **Step 1: Update the bindAll diagram**

Add a `resolveRealtime` step + extend the binder signature line in `dependency-flow.md` to `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`.

- [ ] **Step 2: Update vertical-feature-spec.md § 4 (optional folders)**

Add `realtime/` and `realtime/handlers/` to the optional-folder list, with a cross-reference to ADR-016.

- [ ] **Step 3: Commit**

```bash
git add docs/architecture/
git commit -m "docs(architecture): realtime layer in dependency-flow + vertical-feature-spec"
```

---

## Phase 11 — Final verification

### Task 45: Whole-monorepo green check

- [ ] **Step 1: Full lint**

Run: `pnpm lint`
Expected: PASS — including the two new ESLint rules, the anchor-presence guard, and all rule tests. Pre-existing warnings (NEXT_RUNTIME, etc.) unchanged.

- [ ] **Step 2: Full typecheck**

Run: `pnpm typecheck`
Expected: PASS.

- [ ] **Step 3: Full test**

Run: `pnpm test`
Expected: PASS — including `core-realtime`'s ~20 test cases, the recording-realtime-broadcaster tests, the anchor-presence guard, and the realtime-ping integration test.

- [ ] **Step 4: Boundary check**

Run: `pnpm turbo boundaries`
Expected: PASS — `core-realtime` properly tagged, no new violations.

- [ ] **Step 5: Smoke run of generators**

```bash
cp -r ~/Documents/Projects/template-vertical /tmp/realtime-final-test && cd /tmp/realtime-final-test && pnpm install
pnpm turbo gen realtime --args channel blog test-channel public
pnpm turbo gen realtime --args handler blog test-channel
# manually add the imports printed by the handler generator
pnpm --filter @repo/blog typecheck test lint
```

Expected: PASS.

```bash
rm -rf /tmp/realtime-final-test
```

- [ ] **Step 6: Manual dev-server smoke**

```bash
pnpm --filter @repo/web-next dev
# In another terminal:
curl -i http://localhost:3000
# Stop the dev server.
```

Expected: HTTP 200 from Next.js, no Socket.IO errors in the dev-server log.

- [ ] **Step 7: Final commit (only if any fixes were needed)**

If steps 1–6 surfaced a fix, commit it. Otherwise no-op — the plan is done.

---

## Known follow-ups — out of v1 plan scope

Tracked here so they aren't forgotten:

1. **Observability dashboard.** The first concrete consumer of the bridge. Adds an `admin-realtime` feature package with a `{ role: "admin" }` channel that streams every bus event to connected admin tabs. Includes a Next.js admin route + filter UI. Adds the first allowlist entry to `bindRealtimeBridge`.

2. **DB-backed roles + permissions in the auth feature.** Per spec § 14. Additive: `IRealtimeAuthenticator.authenticate()` widens its return type, scope union grows new kinds (`{ permission }`, `{ permissions, mode }`, `{ check }`), no changes to `core-realtime`.

3. **Generic core-package generator.** User-flagged on 2026-05-08. 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 project memory; brainstorm separately.

4. **Production-mode e2e against real Postgres-backed Payload session.** § 10.3's test runs against a stub authenticator. A parallel test that boots Payload and uses a real `validateSession` would prove the full cookie-auth chain.

5. **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 + Redis pub/sub).

6. **Custom Node server for `cms` and `web-tanstack`.** v1 only converts `web-next`. Lands when those apps actually need realtime.

7. **Generator for outbound broadcasts.** Currently a manual edit (add `realtime: IRealtimeBroadcaster` to a use case factory). If the pattern proliferates, a `gen realtime broadcast <feature> <channel-slug>` could automate it.

---

## Self-review check

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

- Every spec section maps to one or more tasks (§ 3 → Phase 1 + 2, § 5 → Phase 6 + 8, § 6 → Phase 7, § 7 → Tasks 7+8+13, § 8 → Task 32 (bridge stub), § 9 → Phase 4 (ESLint), § 10 → Phase 3 + 9, § 11 → Phase 5 + 8, § 12 → Phase 9, § 13 → covered by per-task scope, § 14 → ADR-016 only, deferred otherwise).
- All file paths are absolute or repo-relative; no "TBD".
- Tests precede implementation in every behavior-adding task (R-style TDD per ADR-011).
- Commit cadence: one commit per task, ~45 commits total.
- Anchor protocol identical to ADR-015 (3 anchors per feature, same kind of CI guard extension).
- Span+capture sandwich tags (`op: "realtime-handler"`, `layer: "realtime-handler"`) appear consistently in Task 35's generator output.
- `RecordingRealtimeBroadcaster` uses the local-type-alias pattern (lessons from ADR-015 Task 56 codified into Task 15).
- v1 scope (out of plan items) explicitly excludes the dashboard, DB-backed roles, multi-instance fanout, and the generic core-package generator.
- Type consistency: `RealtimeContext` shape matches across handler factory definition, the gate-3 dispatch site in `SocketIORealtimeServer`, and the generator template.
