# Cross-feature events and background jobs — Design

**Date:** 2026-05-08
**Status:** Draft (pending user review)
**Supersedes:** the deferred placeholders at `docs/architecture/vertical-feature-spec.md:260` ("No `effects/`, `jobs/`, `events/` unless the feature grows them") and `:662` ("no `core-events` package yet; spec addendum v4's optional `core-events` stays deferred").
**Companion ADR:** ADR-015 (to be created during implementation; this spec is the long-form design that ADR-015 distills).

---

## 1. Context and motivation

This template currently has no convention for two real architectural concerns:

- **Cross-feature reactions.** Boundary rules forbid `feature → feature` imports (R6 in `AGENTS.md`). When `auth` should trigger work in `marketing-pages` (e.g. send a welcome email after sign-up), there is no documented seam. Today the only options are smell (illegal imports) or absence (the reaction can't be expressed cleanly).
- **Deferred / scheduled / retryable work.** Use cases run synchronously on the request path. There is no documented place for work that needs durability, retries, scheduling, or rate-shaping. Payload 3.x ships a built-in jobs queue (`payload.jobs`), but the template has no convention for *where* job code lives in a feature, *how* it is wired through DI and the span+capture sandwich, or *how* it is triggered.

This spec defines both, in one document, because they share machinery (factory + sandwich, dev/prod parity via `bindAll()`, recording-test rules) and a conceptual model (typed dispatch through a core abstraction). The two concerns are kept conceptually distinct via three load-bearing rules below.

---

## 2. Conceptual model and rules

### 2.1 What events and jobs are *for*

| | Event | Job |
|---|---|---|
| **Purpose** | A feature publishes a domain fact (e.g. `auth.user.signed-up`). Other features may react. | Deferred / scheduled / retryable work, owned and triggered by one feature. |
| **Caller** | Always the publishing feature's own use case, after it succeeds. | Any use case, controller, cron schedule, admin UI, or another job. |
| **Audience** | 0..N other features, unknown to the publisher. | One feature (itself). |
| **Transport** | `IEventBus` (in-memory in dev/test, Payload-jobs fan-out in prod). | `IJobQueue` (in-memory in dev/test, Payload jobs in prod). |
| **Why it exists** | Cross-feature decoupling — boundary rules forbid feature → feature imports, so we need a seam. | Durability, retries, scheduling, rate-shaping, getting work off the request path. |

### 2.2 The three rules

These are the load-bearing discipline; everything mechanical follows from them.

- **Rule E0 — Events are for cross-feature decoupling.** If the reaction lives in the *same* feature, do not use the bus. Just call another use case. The bus is a seam, not a router.
- **Rule E1 — Event contracts are exported; handlers are private.** A feature's `events/<name>.event.ts` is re-exported from the package root (already where contracts live per R18–R21). A feature's `events/handlers/*.handler.ts` is wired only inside that feature's own `bind-production` / `bind-dev-seed` and is never re-exported from any subpath.
- **Rule J0 — Jobs are for *deferred* work, not abstraction.** If you can do the work synchronously in the request path and it doesn't need retries, durability, or scheduling, do not make it a job. A job has operational cost (admin UI noise, retry semantics, eventual consistency) that synchronous code does not.

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

### 2.3 The seam (cross-feature event flow)

```
publisher (auth)                                consumer (marketing-pages)
─────────────────                                ──────────────────────────
events/user-signed-up.event.ts                   events/handlers/on-auth-user-signed-up.handler.ts
  └── re-exported from @repo/auth root      ←──    imports contract from @repo/auth (root, allowed)

         publishes via                                    subscribes via
         IEventBus (core-events)                          IEventBus (core-events)
                ↘                                       ↙
                       @repo/core-events bus
```

The publisher never knows the consumer exists. The consumer imports only the publisher's *contract* (event name + payload schema), which already lives behind the root export. The existing ESLint boundary rules permit this — no new boundary rule is required for the cross-feature event flow.

---

## 3. New and extended packages

### 3.1 `@repo/core-events` (new)

A new package, tagged `core` in the boundary rules, owns the bus abstraction and its two implementations.

```
packages/core-events/
  src/
    event-descriptor.ts        # defineEvent(name, schema), EventDescriptor<TName, TSchema>
    event-bus.interface.ts     # IEventBus
    in-memory-event-bus.ts     # InMemoryEventBus (dev/test)
    in-memory-event-bus.test.ts
    payload-jobs-event-bus.ts  # PayloadJobsEventBus (prod)
    payload-jobs-event-bus.test.ts
    symbols.ts                 # CORE_EVENTS_SYMBOLS.IEventBus
    index.ts                   # public surface
  package.json
  tsconfig.json
  vitest.config.ts
  eslint.config.js
  AGENTS.md
```

`core-events` is intentionally a separate package, not folded into `core-shared`, because:

- `core-shared` owns *primitives* (Zod, env, instrumentation, tRPC init). `core-events` owns *infrastructure*.
- The Payload-jobs-backed bus implementation depends on `payload`. `core-shared` does not.
- A future bus replacement (Kafka, NATS, etc.) is one-package swap — easy to spot and easy to test.

Boundary tag: **core**. Allowed dependents: `feature`, `core`, `core-composition`, `app` (everything that may already depend on `core`). Allowed dependencies: `core-shared`, `tooling`.

### 3.2 `@repo/core-shared/jobs/` (extension)

`core-shared` gains a `jobs/` subdirectory with the queue abstraction:

```
packages/core-shared/src/jobs/
  job-queue.interface.ts       # IJobQueue
  in-memory-job-queue.ts       # InMemoryJobQueue
  payload-job-queue.ts         # PayloadJobQueue
  symbols.ts                   # CORE_SHARED_JOBS_SYMBOLS.IJobQueue
  index.ts                     # exported via @repo/core-shared/jobs subpath
```

A new public subpath export `@repo/core-shared/jobs` is added (`./jobs` in `package.json` exports map). This keeps the queue addressable from features without dragging Payload into `core-shared`'s root import surface.

Boundary effect: none. `core-shared` was already `core`-tagged; the new subpath is a cosmetic extension of the existing public API.

### 3.3 Interfaces — exact shapes

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

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

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

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

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

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

  subscribe<T>(
    descriptor: EventDescriptor<string, z.ZodType<T>>,
    consumerFeature: string,
    handler: EventHandler<T>,
  ): void;
}
```

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

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

The queue is intentionally minimal — a single `enqueue` method with optional `runAt`. Cron schedules live in the Payload jobs config (in `core-cms`'s `buildConfig`), not in this interface.

---

## 4. Per-feature folder layout

A feature that uses events and/or jobs has these additional directories (all optional; absent if unused):

```
packages/<feature>/src/
  events/
    <event-name>.event.ts            # publisher: contract (Zod + defineEvent)
    <event-name>.event.test.ts
    handlers/
      on-<publisher>-<event>.handler.ts        # consumer: factory
      on-<publisher>-<event>.handler.test.ts
  jobs/
    <job-name>.job.ts                # factory + Zod schema
    <job-name>.job.test.ts
  integrations/cms/jobs/
    <job-name>.task.ts               # Payload TaskConfig glue
    index.ts                         # barrel for core-cms aggregation
