# Cross-feature events and background jobs — 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 the cross-feature event bus and background-job conventions defined in `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`, including two new core packages, two ESLint rules, two Plop generators, recording test helpers, the existing-feature anchor retrofit, an end-to-end proof-of-life cross-feature flow, and the documentation set.

**Architecture:** Two new abstractions — `IEventBus` in a new `@repo/core-events` package, `IJobQueue` in `@repo/core-shared/jobs` — each with an in-memory implementation (dev/test) and a Payload-jobs-backed implementation (prod). Selection follows the existing `bindAll()` rules in `apps/web-next/src/server/bind-production.ts`. Per-feature binders gain `bus, queue` parameters. Cross-feature reactions go publisher → bus → consumer handler → optional job; consumers import only the publisher's contract from the publisher's root export. Generators augment existing feature packages by inserting at six fixed `// <gen:*>` anchor comments.

**Tech Stack:** TypeScript, Zod, Inversify (DI), Vitest, ESLint flat config, Plop (via `@turbo/gen`), Payload 3.x (`payload.jobs` API for production transport), Turborepo, pnpm workspaces.

**Plan length:** ~60 bite-sized tasks across 9 phases. Each task is 2–5 minutes of work and ends in a commit.

---

## Phase 0 — Read first

Read these once before starting; they contain rules and patterns the rest of the plan assumes:

- `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md` — the design this plan implements.
- `AGENTS.md` § Per-Package Conventions and § Instrumentation conventions.
- `CLAUDE.md` § Key Conventions.
- `packages/auth/src/di/bind-production.ts` — canonical binder shape we extend.
- `apps/web-next/src/server/bind-production.ts` — canonical `bindAll()` shape we extend.
- `docs/decisions/adr-014-instrumentation-sentry.md` (R40, R41–R44) — the span+capture sandwich rules events/jobs reuse.
- `docs/decisions/adr-013-input-output-unification.md` (R1–R5) — the schemas-in-use-case pattern jobs mirror.

Conventions used throughout this plan:

- **Spec sections** are referenced as `spec § N.N`.
- **Verification commands** use `pnpm --filter <pkg> typecheck test lint` for package-scoped checks; `pnpm typecheck` / `pnpm test` / `pnpm turbo boundaries` for whole-monorepo checks.
- **Commit style** matches the repo's existing format (e.g., `feat(core-events): InMemoryEventBus`). Short imperative subject under 70 chars, optional body explaining the *why* if non-obvious.
- **TDD order:** failing test → run to confirm fail → minimal implementation → run to confirm pass → commit. Skipped only for tasks that add no behavior (anchor comments, doc updates, signature-only edits with no logic change) — those use a make-the-change → verify-typecheck → commit sequence.

---

## File map (summary)

**New files:**

```
packages/core-events/                                    # NEW package, tag: core
  package.json
  tsconfig.json
  vitest.config.ts
  eslint.config.js
  turbo.json
  AGENTS.md
  src/
    event-descriptor.ts
    event-descriptor.test.ts
    event-bus.interface.ts
    in-memory-event-bus.ts
    in-memory-event-bus.test.ts
    payload-jobs-event-bus.ts
    payload-jobs-event-bus.test.ts
    symbols.ts
    index.ts

packages/core-shared/src/jobs/                            # NEW subdirectory + ./jobs subpath
  job-queue.interface.ts
  in-memory-job-queue.ts
  in-memory-job-queue.test.ts
  payload-job-queue.ts
  payload-job-queue.test.ts
  symbols.ts
  index.ts

packages/core-testing/src/instrumentation/                # extends existing dir
  recording-event-bus.ts
  recording-event-bus.test.ts
  recording-job-queue.ts
  recording-job-queue.test.ts

packages/core-eslint/src/                                 # extends existing dir
  no-handler-reexport.rule.ts
  no-handler-reexport.rule.test.ts
  no-direct-payload-jobs.rule.ts
  no-direct-payload-jobs.rule.test.ts

turbo/generators/templates/                               # extends existing dir
  event/publish/event.ts.hbs
  event/publish/event.test.ts.hbs
  event/consume/handler.ts.hbs
  event/consume/handler.test.ts.hbs
  event/consume/binder-block.ts.hbs                       # snippet for modify action
  event/consume/symbol.ts.hbs                             # snippet for modify action
  job/job.ts.hbs
  job/job.test.ts.hbs
  job/task.ts.hbs
  job/binder-block.ts.hbs                                 # snippet for modify action
  job/symbol.ts.hbs                                       # snippet for modify action
  job/cms-export.ts.hbs                                   # snippet for modify action

packages/marketing-pages/src/application/services/        # NEW for proof-of-life
  mailer.service.interface.ts
packages/marketing-pages/src/infrastructure/services/
  recording-mailer.service.ts                             # mock impl for the e2e test

docs/decisions/adr-015-events-and-jobs.md                 # NEW
docs/guides/events-and-jobs.md                            # NEW
```

**Modified files (anchor retrofit; pure additive comments):**

For each of `auth`, `blog`, `media`, `marketing-pages`, `navigation`:

```
packages/<feature>/src/index.ts                           # + // <gen:events>
packages/<feature>/src/di/symbols.ts                      # + // <gen:event-handler-symbols> and // <gen:job-symbols>
packages/<feature>/src/di/bind-production.ts              # + // <gen:event-handlers> and // <gen:jobs>
packages/<feature>/src/di/bind-dev-seed.ts                # + same two anchors
packages/<feature>/src/integrations/cms/index.ts          # + // <gen:job-tasks>
```

