Files
agentic-dev/docs/superpowers/specs/2026-05-08-events-and-jobs-design.md
Danijel Martinek cf41d11eff 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>
2026-05-08 10:43:53 +02:00

654 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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