# ADR-015 — Cross-feature events and background jobs

**Status:** Accepted
**Date:** 2026-05-08
**Spec:** docs/superpowers/specs/2026-05-08-events-and-jobs-design.md
**Plan:** docs/superpowers/plans/2026-05-08-events-and-jobs.md

## Context

Until this ADR the monorepo had no shared mechanism for *cross-feature* communication or *deferred* work. Two separate gaps:

1. **Cross-feature reactions** — when `auth` creates a user, `marketing-pages` wants to send a welcome email. Direct imports between feature packages are blocked by ESLint boundaries (R20). Without a bus, the only options were to merge the features or to leak a use-case import through `core-api`. Both compromise the vertical-slice property.
2. **Background jobs** — heavyweight side effects (email send, image processing, periodic cleanups) belong off the request path. The repo had no contract for "enqueue and run later." Payload's job system sits in `apps/cms` but feature packages had no abstraction over it.

The architecture's vendor-isolation principle (R40) — feature packages must not import vendor SDKs directly — applies to Payload as well. Whatever bus and queue we ship must give features a vendor-neutral interface and route the vendor calls through one boundary layer.

## Decision

**1. Two new abstractions, both vendor-neutral.** `IEventBus` lives in `@repo/core-events` (a brand-new package); `IJobQueue` lives in `@repo/core-shared/jobs/` (a new subpath of an existing package). Both are pure TypeScript interfaces. Feature packages depend on the interface, never the implementation.

**2. Three rules, ESLint-enforced.**

- **E0 — Events are for cross-feature decoupling, not internal flow control.** In-feature reactions are direct use-case calls. The bus is for crossing feature boundaries.
- **E1 — Event contracts are public; handlers are private.** The publisher's `events/<x>.event.ts` is exported from the feature root barrel. The consumer's `events/handlers/on-<publisher>-<event>.handler.ts` is private to the consumer's `bind-*` files and never re-exported. Custom rule `core-eslint/rules/no-handler-reexport` blocks accidental exports.
- **J0 — Jobs are for *deferred* work, not abstraction.** Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron).

A second custom ESLint rule, `no-direct-payload-jobs`, blocks `payload.jobs.queue(...)` outside `core-shared/jobs/`. Feature packages enqueue through `IJobQueue` only.

**3. Two bus implementations, two queue implementations, swapped by `bindAll()`.**

- `InMemoryEventBus` — synchronous fan-out for dev / test; respects an optional `failFast` mode.
- `PayloadJobsEventBus` — production; each `publish()` enqueues `__events.<publisher>.<event>.<consumer>` Payload tasks for every subscribed consumer. Uses the per-feature container to resolve the wrapped handler at task-handler time.
- `InMemoryJobQueue` — `setImmediate`-based for dev / test; supports `register(slug, handler)` so feature binders wire dispatch at boot.
- `PayloadJobQueue` — production; thin wrapper over `payload.jobs.queue`.

The `apps/web-next/src/server/bind-production.ts` `bindAll()` dispatcher gains `resolveEventsAndJobsProduction()` and `resolveEventsAndJobsDevSeed()`. Selection follows the same rule order as repository binding: `USE_DEV_SEED=true` → in-memory; `NODE_ENV=production` → Payload-backed; otherwise → in-memory (developer default). Selection is orthogonal to instrumentation Rule 0.

**4. Subscribe takes a `consumerFeature` string.** `IEventBus.subscribe(descriptor, consumerFeature, handler)` — three arguments. The middle argument lets `PayloadJobsEventBus` enumerate concrete `__events.*.task.<consumer>` slugs at fan-out time, and lets `InMemoryEventBus.failFast` produce useful error messages. The spec's original two-arg form was widened during plan self-review so `PayloadJobsEventBus` could be directly assignable to `IEventBus`.

**5. Per-feature folder layout (all optional).**