```

Naming rules — keep file names and wire names consistent but distinct:

- **Event contract file name:** `<event-kebab>.event.ts`, all-kebab (e.g. `user-signed-up.event.ts`).
- **Event name on the wire:** `<feature>.<event-dotted>` where `<event-dotted>` is past-tense, dot-separated (e.g. `auth.user.signed-up`). The file `user-signed-up.event.ts` defines the event named `auth.user.signed-up`.
- **Event handler file name:** `on-<publisher>-<event-kebab>.handler.ts` where `<event-kebab>` matches the publisher's contract file (e.g. `on-auth-user-signed-up.handler.ts` consumes `auth.user.signed-up`). Publisher prefix is **mandatory** to keep handler names unique when one consumer subscribes to events from multiple publishers.
- **Job file name:** `<job-kebab>.job.ts`, verb-noun kebab (e.g. `send-welcome-email.job.ts`).
- **Job slug on the wire:** `<feature>.<job-kebab>` (e.g. `marketing-pages.send-welcome-email`).

---

## 5. File shapes

### 5.1 Event contract (publisher)

```ts
// packages/auth/src/events/user-signed-up.event.ts
import { z } from "zod";
import { defineEvent } from "@repo/core-events";

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

export type UserSignedUpEvent = z.infer<typeof userSignedUpEventSchema>;

