docs(spec): cross-feature events and background jobs design

Long-form design for a future ADR-015. Defines IEventBus (new
@repo/core-events) and IJobQueue (@repo/core-shared/jobs); the bind/swap
rules; the events/, events/handlers/, jobs/, integrations/cms/jobs/
folder layout; the on-<publisher>-<event-kebab>.handler.ts naming rule;
the span+capture sandwich extension with op: "event-handler" and
op: "job"; three new ESLint rules; RecordingEventBus and RecordingJobQueue
for tests; pnpm turbo gen event {publish|consume} and gen job generators
plus the // <gen:*> anchor-comment protocol; and the existing-feature
retrofit. Status: draft pending user review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:43:53 +02:00
parent 72d5513916
commit cf41d11eff

View File

@@ -0,0 +1,653 @@
# 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 R18R21). 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>>,
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, onAuthUserSignedUp);
}
```
The handler is wrapped in the **same span+capture sandwich** as use cases (R41R44 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 R18R21)
- `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.