Plus the feature template under `turbo/generators/templates/feature/` gets the same anchor additions to its `.hbs` files (CMS index excluded — Phase-1 generator doesn't emit it).

**Modified files (binder signatures + bindAll):**

```
packages/<feature>/src/di/bind-production.ts              # + bus, queue params
packages/<feature>/src/di/bind-dev-seed.ts                # + bus, queue params
packages/<feature>/src/di/bind-production.test.ts         # update test invocations
packages/<feature>/src/di/bind-dev-seed.test.ts           # update test invocations
apps/web-next/src/server/bind-production.ts               # + resolveEventsAndJobs(), pass through
apps/web-next/src/server/bind-production.test.ts          # update assertions
apps/cms/src/server/bind-production.ts                    # same as web-next (if file exists; verify in Phase 5)
apps/web-tanstack/src/server/bind-production.ts           # same (if exists; verify in Phase 5)
turbo/generators/config.ts                                # + event + job generators
turbo/generators/templates/feature/...                    # anchor inserts in template
```

**Modified files (docs):**

```
AGENTS.md                                                 # new section under Per-Package Conventions
CLAUDE.md                                                 # Quick Start + Read First + Key Conventions
docs/guides/scaffolding-a-feature.md                      # two new sections
docs/architecture/vertical-feature-spec.md                # update lines :260 and :662
```

---

## Phase 1 — Job queue in `core-shared`

`IJobQueue` lives in `core-shared` (spec § 3.2 / § 6.4) so features can use jobs without depending on `core-events`. The bus depends on the queue, not vice versa.

### Task 1: Add `./jobs` subpath to `@repo/core-shared` package.json

**Files:**
- Modify: `packages/core-shared/package.json`

- [ ] **Step 1: Read the current exports map**

Read `packages/core-shared/package.json` to locate the `"exports"` block.

- [ ] **Step 2: Add the `./jobs` subpath**

Add this entry alongside the existing subpaths:

```json
"./jobs": "./src/jobs/index.ts",
```

- [ ] **Step 3: Verify exports JSON is valid and pnpm picks it up**

Run: `pnpm install --filter @repo/core-shared`
Expected: clean install, no warning about malformed exports.

- [ ] **Step 4: Commit**

```bash
git add packages/core-shared/package.json
git commit -m "feat(core-shared): add ./jobs subpath export"
```

### Task 2: Define `IJobQueue` interface

**Files:**
- Create: `packages/core-shared/src/jobs/job-queue.interface.ts`

- [ ] **Step 1: Create the interface file**

```ts
// packages/core-shared/src/jobs/job-queue.interface.ts
export interface IJobQueue {
  enqueue<T>(
    taskSlug: string,
    input: T,
    options?: { runAt?: Date },
  ): Promise<{ jobId: string }>;
}
```

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

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

- [ ] **Step 3: Commit**

```bash
git add packages/core-shared/src/jobs/job-queue.interface.ts
git commit -m "feat(core-shared/jobs): IJobQueue interface"
```

### Task 3: Add `CORE_SHARED_JOBS_SYMBOLS`

**Files:**
- Create: `packages/core-shared/src/jobs/symbols.ts`

- [ ] **Step 1: Create the symbols file**

```ts
// packages/core-shared/src/jobs/symbols.ts
export const CORE_SHARED_JOBS_SYMBOLS = {
  IJobQueue: Symbol.for("@repo/core-shared/jobs/IJobQueue"),
} as const;
```

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

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

- [ ] **Step 3: Commit**

```bash
git add packages/core-shared/src/jobs/symbols.ts
git commit -m "feat(core-shared/jobs): symbol registry"
```

### Task 4: Write the failing test for `InMemoryJobQueue`

**Files:**
- Create: `packages/core-shared/src/jobs/in-memory-job-queue.test.ts`

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

```ts
// packages/core-shared/src/jobs/in-memory-job-queue.test.ts
import { describe, it, expect, vi } from "vitest";
import { InMemoryJobQueue } from "@/jobs/in-memory-job-queue";
import type { IJobQueue } from "@/jobs/job-queue.interface";

describe("InMemoryJobQueue", () => {
  it("returns a synthetic jobId on enqueue", async () => {
    const handler = vi.fn();
    const queue: IJobQueue = new InMemoryJobQueue({
      "test.task": handler,
    });
    const result = await queue.enqueue("test.task", { x: 1 });
    expect(result.jobId).toMatch(/^in-memory-/);
  });

  it("invokes the registered handler asynchronously with the input", async () => {
    const handler = vi.fn();
    const queue = new InMemoryJobQueue({ "test.task": handler });
    await queue.enqueue("test.task", { x: 42 });
    // Microtask boundary: handler runs after enqueue resolves
    await new Promise((r) => setImmediate(r));
    expect(handler).toHaveBeenCalledWith({ x: 42 });
  });

  it("throws if the task slug has no registered handler", async () => {
    const queue = new InMemoryJobQueue({});
    await expect(queue.enqueue("missing.task", {})).rejects.toThrow(
      /no handler registered for task slug: missing\.task/,
    );
  });

  it("delays execution when runAt is in the future", async () => {
    vi.useFakeTimers();
    try {
      const handler = vi.fn();
      const queue = new InMemoryJobQueue({ "test.task": handler });
      const future = new Date(Date.now() + 1000);
      await queue.enqueue("test.task", {}, { runAt: future });
      expect(handler).not.toHaveBeenCalled();
      vi.advanceTimersByTime(1000);
      await Promise.resolve();
      expect(handler).toHaveBeenCalledTimes(1);
    } finally {
      vi.useRealTimers();
    }
  });
});
```

- [ ] **Step 2: Run the test and confirm it fails**

Run: `pnpm --filter @repo/core-shared test -- in-memory-job-queue`
Expected: FAIL — `InMemoryJobQueue` not exported.

### Task 5: Implement `InMemoryJobQueue`

**Files:**
- Create: `packages/core-shared/src/jobs/in-memory-job-queue.ts`

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

```ts
// packages/core-shared/src/jobs/in-memory-job-queue.ts
import type { IJobQueue } from "./job-queue.interface";

export type InMemoryHandler = (input: unknown) => Promise<void> | void;

export class InMemoryJobQueue implements IJobQueue {
  private counter = 0;

  constructor(private readonly handlers: Record<string, InMemoryHandler>) {}

  async enqueue<T>(
    taskSlug: string,
    input: T,
    options?: { runAt?: Date },
  ): Promise<{ jobId: string }> {
    const handler = this.handlers[taskSlug];
    if (!handler) {
      throw new Error(`no handler registered for task slug: ${taskSlug}`);
    }
    this.counter += 1;
    const jobId = `in-memory-${this.counter}`;
    const delay = options?.runAt ? options.runAt.getTime() - Date.now() : 0;
    if (delay > 0) {
      setTimeout(() => void handler(input), delay);
    } else {
      setImmediate(() => void handler(input));
    }
    return { jobId };
  }
}
```

- [ ] **Step 2: Run tests; expect PASS**

Run: `pnpm --filter @repo/core-shared test -- in-memory-job-queue`
Expected: 4 tests PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-shared/src/jobs/in-memory-job-queue.ts packages/core-shared/src/jobs/in-memory-job-queue.test.ts
git commit -m "feat(core-shared/jobs): InMemoryJobQueue"
```

### Task 6: Write the failing test for `PayloadJobQueue`

**Files:**
- Create: `packages/core-shared/src/jobs/payload-job-queue.test.ts`

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

```ts
// packages/core-shared/src/jobs/payload-job-queue.test.ts
import { describe, it, expect, vi } from "vitest";
import { PayloadJobQueue } from "@/jobs/payload-job-queue";

describe("PayloadJobQueue", () => {
  it("delegates to payload.jobs.queue with the task slug and input", async () => {
    const queueMock = vi.fn().mockResolvedValue({ id: "payload-job-1" });
    const payload = { jobs: { queue: queueMock } } as unknown as Parameters<
      typeof PayloadJobQueue.prototype.constructor
    >[0];
    const queue = new PayloadJobQueue(payload);
    const result = await queue.enqueue("blog.republish", { id: 1 });
    expect(queueMock).toHaveBeenCalledWith({
      task: "blog.republish",
      input: { id: 1 },
      waitUntil: undefined,
    });
    expect(result).toEqual({ jobId: "payload-job-1" });
  });

  it("forwards runAt as waitUntil", async () => {
    const queueMock = vi.fn().mockResolvedValue({ id: "payload-job-2" });
    const payload = { jobs: { queue: queueMock } } as never;
    const queue = new PayloadJobQueue(payload);
    const future = new Date(Date.now() + 5000);
    await queue.enqueue("blog.task", {}, { runAt: future });
    expect(queueMock).toHaveBeenCalledWith(
      expect.objectContaining({ waitUntil: future }),
    );
  });
});
```

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

Run: `pnpm --filter @repo/core-shared test -- payload-job-queue`
Expected: FAIL — `PayloadJobQueue` not exported.

### Task 7: Implement `PayloadJobQueue`

**Files:**
- Create: `packages/core-shared/src/jobs/payload-job-queue.ts`

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

```ts
// packages/core-shared/src/jobs/payload-job-queue.ts
import type { Payload } from "payload";
import type { IJobQueue } from "./job-queue.interface";

/**
 * Production-grade queue: enqueues into Payload's built-in jobs system.
 * The wrapped Payload instance is constructed once at app boot via
 * `getPayload({ config })` and passed in.
 */
export class PayloadJobQueue implements IJobQueue {
  constructor(private readonly payload: Payload) {}

  async enqueue<T>(
    taskSlug: string,
    input: T,
    options?: { runAt?: Date },
  ): Promise<{ jobId: string }> {
    const result = await this.payload.jobs.queue({
      task: taskSlug,
      input: input as never,
      waitUntil: options?.runAt,
    } as never);
    const jobId = (result as { id: string | number }).id;
    return { jobId: String(jobId) };
  }
}
```

> **Note on `as never` casts:** Payload's `jobs.queue` API uses generic types tied to the registered task registry. Since `PayloadJobQueue` is generic across all features, we accept the cost of a few `as never` casts at this boundary in exchange for a uniform `IJobQueue` interface. The boundary parse inside each job (spec § 5.5) recovers type safety at the actual handler entry.

- [ ] **Step 2: Run tests; expect PASS**

Run: `pnpm --filter @repo/core-shared test -- payload-job-queue`
Expected: 2 tests PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-shared/src/jobs/payload-job-queue.ts packages/core-shared/src/jobs/payload-job-queue.test.ts
git commit -m "feat(core-shared/jobs): PayloadJobQueue"
```

### Task 8: Create the `jobs/` barrel

**Files:**
- Create: `packages/core-shared/src/jobs/index.ts`

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

```ts
// packages/core-shared/src/jobs/index.ts
export type { IJobQueue } from "./job-queue.interface";
export { CORE_SHARED_JOBS_SYMBOLS } from "./symbols";
export { InMemoryJobQueue, type InMemoryHandler } from "./in-memory-job-queue";
export { PayloadJobQueue } from "./payload-job-queue";
```

- [ ] **Step 2: Verify typecheck and that the subpath resolves**

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

- [ ] **Step 3: Commit**

```bash
git add packages/core-shared/src/jobs/index.ts
git commit -m "feat(core-shared/jobs): public barrel via @repo/core-shared/jobs subpath"
```

---

## Phase 2 — `@repo/core-events` package

A new package, tag: `core` (spec § 3.1).

### Task 9: Scaffold the package skeleton

**Files:**
- Create: `packages/core-events/package.json`
- Create: `packages/core-events/tsconfig.json`
- Create: `packages/core-events/vitest.config.ts`
- Create: `packages/core-events/eslint.config.js`
- Create: `packages/core-events/turbo.json`
- Create: `packages/core-events/AGENTS.md`

- [ ] **Step 1: Read `packages/core-shared/package.json` and `packages/core-shared/tsconfig.json` as the reference shape**

These are the closest analog (also tag: `core`).

- [ ] **Step 2: Create `package.json`**

```json
{
  "name": "@repo/core-events",
  "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": {
    "@repo/core-shared": "workspace:*",
    "zod": "^3.23.0"
  },
  "peerDependencies": {
    "payload": "^3.0.0"
  },
  "peerDependenciesMeta": {
    "payload": { "optional": true }
  },
  "devDependencies": {
    "@repo/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "typescript": "^5.8.0",
    "vitest": "^3.0.0"
  }
}
```

- [ ] **Step 3: Create `tsconfig.json`**

```json
{
  "extends": "@repo/core-typescript/base.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
```

- [ ] **Step 4: Create `vitest.config.ts`**

```ts
import path from "path";
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { environment: "node", globals: true },
  resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
```

- [ ] **Step 5: Create `eslint.config.js`**

Match the shape of `packages/core-shared/eslint.config.js` (read it first). It should use `@repo/core-eslint`'s base config.

- [ ] **Step 6: Create `turbo.json`**

Match the shape of `packages/core-shared/turbo.json` (read it first). Single-file config that extends the root with a build/test/lint pipeline.

- [ ] **Step 7: Create `AGENTS.md`**

```markdown
# @repo/core-events

Owns the cross-feature event bus: `IEventBus`, `defineEvent`, and two implementations (`InMemoryEventBus`, `PayloadJobsEventBus`).

**Boundary tag:** core. May be imported by feature, core, core-composition, app. May import from core-shared, tooling.

**Public surface:** `IEventBus`, `EventDescriptor`, `defineEvent`, `EventHandler`, `CORE_EVENTS_SYMBOLS`, both implementations.

**See:** `docs/decisions/adr-015-events-and-jobs.md`, `docs/guides/events-and-jobs.md`.
```

- [ ] **Step 8: Run `pnpm install` so the workspace picks up the new package**

Run: `pnpm install`
Expected: clean install, `@repo/core-events` registered.

- [ ] **Step 9: Commit**

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

### Task 10: Add `core-events` to the boundary tag map

**Files:**
- Modify: `packages/core-eslint/` boundary config (file containing the tag map for `eslint-plugin-boundaries`)
- Modify: root `turbo.json` (boundaries config)

- [ ] **Step 1: Locate the boundary config**

Run: `grep -rn "core-shared" packages/core-eslint/src/ turbo.json | head`
Expected output should reveal where the tag map for `core` packages is configured.

- [ ] **Step 2: Add `@repo/core-events` to the `core` tag**

In every place that lists `core` packages by name (typically `packages/core-eslint/src/boundaries.ts` or similar plus `turbo.json`'s `boundaries.tags`), add `@repo/core-events` alongside `@repo/core-shared` and `@repo/core-ui`.

- [ ] **Step 3: Verify the boundary check passes**

Run: `pnpm turbo boundaries`
Expected: PASS.

- [ ] **Step 4: Commit**

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

### Task 11: Define `EventDescriptor` + `defineEvent`

**Files:**
- Create: `packages/core-events/src/event-descriptor.ts`
- Create: `packages/core-events/src/event-descriptor.test.ts`

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

```ts
// packages/core-events/src/event-descriptor.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";

describe("defineEvent", () => {
  it("returns a descriptor with name and schema", () => {
    const schema = z.object({ id: z.string() }).strict();
    const descriptor = defineEvent("test.thing.happened", schema);
    expect(descriptor.name).toBe("test.thing.happened");
    expect(descriptor.schema).toBe(schema);
  });

  it("descriptor.schema parses valid payloads", () => {
    const schema = z.object({ id: z.string() }).strict();
    const d = defineEvent("test.evt", schema);
    expect(() => d.schema.parse({ id: "abc" })).not.toThrow();
  });

  it("descriptor.schema rejects invalid payloads", () => {
    const schema = z.object({ id: z.string() }).strict();
    const d = defineEvent("test.evt", schema);
    expect(() => d.schema.parse({ id: 123 })).toThrow();
  });
});
```

- [ ] **Step 2: Run; expect FAIL** — `defineEvent` not exported.

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

- [ ] **Step 3: Implement**

```ts
// packages/core-events/src/event-descriptor.ts
import type { z } from "zod";

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

export function defineEvent<TName extends string, TSchema extends z.ZodType>(
  name: TName,
  schema: TSchema,
): EventDescriptor<TName, TSchema> {
  return { name, schema };
}
```

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

Run: `pnpm --filter @repo/core-events test`
Expected: 3 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-events/src/event-descriptor.ts packages/core-events/src/event-descriptor.test.ts
git commit -m "feat(core-events): EventDescriptor + defineEvent"
```

### Task 12: Define `IEventBus` interface and symbols

**Files:**
- Create: `packages/core-events/src/event-bus.interface.ts`
- Create: `packages/core-events/src/symbols.ts`

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

```ts
// packages/core-events/src/event-bus.interface.ts
import type { z } from "zod";
import type { EventDescriptor } from "./event-descriptor";

export type EventHandler<T> = (event: T) => Promise<void>;

export interface IEventBus {
  publish<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void>;

  /**
   * Subscribe a handler. `consumerFeature` is the kebab-case name of the
   * subscribing feature (e.g., "marketing-pages"). InMemoryEventBus uses it
   * only as a debug tag; PayloadJobsEventBus uses it to name the fan-out task
   * slug deterministically (`__events.<event>.<consumerFeature>`).
   */
  subscribe<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    consumerFeature: string,
    handler: EventHandler<T>,
  ): void;
}
```

- [ ] **Step 2: Create the symbols**

```ts
// packages/core-events/src/symbols.ts
export const CORE_EVENTS_SYMBOLS = {
  IEventBus: Symbol.for("@repo/core-events/IEventBus"),
} as const;
```

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

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

- [ ] **Step 4: Commit**

```bash
git add packages/core-events/src/event-bus.interface.ts packages/core-events/src/symbols.ts
git commit -m "feat(core-events): IEventBus interface + symbol registry"
```

### Task 13: Write the failing test for `InMemoryEventBus`

**Files:**
- Create: `packages/core-events/src/in-memory-event-bus.test.ts`

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

```ts
// packages/core-events/src/in-memory-event-bus.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
import { InMemoryEventBus } from "@/in-memory-event-bus";

const evt = defineEvent("test.thing", z.object({ id: z.string() }).strict());

describe("InMemoryEventBus", () => {
  it("validates the payload via the descriptor's schema before fanout", async () => {
    const bus = new InMemoryEventBus();
    const handler = vi.fn();
    bus.subscribe(evt, "test-consumer", handler);
    await expect(bus.publish(evt, { id: 123 } as unknown as { id: string })).rejects.toThrow();
    expect(handler).not.toHaveBeenCalled();
  });

  it("delivers to all registered handlers in parallel", async () => {
    const bus = new InMemoryEventBus();
    const a = vi.fn();
    const b = vi.fn();
    bus.subscribe(evt, "consumer-a", a);
    bus.subscribe(evt, "consumer-b", b);
    await bus.publish(evt, { id: "x" });
    expect(a).toHaveBeenCalledWith({ id: "x" });
    expect(b).toHaveBeenCalledWith({ id: "x" });
  });

  it("swallows handler errors by default (publisher's publish does not throw)", async () => {
    const bus = new InMemoryEventBus();
    bus.subscribe(evt, "boom", async () => {
      throw new Error("subscriber blew up");
    });
    await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
  });

  it("rethrows the first handler error when failFast is true", async () => {
    const bus = new InMemoryEventBus({ failFast: true });
    bus.subscribe(evt, "first", async () => {
      throw new Error("first failure");
    });
    bus.subscribe(evt, "second", vi.fn());
    await expect(bus.publish(evt, { id: "x" })).rejects.toThrow("first failure");
  });

  it("delivers nothing when no handlers are registered", async () => {
    const bus = new InMemoryEventBus();
    await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
  });
});
```

- [ ] **Step 2: Run; expect FAIL** — `InMemoryEventBus` not exported.

Run: `pnpm --filter @repo/core-events test -- in-memory-event-bus`
Expected: FAIL.

### Task 14: Implement `InMemoryEventBus`

**Files:**
- Create: `packages/core-events/src/in-memory-event-bus.ts`

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

```ts
// packages/core-events/src/in-memory-event-bus.ts
import type { z } from "zod";
import type { EventDescriptor } from "./event-descriptor";
import type { EventHandler, IEventBus } from "./event-bus.interface";

export type InMemoryEventBusOptions = {
  /** When true, rethrow the first handler error (default: false — errors swallowed). */
  failFast?: boolean;
};

export class InMemoryEventBus implements IEventBus {
  private readonly handlers = new Map<string, EventHandler<unknown>[]>();

  constructor(private readonly options: InMemoryEventBusOptions = {}) {}

  async publish<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    const subscribers = this.handlers.get(descriptor.name) ?? [];
    if (subscribers.length === 0) return;
    const settled = await Promise.allSettled(
      subscribers.map((h) => h(payload)),
    );
    if (this.options.failFast) {
      const failure = settled.find((s) => s.status === "rejected");
      if (failure && failure.status === "rejected") throw failure.reason;
    }
  }

  subscribe<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    _consumerFeature: string, // tagging only; no behavior
    handler: EventHandler<T>,
  ): void {
    const arr = this.handlers.get(descriptor.name) ?? [];
    arr.push(handler as EventHandler<unknown>);
    this.handlers.set(descriptor.name, arr);
  }
}
```

- [ ] **Step 2: Run tests; expect PASS**

Run: `pnpm --filter @repo/core-events test -- in-memory-event-bus`
Expected: 5 tests PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-events/src/in-memory-event-bus.ts packages/core-events/src/in-memory-event-bus.test.ts
git commit -m "feat(core-events): InMemoryEventBus with failFast option"
```

### Task 15: Write the failing test for `PayloadJobsEventBus`

**Files:**
- Create: `packages/core-events/src/payload-jobs-event-bus.test.ts`

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