export const userSignedUpEvent = defineEvent(
  "auth.user.signed-up",
  userSignedUpEventSchema,
);
```

Re-exported from `packages/auth/src/index.ts`:

```ts
// <gen:events>
export {
  userSignedUpEvent,
  userSignedUpEventSchema,
  type UserSignedUpEvent,
} from "./events/user-signed-up.event";
```

The `// <gen:events>` anchor comment is part of the generator protocol (§ 9).

### 5.2 Publisher use case

```ts
// packages/auth/src/application/use-cases/sign-up.use-case.ts
import type { IEventBus } from "@repo/core-events";
import { userSignedUpEvent } from "../../events/user-signed-up.event";

export const signUpUseCase =
  (users: IUsersRepository, bus: IEventBus) =>
  async (input: SignUpInput): Promise<SignUpOutput> => {
    const user = await users.create(input);
    await bus.publish(userSignedUpEvent, {
      userId: user.id,
      email: user.email,
      signedUpAt: new Date().toISOString(),
    });
    return signUpOutputSchema.parse(user);
  };
```

The use-case factory gains a `bus: IEventBus` constructor parameter. The publisher always `await`s `publish`. What the bus does with the await is the bus's call (in-memory: synchronous fan-out; Payload-jobs: returns after enqueue, not after delivery). The publisher's contract is "I told the bus."

DI binding for this use case is updated to inject the bus:

```ts
bind<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase)
  .toDynamicValue((ctx) =>
    withSpan(
      tracer,
      { name: "auth.signUp", op: "use-case" },
      withCapture(
        logger,
        { feature: "auth", layer: "use-case", name: "auth.signUp" },
        signUpUseCase(
          ctx.container.get(AUTH_SYMBOLS.IUsersRepository),
          ctx.container.get(CORE_EVENTS_SYMBOLS.IEventBus),
        ),
      ),
    ),
  );
```

### 5.3 Event handler (consumer)

```ts
// 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,
    });
  };
```

The handler imports `UserSignedUpEvent` from `@repo/auth` (root, allowed). It is a factory of shape `(deps) => async (event: T) => Promise<void>` — same shape as a use case, but with `void` return and no presenter.

### 5.4 Subscription (consumer's `bind-*`)

```ts
// packages/marketing-pages/src/di/bind-production.ts (excerpt)
export function bindProductionMarketingPages(config: SanitizedConfig): void {
  const container = getMarketingPagesContainer(config);
  // ... repository bindings (existing) ...

  const tracer = container.get<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER);
  const logger = container.get<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER);
  const bus = container.get<IEventBus>(CORE_EVENTS_SYMBOLS.IEventBus);
  const queue = container.get<IJobQueue>(CORE_SHARED_JOBS_SYMBOLS.IJobQueue);

  // <gen:event-handlers>
  const onAuthUserSignedUp = withSpan(
    tracer,
    { name: "marketing-pages.onAuthUserSignedUp", op: "event-handler" },
    withCapture(
      logger,
      {
        feature: "marketing-pages",
        layer: "event-handler",
        name: "marketing-pages.onAuthUserSignedUp",
      },
      onAuthUserSignedUpHandler(queue),
    ),
  );
  bus.subscribe(userSignedUpEvent, "marketing-pages", onAuthUserSignedUp);
}
```

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

**Note on `bind-*` side effects.** Today the `bindProduction<Feature>` / `bindDevSeed<Feature>` functions are pure container-modification: they only call `container.bind(...)`. With this spec, they additionally call `bus.subscribe(...)` for each event handler the feature consumes. This is still boot-time-only wiring (the binders run once during `bindAll()`), but it is a small departure worth flagging. The binders remain idempotent: calling `bindAll()` twice is incorrect today and remains incorrect after this change.

### 5.5 Job

```ts
// packages/marketing-pages/src/jobs/send-welcome-email.job.ts
// Illustrative — assumes a future mailer service exists in marketing-pages.
// The shipped end-to-end test in § 13 substitutes a recording mailer.
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); // queue payload arrives as unknown
    await mailer.sendWelcome(input.userId, input.email);
  };
```

Differences from a use case:

- **Boundary parse at the top** of the body. The queue payload arrives as `unknown` (from a serialized JSON column), so the job parses it itself — same role `safeParse` plays in a controller.
- **No output schema and no presenter.** Jobs are fire-and-forget; what they return goes to the queue's bookkeeping, not to a caller.
- **Same factory + sandwich wiring** at DI bind time.

### 5.6 Payload task glue