```
packages/<feature>/src/
  events/
    <event-kebab>.event.ts                       (publisher)
    handlers/on-<publisher>-<event>.handler.ts   (consumer)
  jobs/
    <job-kebab>.job.ts                           (factory + Zod schema + ITypedJob)
  integrations/cms/jobs/
    <job-kebab>.task.ts                          (Payload TaskConfig)
    __events-<publisher>-<event>.task.ts         (auto-generated by gen event consume)
```

The Payload `TaskConfig` for an event-task uses `TaskConfig<{ input; output }>` shape (not `TaskConfig<"slug">`) because runtime-generated event slugs are not keys of `TypedJobs['tasks']`.

**6. Six anchor-comment slots in every feature.** Generators inject at fixed `// <gen:*>` anchors (`<gen:events>` in `src/index.ts`, `<gen:event-handler-symbols>` and `<gen:job-symbols>` in `src/di/symbols.ts`, `<gen:event-handlers>` and `<gen:jobs>` in both `bind-*.ts` files, `<gen:job-tasks>` in `src/integrations/cms/index.ts`). All five existing features were retrofitted; the `feature` generator template emits the four anchors that fall inside generated files (the `<gen:job-tasks>` location is in CMS-index, which is manually authored). A CI guard at `packages/core-eslint/anchors.test.js` asserts the anchors stay present.

**7. Three generators.**

- `pnpm turbo gen event publish <feature> <slug>` — scaffolds the contract + test, threads through `<gen:events>`.
- `pnpm turbo gen event consume <consumer> <slug> <publisher>` — scaffolds the handler + test, plus a Payload event-task; threads through `<gen:event-handler-symbols>`, both `<gen:event-handlers>`, and `<gen:job-tasks>`. The Payload task closes the production-bus loop end-to-end.
- `pnpm turbo gen job <feature> <slug> <void|typed>` — scaffolds the factory + test + Payload TaskConfig; threads through `<gen:job-symbols>`, both `<gen:jobs>`, and `<gen:job-tasks>`.

A shared `assertAnchors(repoRoot, relPath, anchors[])` helper at `turbo/generators/lib/anchor-validate.ts` is the first action of every generator path.

**8. `IEventBus` lives in a new package, `IJobQueue` lives in `core-shared`.** `IEventBus` is built on top of `IJobQueue` (`PayloadJobsEventBus.publish` calls `queue.enqueue`), so they cannot live in the same package without a circular import — hence the split. `IJobQueue` is closer to a system primitive (Redis-style enqueue/dequeue), `IEventBus` is application-layer pubsub.

## Alternatives considered

- **Single package containing both interfaces.** Rejected — `PayloadJobsEventBus` depends on `IJobQueue`. If `IJobQueue` lived in `core-events`, every feature that uses *only* jobs (no events) would still pull `core-events` transitively. The split keeps the dependency graph minimal.
- **Synchronous in-process events without a queue layer.** Rejected for production — Payload's job system gives durability, retries, and observability for free; events that flow through it gain those properties at no extra cost.
- **Vendor-coupled events (e.g., direct `payload.jobs.queue`).** Rejected — would re-couple feature packages to Payload, violating R40's vendor-isolation principle.
- **Event contracts as ad-hoc TypeScript types instead of `EventDescriptor` + Zod.** Rejected — the descriptor's `name` field is the wire format the production bus uses to route to `__events.*` task slugs. Without a single source of truth, publisher and consumer can disagree at runtime. Zod gives runtime payload validation cheaply.
- **No anchor protocol; generators target file-end positions.** Rejected — feature files evolve, file-end positions are unstable, and the ESLint config is the only place we can lock structure. Anchors give explicit injection points; the CI guard locks them.
- **One `event` generator with mode-as-prompt vs. two separate generators (`event-publish`, `event-consume`).** The single-generator form ships, but Plop's `--args` cannot bypass conditional prompts, so the publisher prompt's `when` clause was dropped — publish mode silently ignores the field. Future revision may split the generators if the UX cost is significant.

## Consequences