```ts
// packages/core-events/src/payload-jobs-event-bus.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
import { PayloadJobsEventBus } from "@/payload-jobs-event-bus";
import type { IJobQueue } from "@repo/core-shared/jobs";

const evt = defineEvent("auth.user.signed-up", z.object({ userId: z.string() }).strict());

function recordingQueue(): IJobQueue & { enqueued: { taskSlug: string; input: unknown }[] } {
  const enqueued: { taskSlug: string; input: unknown }[] = [];
  const q: IJobQueue = {
    async enqueue(taskSlug, input) {
      enqueued.push({ taskSlug, input });
      return { jobId: `recording-${enqueued.length}` };
    },
  };
  return Object.assign(q, { enqueued });
}

describe("PayloadJobsEventBus", () => {
  it("validates the payload before enqueueing", async () => {
    const queue = recordingQueue();
    const bus = new PayloadJobsEventBus(queue);
    bus.subscribe(evt, "marketing-pages", vi.fn());
    await expect(
      bus.publish(evt, { userId: 42 } as unknown as { userId: string }),
    ).rejects.toThrow();
    expect(queue.enqueued).toHaveLength(0);
  });

  it("enqueues one task per subscriber, naming `__events.<event>.<consumer>`", async () => {
    const queue = recordingQueue();
    const bus = new PayloadJobsEventBus(queue);
    bus.subscribe(evt, "marketing-pages", vi.fn());
    bus.subscribe(evt, "blog", vi.fn());
    await bus.publish(evt, { userId: "u1" });
    expect(queue.enqueued).toHaveLength(2);
    expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
      "__events.auth.user.signed-up.blog",
      "__events.auth.user.signed-up.marketing-pages",
    ]);
    expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
  });

  it("enqueues nothing when no subscribers are registered", async () => {
    const queue = recordingQueue();
    const bus = new PayloadJobsEventBus(queue);
    await bus.publish(evt, { userId: "u1" });
    expect(queue.enqueued).toHaveLength(0);
  });
});
```

- [ ] **Step 2: Run; expect FAIL** — `PayloadJobsEventBus` not exported.

Run: `pnpm --filter @repo/core-events test -- payload-jobs-event-bus`
Expected: FAIL.

### Task 16: Implement `PayloadJobsEventBus`

**Files:**
- Create: `packages/core-events/src/payload-jobs-event-bus.ts`

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

```ts
// packages/core-events/src/payload-jobs-event-bus.ts
import type { z } from "zod";
import type { IJobQueue } from "@repo/core-shared/jobs";
import type { EventDescriptor } from "./event-descriptor";
import type { EventHandler, IEventBus } from "./event-bus.interface";

/**
 * Production-grade bus: for each subscriber, enqueues one Payload task per
 * `publish()` call. The subscribers are registered locally (their consumer
 * feature name is the second arg to `subscribe`) so this bus can name the
 * fan-out tasks deterministically. The actual handler invocation happens
 * inside Payload's job runner — see the consumer feature's bind-production
 * for the wiring that registers a corresponding TaskConfig with that slug.
 */
export class PayloadJobsEventBus implements IEventBus {
  private readonly subscribers = new Map<string, string[]>(); // event name → consumer-feature names

  constructor(private readonly queue: IJobQueue) {}

  async publish<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    const consumers = this.subscribers.get(descriptor.name) ?? [];
    await Promise.all(
      consumers.map((consumerFeature) =>
        this.queue.enqueue(
          `__events.${descriptor.name}.${consumerFeature}`,
          payload,
        ),
      ),
    );
  }

  subscribe<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    consumerFeature: string,
    _handler: EventHandler<T>,
  ): void {
    const arr = this.subscribers.get(descriptor.name) ?? [];
    if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
    this.subscribers.set(descriptor.name, arr);
  }
}
```

> **Implementation note:** `PayloadJobsEventBus.subscribe` doesn't actually invoke the consumer's handler — it stores the consumer feature name and, on `publish`, enqueues a Payload task at slug `__events.<event>.<consumerFeature>`. The matching Payload task config is generated by `gen event consume` (Task 40 — see the additional `add` action that emits an `__events-<publisher>-<event>.task.ts` alongside the handler). The task config delegates to the consumer's container-resolved wrapped handler. With the generator + `core-cms` aggregation, the production bus is fully wired end-to-end.

- [ ] **Step 2: Run tests; expect PASS**

Run: `pnpm --filter @repo/core-events test -- payload-jobs-event-bus`
Expected: 3 tests PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/core-events/src/payload-jobs-event-bus.ts packages/core-events/src/payload-jobs-event-bus.test.ts
git commit -m "feat(core-events): PayloadJobsEventBus (fan-out via IJobQueue)"
```

### Task 17: Create `core-events` public barrel

**Files:**
- Create: `packages/core-events/src/index.ts`

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

```ts
// packages/core-events/src/index.ts
export type { EventDescriptor } from "./event-descriptor";
export { defineEvent } from "./event-descriptor";
export type { IEventBus, EventHandler } from "./event-bus.interface";
export { CORE_EVENTS_SYMBOLS } from "./symbols";
export { InMemoryEventBus, type InMemoryEventBusOptions } from "./in-memory-event-bus";
export { PayloadJobsEventBus } from "./payload-jobs-event-bus";
```

- [ ] **Step 2: Verify the package resolves**

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

- [ ] **Step 3: Commit**

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

---

## Phase 3 — Recording test helpers

### Task 18: Write the failing test for `RecordingJobQueue`

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-job-queue.test.ts`

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

```ts
// packages/core-testing/src/instrumentation/recording-job-queue.test.ts
import { describe, it, expect } from "vitest";
import { RecordingJobQueue } from "@/instrumentation/recording-job-queue";

describe("RecordingJobQueue", () => {
  it("records every enqueue call", async () => {
    const queue = new RecordingJobQueue();
    const future = new Date("2030-01-01");
    await queue.enqueue("a.task", { x: 1 });
    await queue.enqueue("b.task", { y: 2 }, { runAt: future });
    expect(queue.enqueued).toEqual([
      { taskSlug: "a.task", input: { x: 1 }, options: undefined },
      { taskSlug: "b.task", input: { y: 2 }, options: { runAt: future } },
    ]);
  });

  it("returns a synthetic jobId per call", async () => {
    const queue = new RecordingJobQueue();
    const a = await queue.enqueue("a", {});
    const b = await queue.enqueue("b", {});
    expect(a.jobId).toBe("recording-1");
    expect(b.jobId).toBe("recording-2");
  });
});
```

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

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

### Task 19: Implement `RecordingJobQueue`

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-job-queue.ts`
- Modify: `packages/core-testing/src/instrumentation/index.ts` (export the new class)

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

```ts
// packages/core-testing/src/instrumentation/recording-job-queue.ts
import type { IJobQueue } from "@repo/core-shared/jobs";

export class RecordingJobQueue implements IJobQueue {
  readonly enqueued: { taskSlug: string; input: unknown; options?: { runAt?: Date } }[] = [];

  async enqueue<T>(
    taskSlug: string,
    input: T,
    options?: { runAt?: Date },
  ): Promise<{ jobId: string }> {
    this.enqueued.push({ taskSlug, input, options });
    return { jobId: `recording-${this.enqueued.length}` };
  }
}
```

- [ ] **Step 2: Add `@repo/core-shared` to core-testing dependencies if not already present**

Read `packages/core-testing/package.json`. If `@repo/core-shared` is not in `dependencies`, add it: `"@repo/core-shared": "workspace:*"`. Then `pnpm install`.

- [ ] **Step 3: Re-export from the instrumentation barrel**

Read `packages/core-testing/src/instrumentation/index.ts` and add:

```ts
export { RecordingJobQueue } from "./recording-job-queue";
```

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

Run: `pnpm --filter @repo/core-testing test -- recording-job-queue`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-testing/src/instrumentation/recording-job-queue.ts packages/core-testing/src/instrumentation/recording-job-queue.test.ts packages/core-testing/src/instrumentation/index.ts packages/core-testing/package.json
git commit -m "feat(core-testing): RecordingJobQueue"
```

### Task 20: Write the failing test for `RecordingEventBus`

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-event-bus.test.ts`

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

```ts
// packages/core-testing/src/instrumentation/recording-event-bus.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@repo/core-events";
import { RecordingEventBus } from "@/instrumentation/recording-event-bus";

const evt = defineEvent("test.evt", z.object({ id: z.string() }).strict());

describe("RecordingEventBus", () => {
  it("records every publish call after schema validation", async () => {
    const bus = new RecordingEventBus();
    await bus.publish(evt, { id: "a" });
    await bus.publish(evt, { id: "b" });
    expect(bus.published).toEqual([
      { name: "test.evt", payload: { id: "a" } },
      { name: "test.evt", payload: { id: "b" } },
    ]);
  });

  it("rejects invalid payloads", async () => {
    const bus = new RecordingEventBus();
    await expect(
      bus.publish(evt, { id: 1 } as unknown as { id: string }),
    ).rejects.toThrow();
    expect(bus.published).toHaveLength(0);
  });

  it("invokes registered handlers sequentially in subscription order", async () => {
    const bus = new RecordingEventBus();
    const order: string[] = [];
    bus.subscribe(evt, "consumer-a", async () => {
      order.push("a");
    });
    bus.subscribe(evt, "consumer-b", async () => {
      order.push("b");
    });
    await bus.publish(evt, { id: "x" });
    expect(order).toEqual(["a", "b"]);
  });
});
```

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

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

### Task 21: Implement `RecordingEventBus`

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-event-bus.ts`
- Modify: `packages/core-testing/src/instrumentation/index.ts`
- Modify: `packages/core-testing/package.json` (add `@repo/core-events`)

- [ ] **Step 1: Add `@repo/core-events` to core-testing deps**

In `packages/core-testing/package.json`, add `"@repo/core-events": "workspace:*"` to `dependencies`. Then `pnpm install`.

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

```ts
// packages/core-testing/src/instrumentation/recording-event-bus.ts
import type { z } from "zod";
import type { EventDescriptor, EventHandler, IEventBus } from "@repo/core-events";

export class RecordingEventBus implements IEventBus {
  readonly published: { name: string; payload: unknown }[] = [];
  private readonly handlers = new Map<string, EventHandler<unknown>[]>();

  async publish<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    this.published.push({ name: descriptor.name, payload });
    for (const h of this.handlers.get(descriptor.name) ?? []) await h(payload);
  }

  subscribe<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    _consumerFeature: string,
    handler: EventHandler<T>,
  ): void {
    const arr = this.handlers.get(descriptor.name) ?? [];
    arr.push(handler as EventHandler<unknown>);
    this.handlers.set(descriptor.name, arr);
  }
}
```

- [ ] **Step 3: Re-export from the barrel**

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

```ts
export { RecordingEventBus } from "./recording-event-bus";
```

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

Run: `pnpm --filter @repo/core-testing test -- recording-event-bus`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-testing/src/instrumentation/recording-event-bus.ts packages/core-testing/src/instrumentation/recording-event-bus.test.ts packages/core-testing/src/instrumentation/index.ts packages/core-testing/package.json
git commit -m "feat(core-testing): RecordingEventBus"
```

---

## Phase 4 — ESLint rules

### Task 22: Write the failing test for `no-handler-reexport`

**Files:**
- Create: `packages/core-eslint/src/no-handler-reexport.rule.test.ts`

- [ ] **Step 1: Find the existing rule-test pattern**

Run: `find packages/core-eslint -name "*.rule.ts" -o -name "*.rule.test.ts" | head`. If no custom rule tests exist yet, check `packages/core-eslint`'s test setup conventions for the canonical `RuleTester` usage.

- [ ] **Step 2: Write the test using `RuleTester`**

```ts
// packages/core-eslint/src/no-handler-reexport.rule.test.ts
import { RuleTester } from "@typescript-eslint/rule-tester";
import { describe } from "vitest";
import { rule } from "./no-handler-reexport.rule";

RuleTester.afterAll = () => {};
RuleTester.it = (text, fn) => fn();
RuleTester.itOnly = (text, fn) => fn();
RuleTester.describe = (text, fn) => fn();

const tester = new RuleTester();

describe("no-handler-reexport", () => {
  tester.run("no-handler-reexport", rule, {
    valid: [
      { code: `export { foo } from "./foo";`, filename: "src/index.ts" },
      { code: `export * from "./bar";`, filename: "src/index.ts" },
      { code: `export { useCase } from "./application/sign-in.use-case";`, filename: "src/index.ts" },
    ],
    invalid: [
      {
        code: `export { onUserSignedUp } from "./events/handlers/on-auth-user-signed-up.handler";`,
        filename: "src/index.ts",
        errors: [{ messageId: "noHandlerReexport" }],
      },
      {
        code: `export * from "./events/handlers/on-auth-user-signed-up.handler";`,
        filename: "src/index.ts",
        errors: [{ messageId: "noHandlerReexport" }],
      },
    ],
  });
});
```

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

Run: `pnpm --filter @repo/core-eslint test -- no-handler-reexport`
Expected: FAIL — rule not exported.

### Task 23: Implement `no-handler-reexport`

**Files:**
- Create: `packages/core-eslint/src/no-handler-reexport.rule.ts`
- Modify: `packages/core-eslint/src/index.ts` or wherever rules are aggregated for distribution
- Modify: the boundary config that adds rules to feature/core packages

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

```ts
// packages/core-eslint/src/no-handler-reexport.rule.ts
import { ESLintUtils } from "@typescript-eslint/utils";

const HANDLER_PATTERN = /\/events\/handlers\/[^/]+\.handler(\.ts)?$/;