```ts
// packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts
import type { TaskConfig } from "payload";
import { getMarketingPagesContainer } from "../../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../../di/symbols";
import type { ISendWelcomeEmailJob } from "../../../jobs/send-welcome-email.job";

export const sendWelcomeEmailTask: TaskConfig<"marketing-pages.send-welcome-email"> = {
  slug: "marketing-pages.send-welcome-email",
  inputSchema: [
    /* Payload field config matching the Zod schema */
  ],
  retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } },
  handler: async ({ req, input }) => {
    const handler = getMarketingPagesContainer(req.payload.config).get<ISendWelcomeEmailJob>(
      MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob,
    );
    await handler(input);
    return { output: {} };
  },
};
```

Re-exported from `packages/marketing-pages/src/integrations/cms/index.ts` alongside collections, behind the `// <gen:job-tasks>` anchor. `core-cms` then aggregates them:

```ts
// packages/core-cms/src/index.ts (excerpt)
import {
  pagesCollection,
  siteSettingsGlobal,
  sendWelcomeEmailTask,
} from "@repo/marketing-pages/cms";

export default buildConfig({
  collections: [pagesCollection /* , ... */],
  globals: [siteSettingsGlobal /* , ... */],
  jobs: { tasks: [sendWelcomeEmailTask /* , ... */] },
});
```

No new subpath, no new boundary rule. `./cms` does the same job for tasks that it already does for collections.

---

## 6. Bus and queue implementations

### 6.1 Two bus implementations, swapped by `bindAll()`

| Implementation | Behavior | Default for |
|---|---|---|
| `InMemoryEventBus` | Validates payload via `descriptor.schema.parse`, then `await Promise.allSettled(handlers)`. Publisher's `publish` resolves after all handlers settle. Per-handler errors are captured by each handler's own sandwich (`withCapture`) and **swallowed by the bus** (publisher never sees them). One config knob: `failFast: boolean` (default `false`) for tests that want to assert handler failures bubble. | dev, tests, `pnpm dev` |
| `PayloadJobsEventBus` | Validates payload, then enqueues one Payload task per subscriber (`__events.<event-name>.<consumer-feature>`). `publish` returns after enqueue, not after delivery. Retries + durability come from Payload. | production |

**Decision: swallow vs failFast in dev/test.** Swallow by default because:

1. Mirrors production semantics — `PayloadJobsEventBus` is async; a publisher in prod never sees a downstream handler error, so dev should match.
2. Per-handler sandwiches already capture and report errors via `logger.captureException`. The publisher does not need to re-handle them.
3. Tests that *want* to assert handler failure pass `failFast: true` to the bus constructor — explicit opt-in.

### 6.2 Two queue implementations, swapped by `bindAll()`

| Implementation | Behavior | Default for |
|---|---|---|
| `InMemoryJobQueue` | Resolves the task's handler from the local container and runs it on `setImmediate`. Honors `runAt` via `setTimeout`. Returns a synthetic `jobId`. | dev, tests |
| `PayloadJobQueue` | Wraps `payload.jobs.queue({ task, input, waitUntil: options?.runAt })`. Returns the Payload job id. | production |

### 6.3 Selection rule (extends existing `bindAll()`)

The existing app-level `bindAll()` dispatcher (in `apps/web-next/src/server/bind-production.ts`) gains a new bus/queue swap step that runs **before** any per-feature binder (so per-feature binders can `container.get<IEventBus>(...)` without ambiguity). It is independent of, and runs alongside, the existing Rule 0 (DSN → Sentry vs Noop instrumentation). Both the bus and the queue follow the same three-mode rule the repositories already use:

- `USE_DEV_SEED === "true"` → `InMemoryEventBus` + `InMemoryJobQueue` (any `NODE_ENV`)
- `NODE_ENV === "production"` → `PayloadJobsEventBus` + `PayloadJobQueue`
- otherwise → `InMemoryEventBus` + `InMemoryJobQueue` (developer default)

This means `pnpm dev` boots without Payload and events/jobs still work end-to-end, and `USE_DEV_SEED=true pnpm start` (or any prod-build smoke test) works without a Payload connection. Same trick that makes the existing repository binding work today.

### 6.4 Why `IJobQueue` lives in `core-shared` and `IEventBus` lives in `core-events`

