# 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/.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 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 = { readonly name: TName; readonly schema: TSchema; }; export function defineEvent( name: TName, schema: TSchema, ): EventDescriptor { 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 = (event: T) => Promise; export interface IEventBus { publish( descriptor: EventDescriptor>, payload: T, ): Promise; subscribe( descriptor: EventDescriptor>, handler: EventHandler, ): void; } ``` ```ts // packages/core-shared/src/jobs/job-queue.interface.ts export interface IJobQueue { enqueue( 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//src/ events/ .event.ts # publisher: contract (Zod + defineEvent) .event.test.ts handlers/ on--.handler.ts # consumer: factory on--.handler.test.ts jobs/ .job.ts # factory + Zod schema .job.test.ts integrations/cms/jobs/ .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.ts`, all-kebab (e.g. `user-signed-up.event.ts`). - **Event name on the wire:** `.` where `` 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--.handler.ts` where `` 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.ts`, verb-noun kebab (e.g. `send-welcome-email.job.ts`). - **Job slug on the wire:** `.` (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; export const userSignedUpEvent = defineEvent( "auth.user.signed-up", userSignedUpEventSchema, ); ``` Re-exported from `packages/auth/src/index.ts`: ```ts // export { userSignedUpEvent, userSignedUpEventSchema, type UserSignedUpEvent, } from "./events/user-signed-up.event"; ``` The `// ` 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 => { 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(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; export const onAuthUserSignedUpHandler = (queue: IJobQueue) => async (event: UserSignedUpEvent): Promise => { 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` — 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(INSTRUMENTATION_SYMBOLS.TRACER); const logger = container.get(INSTRUMENTATION_SYMBOLS.LOGGER); const bus = container.get(CORE_EVENTS_SYMBOLS.IEventBus); const queue = container.get(CORE_SHARED_JOBS_SYMBOLS.IJobQueue); // 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 (R41–R44 from `docs/decisions/adr-014-instrumentation-sentry.md`), with two new tag values: `op: "event-handler"`, `layer: "event-handler"`. The `// ` anchor marks where `gen event consume` inserts new blocks. **Note on `bind-*` side effects.** Today the `bindProduction` / `bindDevSeed` 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; export type ISendWelcomeEmailJob = ReturnType; export const sendWelcomeEmailJob = (mailer: IMailerService) => async (input: SendWelcomeEmailInput): Promise => { 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( MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, ); await handler(input); return { output: {} }; }, }; ``` Re-exported from `packages/marketing-pages/src/integrations/cms/index.ts` alongside collections, behind the `// ` 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..`). `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(...)` 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/` 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[]>(); async publish(d: EventDescriptor>, payload: T): Promise { d.schema.parse(payload); this.published.push({ name: d.name, payload }); for (const h of this.handlers.get(d.name) ?? []) await h(payload); } subscribe(d: EventDescriptor>, h: EventHandler): void { const arr = this.handlers.get(d.name) ?? []; arr.push(h as EventHandler); 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(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(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` 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 | |---|---|---| | `// ` | `src/index.ts` | `gen event publish` | | `// ` | `src/di/symbols.ts` | `gen event consume` | | `// ` | `src/di/bind-production.ts`, `bind-dev-seed.ts` | `gen event consume` | | `// ` | `src/integrations/cms/index.ts` | `gen job` | | `// ` | `src/di/symbols.ts` | `gen job` | | `// ` | `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 // anchor in ` 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//src/` must exist | Where the file is generated | | `event` | dotted-kebab past tense (`user.signed-up`) | Event slug — full name becomes `.` (publish) or `.` (consume) | | `publisher` (consume only) | kebab-case, `packages//src/events/.event.ts` must exist | Import path for the contract | **Publish mode** emits: - `packages//src/events/.event.ts` (Zod schema + `defineEvent`; schema body is a `z.object({}).strict()` stub) - `packages//src/events/.event.test.ts` - `modify` `packages//src/index.ts` — re-export the contract behind `// ` 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//src/events/handlers/on--.handler.ts` - `packages//src/events/handlers/on--.handler.test.ts` - `modify` `packages//src/di/symbols.ts` — add `IOn<...>Handler` symbol behind `// ` - `modify` `packages//src/di/bind-production.ts` and `bind-dev-seed.ts` — add `bus.subscribe(...)` block with the span+capture sandwich behind `// ` ### 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 `.` | | `inputShape` | `void` \| `typed` | `z.object({}).strict()` (void) or stub with one example field (typed) | Emits: - `packages//src/jobs/.job.ts` - `packages//src/jobs/.job.test.ts` - `packages//src/integrations/cms/jobs/.task.ts` - `modify` `packages//src/integrations/cms/index.ts` — re-export task (`// `) - `modify` `packages//src/di/symbols.ts` — add job symbol (`// `) - `modify` `packages//src/di/bind-production.ts` and `bind-dev-seed.ts` — add factory binding with `op: "job"` sandwich (`// `) ### 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--.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.