export const rule = ESLintUtils.RuleCreator.withoutDocs({
  meta: {
    type: "problem",
    docs: { description: "Event handlers must not be re-exported (Rule E1)." },
    messages: {
      noHandlerReexport:
        "Event handlers (`events/handlers/*.handler.ts`) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    return {
      ExportNamedDeclaration(node) {
        if (node.source && HANDLER_PATTERN.test(node.source.value)) {
          context.report({ node, messageId: "noHandlerReexport" });
        }
      },
      ExportAllDeclaration(node) {
        if (node.source && HANDLER_PATTERN.test(node.source.value)) {
          context.report({ node, messageId: "noHandlerReexport" });
        }
      },
    };
  },
});
```

- [ ] **Step 2: Wire the rule into the published config**

Read the existing rule-aggregation file in `packages/core-eslint` (likely `src/index.ts` or `src/configs/feature.ts`). Register the new rule under the `@repo` namespace (or whatever the existing custom-rule namespace is). Add the rule to the feature flat-config preset so all feature packages enforce it.

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

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

- [ ] **Step 4: Verify no existing feature trips it**

Run: `pnpm lint`
Expected: PASS — no current code violates the rule.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/src/no-handler-reexport.rule.ts packages/core-eslint/src/no-handler-reexport.rule.test.ts packages/core-eslint/src/index.ts
git commit -m "feat(core-eslint): rule no-handler-reexport (Rule E1)"
```

### Task 24: Write the failing test for `no-direct-payload-jobs`

**Files:**
- Create: `packages/core-eslint/src/no-direct-payload-jobs.rule.test.ts`

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

```ts
// packages/core-eslint/src/no-direct-payload-jobs.rule.test.ts
import { RuleTester } from "@typescript-eslint/rule-tester";
import { describe } from "vitest";
import { rule } from "./no-direct-payload-jobs.rule";

RuleTester.afterAll = () => {};
RuleTester.it = (text, fn) => fn();
RuleTester.itOnly = (text, fn) => fn();
RuleTester.describe = (text, fn) => fn();

const tester = new RuleTester();

describe("no-direct-payload-jobs", () => {
  tester.run("no-direct-payload-jobs", rule, {
    valid: [
      // Allowed inside the integration layer
      {
        code: `await payload.jobs.queue({ task: "x", input: {} });`,
        filename: "packages/blog/src/integrations/cms/jobs/republish.task.ts",
      },
      // Allowed inside core-shared/jobs
      {
        code: `await payload.jobs.queue({ task: "x", input: {} });`,
        filename: "packages/core-shared/src/jobs/payload-job-queue.ts",
      },
      // Allowed: any non-jobs payload access
      {
        code: `await payload.find({ collection: "x" });`,
        filename: "packages/blog/src/infrastructure/repositories/articles.repository.ts",
      },
    ],
    invalid: [
      {
        code: `await payload.jobs.queue({ task: "x", input: {} });`,
        filename: "packages/blog/src/application/use-cases/x.use-case.ts",
        errors: [{ messageId: "noDirectPayloadJobs" }],
      },
      {
        code: `await payload.jobs.queue({ task: "x", input: {} });`,
        filename: "packages/blog/src/infrastructure/repositories/articles.repository.ts",
        errors: [{ messageId: "noDirectPayloadJobs" }],
      },
    ],
  });
});
```

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

Run: `pnpm --filter @repo/core-eslint test -- no-direct-payload-jobs`
Expected: FAIL.

### Task 25: Implement `no-direct-payload-jobs`

**Files:**
- Create: `packages/core-eslint/src/no-direct-payload-jobs.rule.ts`
- Modify: rule aggregation file

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

```ts
// packages/core-eslint/src/no-direct-payload-jobs.rule.ts
import { ESLintUtils } from "@typescript-eslint/utils";

const ALLOW_PATTERNS = [
  /\/integrations\/cms\/jobs\//,
  /\/core-shared\/src\/jobs\//,
];

export const rule = ESLintUtils.RuleCreator.withoutDocs({
  meta: {
    type: "problem",
    docs: {
      description:
        "Direct `payload.jobs.*` access is forbidden outside the integration layer. Use IJobQueue.",
    },
    messages: {
      noDirectPayloadJobs:
        "`payload.jobs.*` is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in integrations/cms/jobs/** and core-shared/jobs/**.",
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    const filename = context.filename ?? context.getFilename();
    if (ALLOW_PATTERNS.some((p) => p.test(filename))) return {};
    return {
      MemberExpression(node) {
        if (
          node.object.type === "MemberExpression" &&
          node.object.object.type === "Identifier" &&
          node.object.object.name === "payload" &&
          node.object.property.type === "Identifier" &&
          node.object.property.name === "jobs"
        ) {
          context.report({ node, messageId: "noDirectPayloadJobs" });
        }
      },
    };
  },
});
```

- [ ] **Step 2: Wire into the published config**

Same rule-aggregation file as Task 23. Add to feature + repository presets.

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

Run: `pnpm --filter @repo/core-eslint test -- no-direct-payload-jobs`
Expected: PASS.

- [ ] **Step 4: Verify no existing code trips it**

Run: `pnpm lint`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/src/no-direct-payload-jobs.rule.ts packages/core-eslint/src/no-direct-payload-jobs.rule.test.ts packages/core-eslint/src/index.ts
git commit -m "feat(core-eslint): rule no-direct-payload-jobs"
```

---

## Phase 5 — Existing-feature anchor retrofit

Add the six anchor comments to all five features. Each is a single-line comment that the generator will insert before. **Pure additive comments — no behavior change.** Spec § 9.1.

### Task 26: Add anchors to `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`
- Modify: `packages/auth/src/integrations/cms/index.ts` (create file with anchor if missing)

- [ ] **Step 1: Read each target file to find the right insertion point**

For each file, the anchor goes near the bottom of the relevant section so generators insert at the natural append point.

- [ ] **Step 2: Add `// <gen:events>` to `src/index.ts`**

At the end of the file (before the final EOF, after all existing exports):

```ts
// <gen:events>
```

- [ ] **Step 3: Add anchors to `src/di/symbols.ts`**

In the symbol registry block, after the last existing entry but before any closing brace:

```ts
  // <gen:event-handler-symbols>
  // <gen:job-symbols>
```

The anchors live inside the `as const` symbol object — be careful to place them where TypeScript still accepts the syntax (between symbol entries, formatted as standalone comment lines).

- [ ] **Step 4: Add anchors to `src/di/bind-production.ts`**

Inside `bindProductionAuth(...)`, near the end of the function body but before the closing brace:

```ts
  // <gen:event-handlers>
  // <gen:jobs>
```

Place these AFTER the existing controller bindings — generators insert their blocks at the end of the binder.

- [ ] **Step 5: Add the same two anchors to `src/di/bind-dev-seed.ts`**

Same locations, near the end of the dev-seed binder function body.

- [ ] **Step 6: Handle `src/integrations/cms/index.ts`**

Run `ls packages/auth/src/integrations/cms/index.ts 2>/dev/null`.

If the file exists, add at the end:

```ts
// <gen:job-tasks>
```

If it does NOT exist, create it with just:

```ts
// packages/auth/src/integrations/cms/index.ts
// <gen:job-tasks>
export {};
```

- [ ] **Step 7: Verify nothing broke**

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

- [ ] **Step 8: Commit**

```bash
git add packages/auth/src/
git commit -m "chore(auth): add // <gen:*> anchor comments for event/job generators"
```

### Task 27: Add anchors to `blog`

Same shape as Task 26, applied to `packages/blog/src/`.

- [ ] **Step 1: Apply the six anchors per Task 26 layout**

Files: `src/index.ts`, `src/di/symbols.ts`, `src/di/bind-production.ts`, `src/di/bind-dev-seed.ts`, `src/integrations/cms/index.ts`.

- [ ] **Step 2: Verify**

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

- [ ] **Step 3: Commit**

```bash
git add packages/blog/src/
git commit -m "chore(blog): add // <gen:*> anchor comments for event/job generators"
```

### Task 28: Add anchors to `media`

Same shape as Task 26, applied to `packages/media/src/`.

- [ ] **Step 1: Apply the six anchors**
- [ ] **Step 2: Verify** — `pnpm --filter @repo/media typecheck test lint`
- [ ] **Step 3: Commit** — `chore(media): add // <gen:*> anchor comments for event/job generators`

### Task 29: Add anchors to `marketing-pages`

Same shape applied to `packages/marketing-pages/src/`.

- [ ] **Step 1: Apply the six anchors**
- [ ] **Step 2: Verify** — `pnpm --filter @repo/marketing-pages typecheck test lint`
- [ ] **Step 3: Commit** — `chore(marketing-pages): add // <gen:*> anchor comments for event/job generators`

### Task 30: Add anchors to `navigation`

Same shape applied to `packages/navigation/src/`.

- [ ] **Step 1: Apply the six anchors**
- [ ] **Step 2: Verify** — `pnpm --filter @repo/navigation typecheck test lint`
- [ ] **Step 3: Commit** — `chore(navigation): add // <gen:*> anchor comments for event/job generators`

### Task 31: Add an anchor-presence CI guard

**Files:**
- Create: `tests/anchors.test.ts` (or add to an existing repo-level test file)

- [ ] **Step 1: Determine where repo-level tests live**

Run: `find . -maxdepth 3 -name "*.test.ts" -path "*/tests/*" | head` and inspect.

- [ ] **Step 2: Write a guard test**

```ts
// tests/anchors.test.ts (or appropriate location)
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const FEATURES = ["auth", "blog", "media", "marketing-pages", "navigation"];
const ANCHORS = {
  "src/index.ts": ["// <gen:events>"],
  "src/di/symbols.ts": ["// <gen:event-handler-symbols>", "// <gen:job-symbols>"],
  "src/di/bind-production.ts": ["// <gen:event-handlers>", "// <gen:jobs>"],
  "src/di/bind-dev-seed.ts": ["// <gen:event-handlers>", "// <gen:jobs>"],
  "src/integrations/cms/index.ts": ["// <gen:job-tasks>"],
};

describe("// <gen:*> anchor presence", () => {
  for (const feature of FEATURES) {
    for (const [relPath, anchors] of Object.entries(ANCHORS)) {
      it(`${feature}/${relPath} contains all required anchors`, () => {
        const path = join(__dirname, "..", "packages", feature, relPath);
        const content = readFileSync(path, "utf8");
        for (const anchor of anchors) {
          expect(content).toContain(anchor);
        }
      });
    }
  }
});
```

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

Run: `pnpm vitest run tests/anchors.test.ts` (adjust path as needed)
Expected: 25 tests PASS (5 features × 5 file/anchor combinations).

- [ ] **Step 4: Commit**

```bash
git add tests/anchors.test.ts
git commit -m "test: anchor-presence CI guard for // <gen:*> comments"
```

### Task 32: 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`

(The template does NOT include `integrations/cms/index.ts` — `gen feature` skips Payload CMS templates per its current Phase-1 scope, so the `<gen:job-tasks>` anchor is added by `gen job` if the user later adds CMS integration to a generated feature.)

- [ ] **Step 1: Read each `.hbs` file to find the insertion points**
- [ ] **Step 2: Add anchors at the same logical positions as Task 26**
- [ ] **Step 3: Run the existing `gen feature` against a sandbox name to verify the template still renders**

```bash
cd /tmp && cp -r ~/Documents/Projects/template-vertical /tmp/template-test && cd /tmp/template-test && pnpm install && pnpm turbo gen feature --args anchor-test AnchorTest anchor-tests && pnpm --filter @repo/anchor-test typecheck lint test
```

Expected: clean generation; package passes typecheck/lint/test.

- [ ] **Step 4: Clean up the sandbox**

```bash
rm -rf /tmp/template-test
```

- [ ] **Step 5: Commit**

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

---

## Phase 6 — Per-feature binder signatures + bindAll() bus/queue swap

### Task 33: Extend `bindProduction<Feature>` and `bindDevSeed<Feature>` signatures

**Files (per feature, applied to all 5):**
- Modify: `packages/<feature>/src/di/bind-production.ts`
- Modify: `packages/<feature>/src/di/bind-production.test.ts`
- Modify: `packages/<feature>/src/di/bind-dev-seed.ts`
- Modify: `packages/<feature>/src/di/bind-dev-seed.test.ts`

**Per-feature task structure** — the steps below apply to each feature in order: `auth`, `blog`, `media`, `marketing-pages`, `navigation`. Either bundle into one task per feature (5 commits) or one task with 5 sub-batches. Bundle.

- [ ] **Step 1: For `auth` — extend the binder signatures**

Modify `packages/auth/src/di/bind-production.ts`:

```ts
// imports — add at top
import type { IEventBus } from "@repo/core-events";
import type { IJobQueue } from "@repo/core-shared/jobs";

// signature change
export function bindProductionAuth(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
): void {
  // ... existing body unchanged ...
}
```

The body does NOT yet use `bus` / `queue` — Phase 7 wires them when the proof-of-life event/handler/job land. For this phase, accept-and-forward is sufficient.

- [ ] **Step 2: Same for `auth`'s `bind-dev-seed.ts`**

```ts
export async function bindDevSeedAuth(
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
): Promise<void> {
  // ... existing body unchanged ...
}
```

- [ ] **Step 3: Update `auth`'s tests**

Both `bind-production.test.ts` and `bind-dev-seed.test.ts` currently call the binders. Add a `RecordingEventBus` and `RecordingJobQueue` instantiation at the top of each test and pass them through:

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