- **`IJobQueue` works standalone.** A feature can have jobs without ever publishing or subscribing to events. Putting the queue in `core-shared` keeps simple cases simple — features only depend on `core-events` if they actually use the bus.
- **The bus depends on the queue (in production).** `PayloadJobsEventBus` enqueues subscriber tasks via `IJobQueue`. `core-events` therefore depends on `core-shared` — the existing direction (`core` may depend on `core`).
- **A future bus replacement does not affect the queue.** If `core-events` swaps to a non-Payload bus (Kafka, NATS), `IJobQueue` is unaffected.

---

## 7. Boundary rules and ESLint

Three new ESLint rules in `@repo/core-eslint`:

1. **No handler re-exports.** Files matching `**/events/handlers/*.handler.ts` may not appear in any `export from` or `export *` statement in any `index.ts` or barrel file. Custom rule, ~20 LOC. Enforces Rule E1.
2. **No direct `payload.jobs` outside the integration layer.** `payload.jobs` member access is forbidden outside `**/integrations/cms/jobs/**` and `packages/core-shared/src/jobs/**`. Use cases / controllers / repositories / event handlers must use `IJobQueue`. Custom rule, ~15 LOC.
3. **R40 allowlist confirmation.** The existing R40 rule (no `@sentry/*` in feature code) is verified to cover `events/`, `events/handlers/`, and `jobs/` — they are *not* added to the allowlist.

The existing five-tag boundary system already covers everything else:

- `feature → core-events` (allowed: feature → core)
- `feature → core-shared/jobs` (allowed: feature → core)
- `feature consumer → @repo/<feature publisher>` for event contracts (allowed only for the root subpath, which is contracts-only per R18–R21)
- `core-events → core-shared` (allowed: core → core)
- `core-events` cannot import from any feature (allowed: core forbids feature)

No new tag, no new package-level rule. Only the three custom-rule additions above.

---

## 8. Testing

`@repo/core-testing/instrumentation` gains two recordings:

