Files
agentic-dev/docs/superpowers/plans/2026-05-08-events-and-jobs.md
Danijel Martinek c2a32363a8 docs(plan): bring production-bus task auto-registration into scope
gen event consume now emits a second file: an __events-<publisher>-
<event>.task.ts Payload task that delegates to the consumer's
container-resolved wrapped handler. The bind block also binds the
wrapped handler into the per-feature container by symbol so the
task can resolve it. With this, PayloadJobsEventBus is fully wired
end-to-end with no manual per-event task hand-write required.
Removes the corresponding Known Follow-up item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:28:25 +02:00

117 KiB
Raw Blame History

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 25 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, R41R44) — the span+capture sandwich rules events/jobs reuse.
  • docs/decisions/adr-013-input-output-unification.md (R1R5) — 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:

"./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
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

// 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
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

// 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
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

// 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

// 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
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

// 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

// 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
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

// 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
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
{
  "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
{
  "extends": "@repo/core-typescript/base.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
  • Step 4: Create vitest.config.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
# @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
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
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

// 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 FAILdefineEvent not exported.

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

  • Step 3: Implement
// 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
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

// 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
// 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
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

// 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 FAILInMemoryEventBus 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

// 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
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

// 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 FAILPayloadJobsEventBus 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

// 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
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

// 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
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

// 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

// 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:

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
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

// 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
// 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:

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
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
// 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

// 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
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

// 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

// 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
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):

// <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:

  // <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:

  // <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:

// <gen:job-tasks>

If it does NOT exist, create it with just:

// 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
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
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: Verifypnpm --filter @repo/media typecheck test lint
  • Step 3: Commitchore(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: Verifypnpm --filter @repo/marketing-pages typecheck test lint
  • Step 3: Commitchore(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: Verifypnpm --filter @repo/navigation typecheck test lint
  • Step 3: Commitchore(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
// 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
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.tsgen 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
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
rm -rf /tmp/template-test
  • Step 5: Commit
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:

// 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
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:

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
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 15 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
// 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:

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

Constructor changes:

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:

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
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:

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
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
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

// 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
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

// 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:

// 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
// 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
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:

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
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:

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
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

// 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
// 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)
// 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
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

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:

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
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

// 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
// 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
// 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
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

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
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
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

// 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
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

// 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
// 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
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
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:

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
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:

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 FAILsignUpUseCase doesn't take bus yet.

  • Step 3: Update signUpUseCase

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:

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
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
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:

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
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
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:

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
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:

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:

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:

${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:

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
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
// 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
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
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
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:

### 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
- **Events and Jobs Guide** — `docs/guides/events-and-jobs.md` — publish, consume, schedule background work
  • Step 3: Commit
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:

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
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

## 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
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
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.

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 3940, 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 12, § 4 → § 5 maps via Tasks 2632 + generators, § 6 → Phases 12 + 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 3940, 47). The original "deferred task-registration gap" is now obsolete.