// inside each test:
const bus = new RecordingEventBus();
const queue = new RecordingJobQueue();
bindProductionAuth(config, tracer, logger, bus, queue);
```

Add `@repo/core-events` to `packages/auth/package.json`'s `devDependencies` if not already a dep. Add `@repo/core-shared/jobs` access (no separate install — the package is already a dep).

- [ ] **Step 4: Verify auth typechecks/tests pass**

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

- [ ] **Step 5: Commit auth**

```bash
git add packages/auth/src/di/ packages/auth/package.json
git commit -m "feat(auth): bind binders accept (bus, queue) params"
```

- [ ] **Step 6: Repeat steps 1–5 for `blog`**

Same edits applied to `packages/blog/src/di/bind-production.ts`, `bind-dev-seed.ts`, and their test files. Update `packages/blog/package.json` if needed.

Commit: `feat(blog): bind binders accept (bus, queue) params`

- [ ] **Step 7: Repeat for `media`**

Commit: `feat(media): bind binders accept (bus, queue) params`

- [ ] **Step 8: Repeat for `marketing-pages`**

Commit: `feat(marketing-pages): bind binders accept (bus, queue) params`

- [ ] **Step 9: Repeat for `navigation`**

Commit: `feat(navigation): bind binders accept (bus, queue) params`

### Task 34: Add `resolveEventsAndJobs()` to `apps/web-next` bindAll

**Files:**
- Modify: `apps/web-next/src/server/bind-production.ts`
- Modify: `apps/web-next/src/server/bind-production.test.ts`
- Modify: `apps/web-next/package.json` (add `@repo/core-events` if not already a dep)

- [ ] **Step 1: Add deps if missing**

In `apps/web-next/package.json`, add `"@repo/core-events": "workspace:*"` to `dependencies` if absent. Run `pnpm install`.

- [ ] **Step 2: Add `resolveEventsAndJobs()` to `bind-production.ts`**

```ts
// inside apps/web-next/src/server/bind-production.ts
import {
  InMemoryEventBus,
  PayloadJobsEventBus,
  type IEventBus,
} from "@repo/core-events";
import {
  InMemoryJobQueue,
  PayloadJobQueue,
  type IJobQueue,
} from "@repo/core-shared/jobs";
import { getPayload } from "payload";

let resolvedBus: IEventBus | null = null;
let resolvedQueue: IJobQueue | null = null;

async function resolveEventsAndJobsProduction(): Promise<{ bus: IEventBus; queue: IJobQueue }> {
  if (resolvedBus && resolvedQueue) return { bus: resolvedBus, queue: resolvedQueue };
  const resolvedConfig = await config;
  const payload = await getPayload({ config: resolvedConfig });
  const queue = new PayloadJobQueue(payload);
  const bus = new PayloadJobsEventBus(queue);
  resolvedBus = bus;
  resolvedQueue = queue;
  return { bus, queue };
}

function resolveEventsAndJobsDevSeed(): { bus: IEventBus; queue: IJobQueue } {
  if (resolvedBus && resolvedQueue) return { bus: resolvedBus, queue: resolvedQueue };
  // In-memory queue starts with no handlers; per-feature binders will register them.
  // We construct one empty queue per app-boot; the dev-seed binders mutate the
  // shared `handlers` map by re-creating the queue once they know their tasks.
  // For Phase 1 we accept that dev-seed jobs run in-process via the binders'
  // direct closure; the queue.enqueue path is exercised by tests using
  // RecordingJobQueue, which is sufficient validation.
  const queue = new InMemoryJobQueue({});
  const bus = new InMemoryEventBus();
  resolvedBus = bus;
  resolvedQueue = queue;
  return { bus, queue };
}
```

> **Implementation note:** `InMemoryJobQueue`'s constructor takes a static handler map — but features want to register handlers at bind time. Two options:
> 1. Make `InMemoryJobQueue` mutable with a `register(slug, handler)` method (small API change in Phase 1).
> 2. Defer handler registration to a per-feature mechanism and exercise the queue only via test recordings.
>
> **Choice for this plan:** go with option 1. Add `register(slug, handler)` to `InMemoryJobQueue` as a follow-up step inside this task (sub-step below).

- [ ] **Step 3: Add `register` method to `InMemoryJobQueue`**

In `packages/core-shared/src/jobs/in-memory-job-queue.ts`:

```ts
// add inside class InMemoryJobQueue
register(slug: string, handler: InMemoryHandler): void {
  this.handlers[slug] = handler;
}
```

Constructor changes:

```ts
constructor(handlers: Record<string, InMemoryHandler> = {}) {
  this.handlers = { ...handlers };
}
private readonly handlers: Record<string, InMemoryHandler>;
```

Add a unit test in `in-memory-job-queue.test.ts`:

```ts
it("register adds a handler that can be enqueued against", async () => {
  const queue = new InMemoryJobQueue();
  const handler = vi.fn();
  queue.register("late.task", handler);
  await queue.enqueue("late.task", { z: 1 });
  await new Promise((r) => setImmediate(r));
  expect(handler).toHaveBeenCalledWith({ z: 1 });
});
```

Run tests: `pnpm --filter @repo/core-shared test -- in-memory-job-queue` — expect PASS.

- [ ] **Step 4: Update `bindAllProduction` and `bindAllDevSeed` to pass through**

```ts
export async function bindAllProduction(): Promise<void> {
  if (bound) return;
  bound = true;
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = await resolveEventsAndJobsProduction();
  const resolvedConfig = await config;
  bindProductionAuth(resolvedConfig, tracer, logger, bus, queue);
  bindProductionBlog(resolvedConfig, tracer, logger, bus, queue);
  bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue);
  bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue);
  bindProductionMedia(resolvedConfig, tracer, logger, bus, queue);
}

export async function bindAllDevSeed(): Promise<void> {
  if (bound) return;
  bound = true;
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = resolveEventsAndJobsDevSeed();
  await bindDevSeedAuth(tracer, logger, bus, queue);
  await bindDevSeedBlog(tracer, logger, bus, queue);
  await bindDevSeedMarketingPages(tracer, logger, bus, queue);
  await bindDevSeedNavigation(tracer, logger, bus, queue);
  await bindDevSeedMedia(tracer, logger, bus, queue);
}
```

Update `__resetBindStateForTests`:

```ts
export function __resetBindStateForTests(): void {
  bound = false;
  resolvedTracer = null;
  resolvedLogger = null;
  resolvedBus = null;
  resolvedQueue = null;
}
```

- [ ] **Step 5: Update `bind-production.test.ts`**

Read the existing test file. Update it to assert that `bindAllProduction` resolves the Payload-backed bus + queue and that `bindAllDevSeed` resolves the in-memory ones. Add a test asserting `bus` and `queue` are passed to each per-feature binder (use `vi.mock` on the per-feature binders, or read existing mocking patterns).

- [ ] **Step 6: Verify**

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

- [ ] **Step 7: Commit**

```bash
git add apps/web-next/ packages/core-shared/src/jobs/
git commit -m "feat(web-next): bindAll resolves IEventBus + IJobQueue per env (spec § 6.3)"
```

### Task 35: Mirror Task 34 to other apps that have a bindAll

**Files:**
- Modify: `apps/cms/src/server/bind-production.ts` (if exists)
- Modify: `apps/web-tanstack/src/server/bind-production.ts` (if exists)

- [ ] **Step 1: Check for the file in each app**

Run: `ls apps/cms/src/server/bind-production.ts apps/web-tanstack/src/server/bind-production.ts 2>/dev/null`

- [ ] **Step 2: For each existing file, apply the same changes as Task 34**

Use the same `resolveEventsAndJobs*` helpers. The CMS app uses Payload natively, so production mode for it will always wire `PayloadJobsEventBus` + `PayloadJobQueue`.

- [ ] **Step 3: Verify each app**

Run: `pnpm --filter @repo/cms typecheck test lint && pnpm --filter @repo/web-tanstack typecheck test lint` (skip filters for non-existent apps).

- [ ] **Step 4: Commit**

```bash
git add apps/cms/ apps/web-tanstack/
git commit -m "feat(cms,web-tanstack): bindAll resolves IEventBus + IJobQueue per env"
```

---

## Phase 7 — Generators

### Task 36: Anchor-presence validation helper

**Files:**
- Create: `turbo/generators/lib/anchor-validate.ts`

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

```ts
// turbo/generators/lib/anchor-validate.ts
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

/**
 * Throws with a clear message if any anchor is missing from the target file.
 * Used in generator prompt validators to abort before writing partial output.
 */
export function assertAnchors(
  repoRoot: string,
  relativePath: string,
  anchors: string[],
): void {
  const path = join(repoRoot, relativePath);
  if (!existsSync(path)) {
    throw new Error(`Required file does not exist: ${relativePath}`);
  }
  const content = readFileSync(path, "utf8");
  for (const anchor of anchors) {
    if (!content.includes(anchor)) {
      throw new Error(
        `Missing anchor "${anchor}" in ${relativePath}. Add it before running this generator.`,
      );
    }
  }
}
```

- [ ] **Step 2: No test in TDD style — exercised via the generator's own smoke runs**

(Plop generators are notoriously hard to unit-test without a sandbox; we rely on the smoke tests in Tasks 38, 40, 42.)

- [ ] **Step 3: Verify it compiles in the generators dir**

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

- [ ] **Step 4: Commit**

```bash
git add turbo/generators/lib/anchor-validate.ts
git commit -m "feat(turbo-gen): assertAnchors helper"
```

### Task 37: Generator templates for `gen event publish`

**Files:**
- Create: `turbo/generators/templates/event/publish/event.ts.hbs`
- Create: `turbo/generators/templates/event/publish/event.test.ts.hbs`

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

```hbs
// packages/{{kebabCase feature}}/src/events/{{kebabCase event}}.event.ts
import { z } from "zod";
import { defineEvent } from "@repo/core-events";

export const {{camelCase event}}EventSchema = z.object({}).strict();

export type {{pascalCase event}}Event = z.infer<typeof {{camelCase event}}EventSchema>;

export const {{camelCase event}}Event = defineEvent(
  "{{kebabCase feature}}.{{dotCase event}}",
  {{camelCase event}}EventSchema,
);
```

> **Plop helper note:** Plop ships kebab/camel/pascal helpers but not "dotCase". In Plop, register a custom helper or use `replace`. Easiest: have the prompt accept the dotted form directly (e.g. `user.signed-up`) and use `{{event}}` raw in the template for the wire name; use `{{kebabCase event}}` (which converts dots to hyphens) for the file name. Update template to use raw `{{event}}` for the descriptor name.

Revised template:

```hbs
// packages/{{kebabCase feature}}/src/events/{{kebabCase event}}.event.ts
import { z } from "zod";
import { defineEvent } from "@repo/core-events";

export const {{camelCase event}}EventSchema = z.object({}).strict();

export type {{pascalCase event}}Event = z.infer<typeof {{camelCase event}}EventSchema>;

export const {{camelCase event}}Event = defineEvent(
  "{{kebabCase feature}}.{{event}}",
  {{camelCase event}}EventSchema,
);
```

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

```hbs
// packages/{{kebabCase feature}}/src/events/{{kebabCase event}}.event.test.ts
import { describe, it, expect } from "vitest";
import { {{camelCase event}}EventSchema, {{camelCase event}}Event } from "@/events/{{kebabCase event}}.event";

describe("{{camelCase event}}Event", () => {
  it("has the correct wire name", () => {
    expect({{camelCase event}}Event.name).toBe("{{kebabCase feature}}.{{event}}");
  });

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

- [ ] **Step 3: Commit**

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

### Task 38: Wire `gen event publish` into `turbo/generators/config.ts`

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

- [ ] **Step 1: Read `config.ts` to understand the existing generator structure**

The `feature` generator is the reference. We add a new `event` generator with a `mode` prompt that branches.

- [ ] **Step 2: Add the `event` generator (publish branch)**

Inside the `generator(plop)` function, after the existing `feature` generator's `setGenerator` call:

```ts
plop.setGenerator("event", {
  description: "Scaffold an event contract (publish) or handler (consume)",
  prompts: [
    {
      type: "list",
      name: "mode",
      message: "Mode:",
      choices: ["publish", "consume"],
    },
    {
      type: "input",
      name: "feature",
      message:
        "Owning feature (kebab-case; for publish: contract package; for consume: consumer package):",
      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: "event",
      message:
        "Event slug (dotted-kebab past tense, e.g. 'user.signed-up'):",
      validate(input: string) {
        if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(input)) {
          return "Must be dotted-kebab past tense (e.g. user.signed-up)";
        }
        return true;
      },
    },
    {
      type: "input",
      name: "publisher",
      message:
        "Publisher feature (kebab-case; only for consume mode):",
      when(answers: { mode: string }) {
        return answers.mode === "consume";
      },
      validate(input: string, answers: { event: string }) {
        if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
        const eventKebab = answers.event.replace(/\./g, "-");
        const path = join(
          process.cwd(),
          "packages",
          input,
          "src",
          "events",
          `${eventKebab}.event.ts`,
        );
        if (!existsSync(path)) {
          return `Publisher contract not found: ${path}`;
        }
        return true;
      },
    },
  ],
  actions(answers) {
    const a = answers as {
      mode: "publish" | "consume";
      feature: string;
      event: string;
      publisher?: string;
    };
    if (a.mode === "publish") return publishActions(a);
    return consumeActions(a);
  },
});
```

Add helpers `publishActions` and `consumeActions` and `existsSync`/`join` imports at the top of `config.ts`.

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

```ts
function publishActions(a: { feature: string; event: string }): PlopTypes.ActionType[] {
  const eventKebab = a.event.replace(/\./g, "-");
  const indexPath = `packages/${a.feature}/src/index.ts`;
  return [
    () => {
      assertAnchors(process.cwd(), indexPath, ["// <gen:events>"]);
      return `Anchors verified in ${indexPath}`;
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/events/${eventKebab}.event.ts`,
      templateFile: "templates/event/publish/event.ts.hbs",
      data: { event: a.event, feature: a.feature, eventKebab },
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/events/${eventKebab}.event.test.ts`,
      templateFile: "templates/event/publish/event.test.ts.hbs",
      data: { event: a.event, feature: a.feature, eventKebab },
    },
    {
      type: "modify",
      path: indexPath,
      pattern: /\/\/ <gen:events>/,
      template: `// <gen:events>\nexport {\n  {{camelCase event}}Event,\n  {{camelCase event}}EventSchema,\n  type {{pascalCase event}}Event,\n} from "./events/{{kebabCase event}}.event";`,
      data: { event: a.event },
    },
    () => printPublishNextSteps(a),
  ];
}