```ts
// packages/core-testing/src/instrumentation/recording-event-bus.ts
export class RecordingEventBus implements IEventBus {
  readonly published: { name: string; payload: unknown }[] = [];
  private handlers = new Map<string, EventHandler<unknown>[]>();

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

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

```ts
// packages/core-testing/src/instrumentation/recording-job-queue.ts
export class RecordingJobQueue implements IJobQueue {
  readonly enqueued: { taskSlug: string; input: unknown; options?: { runAt?: Date } }[] = [];

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

| Test | Strategy |
|---|---|
| Publisher use case publishes the right event | Inject `RecordingEventBus`, assert `bus.published[0]` matches. |
| Consumer handler does its job | Call factory with mocks, pass an event payload directly. No bus involved. |
| Subscription is wired | One per-feature integration test: instantiate `InMemoryEventBus`, run `bindDevSeed<Feature>(bus, queue)`, publish, assert handler side effect. |
| Job factory does its job | Identical to a use-case test. |
| Use case enqueues a job | Inject `RecordingJobQueue`, assert `queue.enqueued[0]` matches. |
| Schema enforcement at the bus | Pass an invalid payload; assert `publish` throws `ZodError`, captured by the publisher's `withCapture`. |

The `bindDevSeed<Feature>` signature gains optional `bus` and `queue` parameters (defaulted to fresh in-memory instances) so feature integration tests can substitute recordings.

---

## 9. Generators

Two new Plop generators in `turbo/generators/config.ts`, alongside the existing `feature` generator. Both **augment an existing feature package** (rather than creating a new one) using a mix of Plop `add` and `modify` actions.

### 9.1 The anchor-comment protocol

A new convention introduced by this spec: six stable single-line anchor comments, each marking an insertion point for a generator. Pre-existing features and the `feature` generator template are updated to include the anchors as no-op comments, so all features have them from day one regardless of whether they currently use events or jobs.

| Anchor | File | Inserted by |
|---|---|---|
| `// <gen:events>` | `src/index.ts` | `gen event publish` |
| `// <gen:event-handler-symbols>` | `src/di/symbols.ts` | `gen event consume` |
| `// <gen:event-handlers>` | `src/di/bind-production.ts`, `bind-dev-seed.ts` | `gen event consume` |
| `// <gen:job-tasks>` | `src/integrations/cms/index.ts` | `gen job` |
| `// <gen:job-symbols>` | `src/di/symbols.ts` | `gen job` |
| `// <gen:jobs>` | `src/di/bind-production.ts`, `bind-dev-seed.ts` | `gen job` |

Files without the required anchor cause the generator to abort with a clear `missing // <gen:xxx> anchor in <file>` error before writing partial output.

### 9.2 `pnpm turbo gen event`

A single generator with a `mode` prompt. Prompts and validation:

| Prompt | Validation | Used for |
|---|---|---|
| `mode` | `publish` \| `consume` | Branch logic |
| `feature` | kebab-case, `packages/<feature>/src/` must exist | Where the file is generated |
| `event` | dotted-kebab past tense (`user.signed-up`) | Event slug — full name becomes `<feature>.<event>` (publish) or `<publisher>.<event>` (consume) |
| `publisher` (consume only) | kebab-case, `packages/<publisher>/src/events/<event>.event.ts` must exist | Import path for the contract |

**Publish mode** emits:

- `packages/<feature>/src/events/<event>.event.ts` (Zod schema + `defineEvent`; schema body is a `z.object({}).strict()` stub)
- `packages/<feature>/src/events/<event>.event.test.ts`
- `modify` `packages/<feature>/src/index.ts` — re-export the contract behind `// <gen:events>`

It does **not** modify any use case. Wiring `bus: IEventBus` into a use-case factory is a manual edit because the user picks which use case publishes; the printed next-steps block walks through that edit.

**Consume mode** emits:

- `packages/<feature>/src/events/handlers/on-<publisher>-<event-kebab>.handler.ts`
- `packages/<feature>/src/events/handlers/on-<publisher>-<event-kebab>.handler.test.ts`
- `modify` `packages/<feature>/src/di/symbols.ts` — add `IOn<...>Handler` symbol behind `// <gen:event-handler-symbols>`
- `modify` `packages/<feature>/src/di/bind-production.ts` and `bind-dev-seed.ts` — add `bus.subscribe(...)` block with the span+capture sandwich behind `// <gen:event-handlers>`

### 9.3 `pnpm turbo gen job`

Prompts:

| Prompt | Validation | Used for |
|---|---|---|
| `feature` | kebab-case, must exist | Where the job lives |
| `job` | verb-noun kebab (`send-welcome-email`) | Slug becomes `<feature>.<job>` |
| `inputShape` | `void` \| `typed` | `z.object({}).strict()` (void) or stub with one example field (typed) |

Emits:

- `packages/<feature>/src/jobs/<job>.job.ts`
- `packages/<feature>/src/jobs/<job>.job.test.ts`
- `packages/<feature>/src/integrations/cms/jobs/<job>.task.ts`
- `modify` `packages/<feature>/src/integrations/cms/index.ts` — re-export task (`// <gen:job-tasks>`)
- `modify` `packages/<feature>/src/di/symbols.ts` — add job symbol (`// <gen:job-symbols>`)
- `modify` `packages/<feature>/src/di/bind-production.ts` and `bind-dev-seed.ts` — add factory binding with `op: "job"` sandwich (`// <gen:jobs>`)

### 9.4 Idempotency / failure modes

- **File collisions** (`add` actions) fail loudly — Plop's default. Re-running with the same slug gets a clear error.
- **Modify actions** insert at the anchor on every run. Duplicate bindings produce a TypeScript error at the next `pnpm typecheck`. Acceptable tradeoff: simple regex inserts, no AST manipulation, errors are obvious.
- **Validation order:** existence checks (feature directory, anchor presence in target files, publisher contract for consume mode) run during the `prompts` phase so the generator aborts before writing partial output.

### 9.5 What the generators do NOT do

Stays out of scope (printed in the next-steps block instead):

- Modifying the publisher use case to inject the bus (publish mode).
- Wiring custom dependencies into the handler / job factory (user edits the constructor signature manually).
- Filling in the Zod schema body or Payload `inputSchema` field config beyond a stub.
- Cron schedule definition (lives in `core-cms`'s `buildConfig`, manual edit).
- Aggregator wiring (the existing `bindAll()` in `apps/web-next/src/server/bind-production.ts` already handles per-feature binders; no per-event/per-job edits needed there).

### 9.6 Existing-feature retrofit

The five existing features (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) and the `feature` generator template all gain the six anchor comments as no-op single-line comments in the appropriate files. Pure additive comment-only edits — no behavioral change. One implementation-plan task per feature plus one for the template.

---

## 10. Documentation impact

Files updated as part of this spec's implementation:

- **`AGENTS.md`** — new section under Per-Package Conventions for events and jobs (rules E0/E1/J0, folder layout, sandwich tags, `bindAll` rule, lint summary). New row in the Specification & Guides table.
- **`CLAUDE.md`** — Quick Start gains `pnpm turbo gen event` / `pnpm turbo gen job`. Read First gains a link to `docs/guides/events-and-jobs.md` (new). Key Conventions gains short bullets for E0, E1, J0.
- **`docs/guides/events-and-jobs.md`** — new long-form guide covering "publish an event", "consume an event", "add a job", with step-by-step generator-plus-manual-wiring walkthroughs.
- **`docs/guides/scaffolding-a-feature.md`** — two new sections at the bottom referencing the new generators and the events/jobs guide.
- **`docs/decisions/adr-015-events-and-jobs.md`** — new ADR distilling this spec's decisions and rules.
- **`docs/architecture/vertical-feature-spec.md`** — the deferred lines at `:260` and `:662` get updated to point at this spec instead of saying "deferred".

---

## 11. Out of scope (deferred until needed)

Each gets a one-line "deferred until needed; will be its own ADR" note in ADR-015:

- **Multi-task workflows.** Payload's `WorkflowConfig` (composing tasks with branching/retry logic). Defer until one feature has a real multi-step workflow.
- **Saga / orchestration.** Cross-feature multi-step processes with compensating actions. Defer until a real saga appears.
- **Exactly-once delivery semantics.** Current design is at-least-once for the Payload-jobs bus (each subscriber gets its own task; retries possible). If at-most-once or exactly-once is ever required, that is a new ADR with a new bus implementation.
- **Event versioning.** Schema evolution strategy (v1 / v2 event shapes, parallel publishing, consumer migration). Defer until first breaking-change need.
- **Subscriber dynamic discovery.** Currently `bus.subscribe` is called at app boot inside each consumer's `bind-production`. A feature added at runtime (plugin model) cannot subscribe. Defer.
- **`pnpm turbo gen feature --with-events --with-jobs` flags.** The Phase-1 `feature` generator stays single-purpose. If repeated patterns emerge across features, revisit.

---

## 12. Decisions locked at this stage

These are the irreversible-feeling design calls — flagged here so reviewers spot any disagreement before implementation begins:

1. **Both events and jobs in one ADR (ADR-015), not split.** They share machinery (factory + sandwich, `bindAll` rule, recording-test pattern). Splitting would force cross-references on every page.
2. **Rule E0: no in-feature events.** The bus is for cross-feature decoupling only. In-feature reactions are direct use-case calls.
3. **`@repo/core-events` is a new package, not a fold-in to `core-shared`.** Reasoning in § 3.1.
4. **`IJobQueue` lives in `core-shared/jobs/`.** Reasoning in § 6.4.
5. **In-memory bus swallows handler errors by default.** Reasoning in § 6.1.
6. **Handler files use `on-<publisher>-<event-kebab>.handler.ts`.** Publisher prefix is mandatory.
7. **Generators for event (publish/consume) and job are in scope.** Anchor-comment protocol is the convention.
8. **Existing-feature retrofit is part of the implementation plan.** All five features gain the six anchor comments as no-op edits.

---

## 13. Success criteria

The implementation is complete when:

- `pnpm turbo gen event publish` and `pnpm turbo gen event consume` run cleanly against `auth` and `marketing-pages`, producing files that pass lint + typecheck + tests on first generation.
- `pnpm turbo gen job` runs cleanly against `marketing-pages`, producing files that pass lint + typecheck + tests on first generation.
- A single end-to-end test in `apps/web-next` proves the cross-feature flow: a sign-up triggers a `marketing-pages` job that asserts a side effect against a recording mailer.
- `bindAll()` correctly swaps the bus and queue between in-memory and Payload-backed based on `USE_DEV_SEED` / `NODE_ENV`, verified by a feature integration test in each mode.
- All five existing features have the six anchor comments (verified by a tiny CI grep test).
- `AGENTS.md`, `CLAUDE.md`, `docs/guides/events-and-jobs.md`, `docs/guides/scaffolding-a-feature.md`, and `docs/decisions/adr-015-events-and-jobs.md` are written and cross-linked.
- All three new ESLint rules pass against the touched packages and reject deliberately-broken fixtures in `core-eslint`'s test suite.