**Positive:**
- Cross-feature event flows that span vertical slices without violating boundaries.
- Background work has a single contract (`IJobQueue`) that swaps from in-memory to Payload-durable per environment.
- Vendor-swappable: replacing Payload means writing one new `IJobQueue` adapter.
- Generators eliminate boilerplate for the most repetitive parts (handler scaffold, Payload task glue, DI bindings).
- The proof-of-life flow (sign-up → welcome email) ships green in both dev-seed and production wiring; the dev-seed path is fully exercised by `apps/web-next/src/__tests__/sign-up-welcome-email.test.ts`.

**Negative:**
- Two queue implementations means dev-seed handlers register via `queue.register(slug, ...)` while production relies on Payload tasks resolving from the per-feature container. The dispatch story differs by environment; the abstraction hides it but it's a real surface.
- `InMemoryEventBus` is synchronous; `PayloadJobsEventBus` is asynchronous and at-least-once. Subscribers must be idempotent.
- Six anchor comments in every feature is more visual noise than the average reader expects. Mitigated by the CI guard (so they can't drift accidentally) and the generators (so contributors don't need to know they exist).
- `void bus; void queue;` lines linger in feature binders that haven't yet wired any event/job (Phase 6 placeholder so `no-unused-vars` passes). Cosmetic; removed naturally as features adopt the system.

## Notes from execution

- **Plan paused at the Phase 5/6 seam, not at a failure.** Phases 1–5 (additive — new packages, new lint rules, new anchor comments) finished cleanly. Phases 6–9 (invasive — every binder signature, every app's bindAll, generators, proof-of-life, docs) ran in a continuation session.
- **`InMemoryJobQueue.register()` was already in scope at Task 5** (`feat(core-shared/jobs): InMemoryJobQueue with register()`) so Task 34 Step 3's "add register" sub-step was already complete by the time bindAll-wiring landed.
- **Auth is username-based, not email-based.** The spec's example contract had `email`; this ADR's `userSignedUpEvent` schema does keep `email`, but `signUpUseCase` synthesizes `${username}@example.local` to satisfy the contract. The proof-of-life flows record this synthesized email — the realism of the address is incidental to the cross-feature plumbing being verified.
- **`apps/auth/src/di/module.ts` (the default-fallback DI module) gains `new InMemoryEventBus()` per `.toDynamicValue()` resolution.** Real cross-feature wiring runs through `bindProductionAuth` / `bindDevSeedAuth` where the bindAll-resolved bus is shared; the module's per-resolution bus is acceptable because the module is a default-mock fallback, not a runtime path.
- **`@repo/auth` and `@repo/marketing-pages` exports were extended** for the e2e test: `./di/container`, `./di/symbols`, plus `marketing-pages` exposes `./services/mailer` and `./services/recording-mailer`. Containers and symbols being public is consistent with the binders already being public.
- **Generator-level fixes folded in during Phase 8:** dropped publisher prompt's `when` clause (Plop `--args` cannot bypass conditional prompts); switched event-task template to `TaskConfig<{ input; output }>` shape (runtime slugs aren't keys of `TypedJobs['tasks']`); registered a custom Handlebars `eq` helper for the void/typed branch in `gen job`'s template.

## Out of scope (deferred)

1. **Production-mode e2e test against Payload.** The proof-of-life test runs in dev-seed (`InMemoryEventBus` + `InMemoryJobQueue`). A parallel test that exercises `PayloadJobsEventBus` against a real Payload test database would prove the `__events.*.task.ts` slug-to-handler chain end-to-end. Skipped for v1 — requires Payload test fixtures.
2. **Cron schedules for jobs.** Job cron schedules live in `core-cms`'s `buildConfig({ jobs: { ... } })`, not in the feature's job file or generator output. v2 may add a `--cron` prompt to `gen job`.
3. **Event contract evolution / versioning.** The current spec has no migration story for breaking-change schema updates. v2 may introduce versioned descriptors (`auth.user.signed-up.v2`) and dual-publish helpers.

## Related

- ADR-008 — per-feature DI containers
- ADR-010 — Turborepo boundaries
- ADR-014 — Instrumentation & Sentry logging
- Plan 10 spec — events-and-jobs-design.md