function printPublishNextSteps(a: { feature: string; event: string }): string {
  return [
    "",
    "─────────────────────────────────────────────────────────────",
    `Event ${a.feature}.${a.event} contract scaffolded.`,
    "",
    "Next steps (manual):",
    `  1. Fill in the schema in packages/${a.feature}/src/events/${a.event.replace(/\./g, "-")}.event.ts`,
    `  2. Pick a use case in packages/${a.feature}/src/application/use-cases/ that should publish.`,
    `     - Add 'bus: IEventBus' to the factory signature.`,
    `     - Call 'await bus.publish(${camelCase(a.event)}Event, payload)' after success.`,
    `     - Update the use case's DI binding to inject the bus.`,
    `  3. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
    "",
  ].join("\n");
}
```

(Define a small local `camelCase` helper at the bottom of `config.ts` if not already present — see how `feature` generator's `cap`/`camel` helpers are defined.)

- [ ] **Step 4: Smoke-test the publish branch**

Run from `/tmp` with a copy of the repo:

```bash
cp -r ~/Documents/Projects/template-vertical /tmp/gen-test && cd /tmp/gen-test && pnpm install && pnpm turbo gen event --args publish auth user.signed-up && pnpm --filter @repo/auth typecheck test lint
```

Expected: clean generation; `packages/auth/src/events/user-signed-up.event.ts` and its test exist; `auth/src/index.ts` re-exports the contract; package passes typecheck/lint/test.

- [ ] **Step 5: Clean up sandbox; commit**

```bash
rm -rf /tmp/gen-test
git add turbo/generators/config.ts
git commit -m "feat(turbo-gen): event generator (publish mode)"
```

### Task 39: Generator templates for `gen event consume`

**Files:**
- Create: `turbo/generators/templates/event/consume/handler.ts.hbs`
- Create: `turbo/generators/templates/event/consume/handler.test.ts.hbs`
- Create: `turbo/generators/templates/event/consume/event-task.ts.hbs`

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

```hbs
// packages/{{kebabCase feature}}/src/events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler.ts
import type { {{pascalCase event}}Event } from "@repo/{{kebabCase publisher}}";

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

export const on{{pascalCase publisher}}{{pascalCase event}}Handler =
  () =>
  async (_event: {{pascalCase event}}Event): Promise<void> => {
    // TODO: implement the reaction. Inject dependencies via the factory's
    // constructor and use them here. The handler runs inside the consumer's
    // span+capture sandwich, so just throwing on failure is the right shape.
  };
```

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

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

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

  it("does not throw on a valid stub event", async () => {
    const handler = on{{pascalCase publisher}}{{pascalCase event}}Handler();
    await expect(handler({} as never)).resolves.toBeUndefined();
  });
});
```

- [ ] **Step 3: Write `event-task.ts.hbs`** (Payload task that completes the production bus loop)

```hbs
// packages/{{kebabCase feature}}/src/integrations/cms/jobs/__events-{{kebabCase publisher}}-{{kebabCase event}}.task.ts
// Generated by `gen event consume`. The PayloadJobsEventBus enqueues this task
// when {{kebabCase publisher}} publishes `{{kebabCase publisher}}.{{event}}`. The handler
// resolves the consumer's wrapped event handler from the per-feature container
// and invokes it with the typed payload.
import type { TaskConfig } from "payload";
import { {{camelCase feature}}Container } from "../../../di/container";
import { {{constantCase feature}}_SYMBOLS } from "../../../di/symbols";
import type { IOn{{pascalCase publisher}}{{pascalCase event}}Handler } from "../../../events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler";
import type { {{pascalCase event}}Event } from "@repo/{{kebabCase publisher}}";

export const on{{pascalCase publisher}}{{pascalCase event}}EventTask: TaskConfig<"__events.{{kebabCase publisher}}.{{event}}.{{kebabCase feature}}"> = {
  slug: "__events.{{kebabCase publisher}}.{{event}}.{{kebabCase feature}}",
  inputSchema: [],
  retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } },
  handler: async ({ input }) => {
    const handler = {{camelCase feature}}Container.get<IOn{{pascalCase publisher}}{{pascalCase event}}Handler>(
      {{constantCase feature}}_SYMBOLS.IOn{{pascalCase publisher}}{{pascalCase event}}Handler,
    );
    await handler(input as {{pascalCase event}}Event);
    return { output: {} };
  },
};
```

- [ ] **Step 4: Commit**

```bash
git add turbo/generators/templates/event/consume/
git commit -m "feat(turbo-gen): templates for gen event consume (handler + Payload task)"
```

### Task 40: Wire `gen event consume` actions and modify-blocks

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

- [ ] **Step 1: Implement `consumeActions` in `config.ts`**

```ts
function consumeActions(a: {
  feature: string;
  event: string;
  publisher: string;
}): PlopTypes.ActionType[] {
  const eventKebab = a.event.replace(/\./g, "-");
  const handlerName = `on${pascalCase(a.publisher)}${pascalCase(a.event)}`;
  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`;
  const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
  return [
    () => {
      assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
      assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
      assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
      assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
      return "All required anchors present";
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`,
      templateFile: "templates/event/consume/handler.ts.hbs",
      data: a,
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.test.ts`,
      templateFile: "templates/event/consume/handler.test.ts.hbs",
      data: a,
    },
    // Production-bus task config — completes the PayloadJobsEventBus loop.
    // Slug must match what PayloadJobsEventBus.publish enqueues:
    //   __events.<publisher>.<event>.<consumer>
    {
      type: "add",
      path: `packages/${a.feature}/src/integrations/cms/jobs/__events-${a.publisher}-${eventKebab}.task.ts`,
      templateFile: "templates/event/consume/event-task.ts.hbs",
      data: a,
    },
    {
      type: "modify",
      path: symbolFile,
      pattern: /\/\/ <gen:event-handler-symbols>/,
      template: `// <gen:event-handler-symbols>\n  I${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`,
    },
    {
      type: "modify",
      path: bindProdFile,
      pattern: /\/\/ <gen:event-handlers>/,
      template: handlerBindBlock(a, "production"),
    },
    {
      type: "modify",
      path: bindDevFile,
      pattern: /\/\/ <gen:event-handlers>/,
      template: handlerBindBlock(a, "dev-seed"),
    },
    // Re-export the event-task config from integrations/cms/index.ts so
    // core-cms picks it up when aggregating Payload tasks.
    {
      type: "modify",
      path: cmsIndexFile,
      pattern: /\/\/ <gen:job-tasks>/,
      template: `// <gen:job-tasks>\nexport { on${pascalCase(a.publisher)}${pascalCase(a.event)}EventTask } from "./jobs/__events-${a.publisher}-${eventKebab}.task";`,
    },
    () => printConsumeNextSteps(a),
  ];
}

function handlerBindBlock(
  a: { feature: string; event: string; publisher: string },
  _mode: "production" | "dev-seed",
): string {
  const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
  const eventConst = `${camelCase(a.event)}Event`;
  const handlerSymbol = `${constantCase(a.feature)}_SYMBOLS.IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
  const wrappedVar = `wrapped${pascalCase(a.publisher)}${pascalCase(a.event)}`;
  const containerVar = `${camelCase(a.feature)}Container`;
  return `// <gen:event-handlers>
  // ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior.
  const ${wrappedVar} = withSpan(
    tracer,
    { name: "${a.feature}.${handlerFn}", op: "event-handler" },
    withCapture(
      logger,
      {
        feature: "${a.feature}",
        layer: "event-handler",
        name: "${a.feature}.${handlerFn}",
      },
      ${handlerFn}(),
    ),
  );
  // Bind into the per-feature container so the production __events.* task
  // can resolve and invoke the wrapped handler.
  if (${containerVar}.isBound(${handlerSymbol})) {
    ${containerVar}.unbind(${handlerSymbol});
  }
  ${containerVar}.bind(${handlerSymbol}).toConstantValue(${wrappedVar});
  // Subscribe so the in-memory bus delivers in dev/test, and so the production
  // bus knows which consumers to fan out __events.* tasks for.
  bus.subscribe(${eventConst}, "${a.feature}", ${wrappedVar});`;
}

function printConsumeNextSteps(a: { feature: string; event: string; publisher: string }): string {
  return [
    "",
    "─────────────────────────────────────────────────────────────",
    `Handler on-${a.publisher}-${a.event.replace(/\./g, "-")} scaffolded in ${a.feature}.`,
    "",
    "Next steps (manual):",
    `  1. Implement the handler body in packages/${a.feature}/src/events/handlers/.`,
    `  2. Add the import for ${camelCase(a.event)}Event in bind-production.ts and bind-dev-seed.ts (from @repo/${a.publisher}).`,
    `  3. If your handler needs dependencies, add them to the factory signature and pass them in the generated bind block.`,
    `  4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
    "",
  ].join("\n");
}
```

> **Note:** The generated bind block does NOT include the import for `<event>Event` from the publisher — that's a manual edit (printed in the next-steps). This is intentional: imports go at the top of the file, not at the anchor location, and Plop's `modify` is awkward for that. The user adds one import line.

- [ ] **Step 2: Smoke-test consume mode**

Use a sandbox copy. First run a publish from auth, then a consume in marketing-pages:

```bash
cp -r ~/Documents/Projects/template-vertical /tmp/gen-test2 && cd /tmp/gen-test2 && pnpm install
pnpm turbo gen event --args publish auth user.signed-up
pnpm turbo gen event --args consume marketing-pages user.signed-up auth
# manually edit marketing-pages/src/di/bind-production.ts to add the import for userSignedUpEvent
pnpm --filter @repo/marketing-pages typecheck test lint
```

Expected: typecheck/lint/test pass after the manual import edit.

- [ ] **Step 3: Clean up sandbox; commit**

```bash
rm -rf /tmp/gen-test2
git add turbo/generators/config.ts
git commit -m "feat(turbo-gen): event generator (consume mode)"
```

### Task 41: Generator templates for `gen job`

**Files:**
- Create: `turbo/generators/templates/job/job.ts.hbs`
- Create: `turbo/generators/templates/job/job.test.ts.hbs`
- Create: `turbo/generators/templates/job/task.ts.hbs`

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

```hbs
// packages/{{kebabCase feature}}/src/jobs/{{kebabCase job}}.job.ts
import { z } from "zod";

{{#if (eq inputShape "typed")}}
export const {{camelCase job}}InputSchema = z
  .object({ exampleField: z.string() })
  .strict();
{{else}}
export const {{camelCase job}}InputSchema = z.object({}).strict();
{{/if}}

export type {{pascalCase job}}Input = z.infer<typeof {{camelCase job}}InputSchema>;
export type I{{pascalCase job}}Job = ReturnType<typeof {{camelCase job}}Job>;

export const {{camelCase job}}Job =
  () =>
  async (input: {{pascalCase job}}Input): Promise<void> => {
    {{camelCase job}}InputSchema.parse(input);
    // TODO: implement the job. Inject dependencies via the factory's constructor.
  };
```

> **Plop helper note:** Plop ships `eq` via the inquirer integration; if it isn't registered, replace the conditional with two separate template files (e.g., `job.ts.void.hbs` and `job.ts.typed.hbs`) selected by `actions`. Test which works during smoke-testing.

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

```hbs
// packages/{{kebabCase feature}}/src/jobs/{{kebabCase job}}.job.test.ts
import { describe, it, expect } from "vitest";
import { {{camelCase job}}Job, {{camelCase job}}InputSchema } from "@/jobs/{{kebabCase job}}.job";

describe("{{camelCase job}}Job", () => {
  it("validates input via the schema", async () => {
    const job = {{camelCase job}}Job();
    await expect(job({ unexpectedField: 1 } as never)).rejects.toThrow();
  });

  it("accepts valid input", async () => {
    const job = {{camelCase job}}Job();
    {{#if (eq inputShape "typed")}}
    await expect(job({ exampleField: "x" })).resolves.toBeUndefined();
    {{else}}
    await expect(job({})).resolves.toBeUndefined();
    {{/if}}
  });
});
```

- [ ] **Step 3: Write `task.ts.hbs`**

```hbs
// packages/{{kebabCase feature}}/src/integrations/cms/jobs/{{kebabCase job}}.task.ts
import type { TaskConfig } from "payload";
import { {{camelCase feature}}Container } from "../../../di/container";
import { {{constantCase feature}}_SYMBOLS } from "../../../di/symbols";
import type { I{{pascalCase job}}Job } from "../../../jobs/{{kebabCase job}}.job";

export const {{camelCase job}}Task: TaskConfig<"{{kebabCase feature}}.{{kebabCase job}}"> = {
  slug: "{{kebabCase feature}}.{{kebabCase job}}",
  inputSchema: [],
  retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } },
  handler: async ({ input }) => {
    const job = {{camelCase feature}}Container.get<I{{pascalCase job}}Job>(
      {{constantCase feature}}_SYMBOLS.I{{pascalCase job}}Job,
    );
    await job(input as never);
    return { output: {} };
  },
};
```

> The `handler` reads from the per-feature container directly (Inversify singleton). If a feature's container needs the Payload `req` for some other reason, that's a per-feature edit; the default scaffolded shape is "container resolves the wrapped job."

- [ ] **Step 4: Commit**

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

### Task 42: Wire `gen job` actions and modify-blocks

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

- [ ] **Step 1: Add the `job` generator**

```ts
plop.setGenerator("job", {
  description: "Scaffold a background job in an existing feature",
  prompts: [
    {
      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: "job",
      message: "Job slug (verb-noun kebab, e.g. 'send-welcome-email'):",
      validate(input: string) {
        if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
        return true;
      },
    },
    {
      type: "list",
      name: "inputShape",
      message: "Input shape:",
      choices: ["void", "typed"],
      default: "void",
    },
  ],
  actions(answers) {
    const a = answers as { feature: string; job: string; inputShape: "void" | "typed" };
    return jobActions(a);
  },
});

function jobActions(a: {
  feature: string;
  job: string;
  inputShape: "void" | "typed";
}): 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`;
  const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
  return [
    () => {
      assertAnchors(process.cwd(), symbolFile, ["// <gen:job-symbols>"]);
      assertAnchors(process.cwd(), bindProdFile, ["// <gen:jobs>"]);
      assertAnchors(process.cwd(), bindDevFile, ["// <gen:jobs>"]);
      assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
      return "All required anchors present";
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/jobs/${a.job}.job.ts`,
      templateFile: "templates/job/job.ts.hbs",
      data: a,
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/jobs/${a.job}.job.test.ts`,
      templateFile: "templates/job/job.test.ts.hbs",
      data: a,
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/integrations/cms/jobs/${a.job}.task.ts`,
      templateFile: "templates/job/task.ts.hbs",
      data: a,
    },
    {
      type: "modify",
      path: cmsIndexFile,
      pattern: /\/\/ <gen:job-tasks>/,
      template: `// <gen:job-tasks>\nexport { ${camelCase(a.job)}Task } from "./jobs/${a.job}.task";`,
    },
    {
      type: "modify",
      path: symbolFile,
      pattern: /\/\/ <gen:job-symbols>/,
      template: `// <gen:job-symbols>\n  I${pascalCase(a.job)}Job: Symbol.for("@repo/${a.feature}/${camelCase(a.job)}Job"),`,
    },
    {
      type: "modify",
      path: bindProdFile,
      pattern: /\/\/ <gen:jobs>/,
      template: jobBindBlock(a),
    },
    {
      type: "modify",
      path: bindDevFile,
      pattern: /\/\/ <gen:jobs>/,
      template: jobBindBlock(a),
    },
    () => printJobNextSteps(a),
  ];
}

function jobBindBlock(a: { feature: string; job: string }): string {
  const factoryFn = `${camelCase(a.job)}Job`;
  const symbol = `${constantCase(a.feature)}_SYMBOLS.I${pascalCase(a.job)}Job`;
  return `// <gen:jobs>
  const wrapped${pascalCase(a.job)} = withSpan(
    tracer,
    { name: "${a.feature}.${camelCase(a.job)}", op: "job" },
    withCapture(
      logger,
      {
        feature: "${a.feature}",
        layer: "job",
        name: "${a.feature}.${camelCase(a.job)}",
      },
      ${factoryFn}(),
    ),
  );
  if (${camelCase(a.feature)}Container.isBound(${symbol})) {
    ${camelCase(a.feature)}Container.unbind(${symbol});
  }
  ${camelCase(a.feature)}Container.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`;
}

function printJobNextSteps(a: { feature: string; job: string }): string {
  return [
    "",
    "─────────────────────────────────────────────────────────────",
    `Job ${a.feature}.${a.job} scaffolded.`,
    "",
    "Next steps (manual):",
    `  1. Fill in the job body in packages/${a.feature}/src/jobs/${a.job}.job.ts`,
    `  2. Add Payload field config to inputSchema in ${a.job}.task.ts (matches your Zod schema).`,
    `  3. Add the import for ${camelCase(a.job)}Job in bind-production.ts and bind-dev-seed.ts.`,
    `  4. (Optional) Add a cron schedule in core-cms's buildConfig if this job runs periodically.`,
    `  5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
    "",
  ].join("\n");
}
```

- [ ] **Step 2: Smoke-test the job generator**

```bash
cp -r ~/Documents/Projects/template-vertical /tmp/gen-test3 && cd /tmp/gen-test3 && pnpm install
pnpm turbo gen job --args marketing-pages send-welcome-email void
# manually edit marketing-pages/src/di/bind-production.ts to add the import for sendWelcomeEmailJob
pnpm --filter @repo/marketing-pages typecheck test lint
```

Expected: clean generation; typecheck/lint/test pass after the manual import edit.

- [ ] **Step 3: Clean up; commit**

```bash
rm -rf /tmp/gen-test3
git add turbo/generators/config.ts
git commit -m "feat(turbo-gen): job generator"
```

---

## Phase 8 — Proof-of-life: cross-feature flow (sign-up → welcome email)

This validates that the full stack works end-to-end. Spec § 13.

### Task 43: Add `IMailerService` interface to `marketing-pages`

**Files:**
- Create: `packages/marketing-pages/src/application/services/mailer.service.interface.ts`

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

```ts
// packages/marketing-pages/src/application/services/mailer.service.interface.ts
export interface IMailerService {
  sendWelcome(userId: string, email: string): Promise<void>;
}
```

- [ ] **Step 2: Add a symbol for it**

In `packages/marketing-pages/src/di/symbols.ts`, add `IMailerService: Symbol.for("@repo/marketing-pages/IMailerService")` to the symbol object (NOT under any anchor — this is a regular service symbol).

- [ ] **Step 3: Verify**

Run: `pnpm --filter @repo/marketing-pages typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/application/services/mailer.service.interface.ts packages/marketing-pages/src/di/symbols.ts
git commit -m "feat(marketing-pages): IMailerService interface (proof-of-life)"
```

### Task 44: Add `RecordingMailerService`

**Files:**
- Create: `packages/marketing-pages/src/infrastructure/services/recording-mailer.service.ts`
- Create: `packages/marketing-pages/src/infrastructure/services/recording-mailer.service.test.ts`

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

```ts
// packages/marketing-pages/src/infrastructure/services/recording-mailer.service.test.ts
import { describe, it, expect } from "vitest";
import { RecordingMailerService } from "@/infrastructure/services/recording-mailer.service";

describe("RecordingMailerService", () => {
  it("records welcome calls", async () => {
    const mailer = new RecordingMailerService();
    await mailer.sendWelcome("u1", "u1@example.com");
    expect(mailer.sent).toEqual([{ userId: "u1", email: "u1@example.com" }]);
  });
});
```

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

Run: `pnpm --filter @repo/marketing-pages test -- recording-mailer`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/marketing-pages/src/infrastructure/services/recording-mailer.service.ts
import type { IMailerService } from "../../application/services/mailer.service.interface";

export class RecordingMailerService implements IMailerService {
  readonly sent: { userId: string; email: string }[] = [];

  async sendWelcome(userId: string, email: string): Promise<void> {
    this.sent.push({ userId, email });
  }
}
```

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

Run: `pnpm --filter @repo/marketing-pages test -- recording-mailer`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/marketing-pages/src/infrastructure/services/recording-mailer.service.ts packages/marketing-pages/src/infrastructure/services/recording-mailer.service.test.ts
git commit -m "feat(marketing-pages): RecordingMailerService"
```

### Task 45: Use `gen event publish` to scaffold `auth.user.signed-up`

- [ ] **Step 1: Run the generator**

```bash
pnpm turbo gen event --args publish auth user.signed-up
```

Expected: clean generation; `packages/auth/src/events/user-signed-up.event.ts` exists; auth's `index.ts` re-exports it.

- [ ] **Step 2: Fill in the schema**

Edit `packages/auth/src/events/user-signed-up.event.ts`:

```ts
export const userSignedUpEventSchema = z
  .object({
    userId: z.string(),
    email: z.string().email(),
    signedUpAt: z.string().datetime(),
  })
  .strict();
```

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

Edit `packages/auth/src/events/user-signed-up.event.test.ts` to assert the actual schema rejects invalid input and accepts valid input.

- [ ] **Step 4: Verify**

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

- [ ] **Step 5: Commit**

```bash
git add packages/auth/src/
git commit -m "feat(auth): userSignedUpEvent contract"
```

### Task 46: Update `signUpUseCase` to publish

**Files:**
- Modify: `packages/auth/src/application/use-cases/sign-up.use-case.ts`
- Modify: `packages/auth/src/application/use-cases/sign-up.use-case.test.ts`

- [ ] **Step 1: Add a failing test asserting `bus.publish` is called**

Read the existing test, then add:

```ts
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
import { userSignedUpEvent } from "../../events/user-signed-up.event";

it("publishes auth.user.signed-up after creating the user", async () => {
  const bus = new RecordingEventBus();
  // ... existing mocks ...
  const useCase = signUpUseCase(mockUsers, mockAuth, bus);
  await useCase({ email: "a@b.com", password: "secret" });
  expect(bus.published).toContainEqual(
    expect.objectContaining({ name: "auth.user.signed-up" }),
  );
});
```

- [ ] **Step 2: Run; expect FAIL** — `signUpUseCase` doesn't take `bus` yet.

- [ ] **Step 3: Update `signUpUseCase`**

```ts
import type { IEventBus } from "@repo/core-events";
import { userSignedUpEvent } from "../../events/user-signed-up.event";

export const signUpUseCase =
  (users: IUsersRepository, auth: IAuthenticationService, bus: IEventBus) =>
  async (input: SignUpInput): Promise<SignUpOutput> => {
    // ... existing logic to create user + return token ...
    const user = /* existing creation */;
    await bus.publish(userSignedUpEvent, {
      userId: user.id,
      email: user.email,
      signedUpAt: new Date().toISOString(),
    });
    return signUpOutputSchema.parse(/* existing return */);
  };
```

- [ ] **Step 4: Update the binding in `bind-production.ts`**

In `packages/auth/src/di/bind-production.ts`, the `signUpUseCase` factory call now takes `bus`:

```ts
const wrappedSignUp = withSpan(
  tracer,
  { name: "auth.signUp", op: "use-case" },
  withCapture(
    logger,
    { feature: "auth", layer: "use-case", name: "auth.signUp" },
    signUpUseCase(repo, authService, bus),
  ),
);
```

Same for `bind-dev-seed.ts`.

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

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

- [ ] **Step 6: Commit**

```bash
git add packages/auth/src/
git commit -m "feat(auth): signUp publishes userSignedUpEvent"
```

### Task 47: Use `gen event consume` to scaffold the marketing-pages handler

- [ ] **Step 1: Run the generator**

```bash
pnpm turbo gen event --args consume marketing-pages user.signed-up auth
```

Expected: clean generation. Files produced:
- `packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts` (+ .test.ts)
- `packages/marketing-pages/src/integrations/cms/jobs/__events-auth-user-signed-up.task.ts`

Modifications:
- `symbols.ts` adds `IOnAuthUserSignedUpHandler`.
- `bind-production.ts` / `bind-dev-seed.ts` get the wrapped-handler block + `bus.subscribe` call + container binding.
- `integrations/cms/index.ts` re-exports the `__events-...task.ts` so `core-cms` aggregates it.

- [ ] **Step 2: Add the missing imports to bind files**

Both `packages/marketing-pages/src/di/bind-production.ts` and `bind-dev-seed.ts` need:

```ts
import { userSignedUpEvent } from "@repo/auth";
import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler";
```

- [ ] **Step 3: Verify the generated task exists and `core-cms` aggregates it**

Run: `pnpm --filter @repo/marketing-pages typecheck lint`
Run: `pnpm --filter @repo/core-cms typecheck`

Expected: PASS for both. Open `packages/core-cms/src/index.ts` and confirm the import line for `onAuthUserSignedUpEventTask` is implicit (via `@repo/marketing-pages/cms`'s barrel re-export). If `core-cms` does NOT explicitly enumerate task names but instead spreads the imported `tasks` array, no edit is needed. If `core-cms` enumerates each task individually, add `onAuthUserSignedUpEventTask` to the `jobs.tasks` array — read `packages/core-cms/src/index.ts` to determine which.

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/ packages/core-cms/src/
git commit -m "feat(marketing-pages): subscribe to auth.user.signed-up (handler + production task)"
```

### Task 48: Use `gen job` to scaffold `send-welcome-email`

- [ ] **Step 1: Run the generator**

```bash
pnpm turbo gen job --args marketing-pages send-welcome-email typed
```

- [ ] **Step 2: Fill in the schema and body**

Edit `packages/marketing-pages/src/jobs/send-welcome-email.job.ts`:

```ts
import { z } from "zod";
import type { IMailerService } from "../application/services/mailer.service.interface";

export const sendWelcomeEmailInputSchema = z
  .object({ userId: z.string(), email: z.string().email() })
  .strict();

export type SendWelcomeEmailInput = z.infer<typeof sendWelcomeEmailInputSchema>;
export type ISendWelcomeEmailJob = ReturnType<typeof sendWelcomeEmailJob>;

export const sendWelcomeEmailJob =
  (mailer: IMailerService) =>
  async (input: SendWelcomeEmailInput): Promise<void> => {
    sendWelcomeEmailInputSchema.parse(input);
    await mailer.sendWelcome(input.userId, input.email);
  };
```

- [ ] **Step 3: Update the generated job test**

```ts
import { RecordingMailerService } from "@/infrastructure/services/recording-mailer.service";

it("sends a welcome email via the mailer", async () => {
  const mailer = new RecordingMailerService();
  const job = sendWelcomeEmailJob(mailer);
  await job({ userId: "u1", email: "u1@example.com" });
  expect(mailer.sent).toEqual([{ userId: "u1", email: "u1@example.com" }]);
});
```

- [ ] **Step 4: Update the generated bind block to inject the mailer**

The generator emits `sendWelcomeEmailJob()` (no args). Edit the bind blocks in both `bind-production.ts` and `bind-dev-seed.ts` to wire in a mailer:

For `bind-dev-seed.ts`:

```ts
const mailer = new RecordingMailerService();
marketingPagesContainer
  .bind<IMailerService>(MARKETING_PAGES_SYMBOLS.IMailerService)
  .toConstantValue(mailer);
// Then in the gen:jobs block:
sendWelcomeEmailJob(mailer),
```

For `bind-production.ts`: bind a real mailer (placeholder: bind another `RecordingMailerService` until a real impl ships, or use a stub).

- [ ] **Step 5: Update the handler to inject the queue and enqueue the job**

In `packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts`:

```ts
import type { UserSignedUpEvent } from "@repo/auth";
import type { IJobQueue } from "@repo/core-shared/jobs";

export type IOnAuthUserSignedUpHandler = ReturnType<typeof onAuthUserSignedUpHandler>;

export const onAuthUserSignedUpHandler =
  (queue: IJobQueue) =>
  async (event: UserSignedUpEvent): Promise<void> => {
    await queue.enqueue("marketing-pages.send-welcome-email", {
      userId: event.userId,
      email: event.email,
    });
  };
```

Update the bind block to pass `queue`:

```ts
${handlerFn}(queue),
// becomes:
onAuthUserSignedUpHandler(queue),
```

(The generator emitted `onAuthUserSignedUpHandler()` with no args; you edit the bind block to pass `queue`.)

- [ ] **Step 6: For dev-seed, register the job with the in-memory queue**

In `bind-dev-seed.ts`, after binding the wrapped job:

```ts
if (queue && typeof (queue as { register?: unknown }).register === "function") {
  (queue as InMemoryJobQueue).register(
    "marketing-pages.send-welcome-email",
    async (input) => {
      const wrapped = marketingPagesContainer.get<ISendWelcomeEmailJob>(
        MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob,
      );
      await wrapped(input as SendWelcomeEmailInput);
    },
  );
}
```

This registration is what makes the in-memory queue actually fire when the handler enqueues.

- [ ] **Step 7: Verify all marketing-pages tests pass**

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

- [ ] **Step 8: Commit**

```bash
git add packages/marketing-pages/src/
git commit -m "feat(marketing-pages): sendWelcomeEmail job + handler enqueues it"
```

### Task 49: End-to-end test: sign-up triggers welcome email

**Files:**
- Create: `apps/web-next/tests/sign-up-welcome-email.test.ts` (or extend an existing e2e test file)

- [ ] **Step 1: Determine where app-level tests live**

Run: `find apps/web-next -name "*.test.ts" | head`. If there's no existing top-level test directory, place this one near the bind-production test.

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

```ts
// apps/web-next/tests/sign-up-welcome-email.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { bindAllDevSeed, __resetBindStateForTests } from "@/server/bind-production";
import { authContainer } from "@repo/auth/di/container"; // adjust import path
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
import { marketingPagesContainer } from "@repo/marketing-pages/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@repo/marketing-pages/di/symbols";
import type { ISignUpController } from "@repo/auth";
import type { IMailerService } from "@repo/marketing-pages";
import { RecordingMailerService } from "@repo/marketing-pages/src/infrastructure/services/recording-mailer.service";

describe("e2e: sign-up triggers welcome email via cross-feature event", () => {
  beforeEach(() => {
    __resetBindStateForTests();
  });

  it("delivers a welcome email after a successful sign-up", async () => {
    await bindAllDevSeed();

    const mailer = marketingPagesContainer.get<IMailerService>(
      MARKETING_PAGES_SYMBOLS.IMailerService,
    ) as RecordingMailerService;

    const controller = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
    await controller({ email: "test@example.com", password: "secret123" });

    // The job runs on setImmediate; let microtasks settle
    await new Promise((r) => setImmediate(r));

    expect(mailer.sent).toEqual([{ userId: expect.any(String), email: "test@example.com" }]);
  });
});
```

> The exact import paths depend on the actual public-API exposure of the auth and marketing-pages packages. If `authContainer` is not exported, replace with a `getAuthContainer(config)` form.

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

Run: `pnpm --filter @repo/web-next test -- sign-up-welcome-email`
Expected: PASS.

> If timing of `setImmediate` is flaky, replace with a synchronous in-memory bus that awaits handlers (default `InMemoryEventBus`) and ensure `bindAllDevSeed` uses it (it does — InMemoryEventBus is the dev-seed default).

- [ ] **Step 4: Commit**

```bash
git add apps/web-next/tests/sign-up-welcome-email.test.ts
git commit -m "test(web-next): e2e cross-feature sign-up→welcome-email flow"
```

---

## Phase 9 — Documentation

### Task 50: Write `docs/decisions/adr-015-events-and-jobs.md`

**Files:**
- Create: `docs/decisions/adr-015-events-and-jobs.md`

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

Read `docs/decisions/adr-014-instrumentation-sentry.md` for the structure (Status / Context / Decision / Consequences).

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

Distill the spec's § 2.2 (rules E0/E1/J0), § 3 (new packages), § 6.3 (bindAll rule), § 7 (boundary/lint), and § 11 (out of scope) into the ADR format. Each rule gets one paragraph. Cross-link the spec at `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`.

- [ ] **Step 3: Commit**

```bash
git add docs/decisions/adr-015-events-and-jobs.md
git commit -m "docs(adr-015): cross-feature events and background jobs"
```

### Task 51: Write `docs/guides/events-and-jobs.md`

**Files:**
- Create: `docs/guides/events-and-jobs.md`

- [ ] **Step 1: Structure the guide**

Three sections:
1. "Publish an event" — runs `gen event publish`, fills the schema, modifies a use case, updates DI binding. Show the full diff for each step.
2. "Consume an event" — runs `gen event consume`, edits the handler body, adds the import to bind files. Full diff.
3. "Add a job" — runs `gen job`, fills the schema and body, edits Payload `inputSchema`, updates the bind block. Full diff.

For each section, reference the spec sections that explain the rationale, and list the verification commands.

- [ ] **Step 2: Commit**

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

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

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

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

Insert after the "Apps call `bindAll()` per feature at boot" section:

```markdown
### Cross-feature events and background jobs (Plan 10, ADR-015)

Three rules:

- **E0:** Events are for cross-feature decoupling. In-feature reactions are direct use-case calls — do not use the bus.
- **E1:** Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-* files (never re-exported, ESLint-enforced).
- **J0:** Jobs are for *deferred* work, not abstraction. Synchronous code stays synchronous.

`@repo/core-events` provides `IEventBus` (`InMemoryEventBus` for dev/test, `PayloadJobsEventBus` for prod). `@repo/core-shared/jobs` provides `IJobQueue` (`InMemoryJobQueue` / `PayloadJobQueue`). Both are swapped by `bindAll()` using the same `USE_DEV_SEED` / `NODE_ENV` rules as repositories.

Per-feature folders (all optional): `events/<x>.event.ts`, `events/handlers/on-<publisher>-<event>.handler.ts`, `jobs/<x>.job.ts`, `integrations/cms/jobs/<x>.task.ts`.

Use the generators: `pnpm turbo gen event {publish|consume}`, `pnpm turbo gen job`. They insert at six fixed `// <gen:*>` anchor comments present in every feature.

See `docs/guides/events-and-jobs.md` and `docs/decisions/adr-015-events-and-jobs.md`.
```

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

```markdown
- **Events and Jobs Guide** — `docs/guides/events-and-jobs.md` — publish, consume, schedule background work
```

- [ ] **Step 3: Commit**

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

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

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

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

Add to the command block:

```bash
pnpm turbo gen event   # Scaffold an event contract or handler
pnpm turbo gen job     # Scaffold a background job
```

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

Add: `- docs/guides/events-and-jobs.md — publish/consume/schedule cookbook`

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

Add three short bullets matching E0, E1, J0 from the AGENTS.md section.

- [ ] **Step 4: Commit**

```bash
git add CLAUDE.md
git commit -m "docs(claude): generators + events/jobs conventions"
```

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

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

- [ ] **Step 1: Add two new sections at the bottom**

```markdown
## Adding events and jobs to a feature

Once a feature exists, augment it with cross-feature events or background jobs using the dedicated generators. See `docs/guides/events-and-jobs.md` for full walkthroughs.

```bash
pnpm turbo gen event publish     # publisher contract
pnpm turbo gen event consume     # consumer handler
pnpm turbo gen job               # background job
```

The generators insert at six fixed `// <gen:*>` anchor comments. Generated features include them automatically; pre-existing features were retrofitted in ADR-015.
```

- [ ] **Step 2: Commit**

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

### Task 55: Update `docs/architecture/vertical-feature-spec.md` deferred lines

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

- [ ] **Step 1: Update the deferred placeholders**

Line 260 (currently "No `effects/`, `jobs/`, `events/` unless the feature grows them") becomes:

```
Optional `events/`, `jobs/`, `integrations/cms/jobs/` directories (see ADR-015 and `docs/guides/events-and-jobs.md`); features may grow them on demand. The spec's canonical layout below remains correct as the minimum.
```

Line 662 ("no `core-events` package yet; spec addendum v4's optional `core-events` stays deferred") becomes:

```
Cross-feature events: shipped via `@repo/core-events` (ADR-015, 2026-05-08). Jobs: shipped via `@repo/core-shared/jobs` + Payload's job queue (same ADR).
```

- [ ] **Step 2: Commit**

```bash
git add docs/architecture/vertical-feature-spec.md
git commit -m "docs(spec): events/jobs no longer deferred (ADR-015)"
```

---

## Final verification

### Task 56: Whole-monorepo green check

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

Run: `pnpm lint`
Expected: PASS.

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

Run: `pnpm typecheck`
Expected: PASS.

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

Run: `pnpm test`
Expected: PASS — including the new anchor-presence guard, the e2e cross-feature flow, and all rule tests.

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

Run: `pnpm turbo boundaries`
Expected: PASS.

- [ ] **Step 5: Dry-run smoke of all three generators against a sandbox**

```bash
cp -r ~/Documents/Projects/template-vertical /tmp/final-test && cd /tmp/final-test && pnpm install
pnpm turbo gen feature --args widgets Widget widgets
pnpm turbo gen event --args publish widgets thing.happened
pnpm turbo gen event --args consume blog thing.happened widgets
pnpm turbo gen job --args widgets process-thing void
# manual edits as printed by each generator
pnpm typecheck test lint
```

Expected: end-to-end green.

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

- [ ] **Step 6: Final commit**

If any step revealed a fix, commit it. Otherwise no-op — the plan is done.

---

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

Tracked here so they aren't forgotten:

1. **Spec interface drift — 3-arg `subscribe`.** During plan self-review, `IEventBus.subscribe` was widened to take `consumerFeature: string` as a second arg (between `descriptor` and `handler`) so `PayloadJobsEventBus` is directly assignable to `IEventBus`. The spec at `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md` § 3.3 still shows the older 2-arg form. **As part of Task 50 (ADR-015), update the spec § 3.3 + § 5.4 to the 3-arg form** so the spec and code agree. Do this in the same commit as the ADR.
2. **Cron schedules are out-of-band.** Job cron schedules live in `core-cms`'s `buildConfig({ jobs: { ... } })`, not in the feature's job file or the generator output. v1 documents this in Task 51's guide; v2 may add a `--cron` prompt to `gen job`.
3. **Production-mode e2e test.** Task 49's e2e test runs in dev-seed mode (`InMemoryEventBus`). A parallel test that runs in production mode (`PayloadJobsEventBus` against a Payload test database) would prove the `__events.*.task.ts` slug-to-handler chain end-to-end. Out of scope for v1 because it requires a Payload test fixture; the production-bus unit tests (Task 15) plus the generator's typecheck-on-output cover the wiring statically.

The first item is mandatory and folded into Task 50. The remaining two are explicitly deferred and should be tracked in ADR-015's "Out of scope" list.

> **Production bus task auto-registration is now in scope** (Tasks 39–40, 47). The `gen event consume` generator emits the `__events-<publisher>-<event>.task.ts` Payload task alongside the handler, and the consumer's `bind-*` files bind the wrapped handler into the per-feature container so the task can resolve it. With this, `PayloadJobsEventBus` is fully wired end-to-end — no manual per-event task hand-write required.

---

## 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 (§ 2 → Phase 0, § 3 → Phases 1–2, § 4 → § 5 maps via Tasks 26–32 + generators, § 6 → Phases 1–2 + Phase 6, § 7 → Phase 4, § 8 → Phase 3, § 9 → Phase 7, § 10 → Phase 9, § 11 not implemented intentionally, § 12 honored throughout, § 13 verified by Task 49 + Task 56).
- All file paths are absolute or repo-relative; no "TBD".
- Tests precede implementation in every behavior-adding task.
- Commit cadence: one commit per task, ~56 commits total, each green at the boundary.
- Anchor protocol is consistent across Phases 5, 6, and 7.
- Span+capture sandwich tags (`op: "event-handler"`, `op: "job"`, `layer: "event-handler"`, `layer: "job"`) appear in the same form in Tasks 40 and 42 generator output and Task 48 manual edits.
- One consistency issue caught during review and fixed inline: `IEventBus.subscribe` widened to 3-arg form so `PayloadJobsEventBus` is directly assignable. See Known Follow-up #1.
- One scope expansion accepted post-review per user request: `gen event consume` now also emits the `__events-<publisher>-<event>.task.ts` Payload task and binds the wrapped handler into the per-feature container, closing the production-bus loop end-to-end (Tasks 39–40, 47). The original "deferred task-registration gap" is now obsolete.
