Files
agentic-dev-template/docs/decisions/adr-015-events-and-jobs.md
Danijel Martinek 89d47cce5c docs: strip dead docs/superpowers/ refs across ADRs + guides + glossary
The docs/superpowers/{specs,plans}/ directory was archived to .archive/
in an earlier session (and .archive/ is gitignored). Every md link
into that path is now a broken reference for anyone consuming the
template fresh.

Stripped:
  - ADR-011: **Spec:** header line
  - ADR-015: **Spec:** + **Plan:** header lines
  - ADR-016: **Spec:** + **Plan:** header lines + footer "Spec —"
    bullet (the design rationale is captured in the ADR body itself)
  - ADR-017: **Spec:** + **Plan:** header lines
  - ADR-018: **Spec:** + **Plan:** header lines
  - guides/realtime.md: inline "the full spec" link + footer
    [Spec] entry (folded its description into the ADR-016 entry)
  - guides/events-and-jobs.md: inline "the full spec" link
  - architecture/vertical-feature-spec.md: stale "Deleted" subsection
    referencing docs/superpowers/plans/*

Updated:
  - glossary.md "PRD" entry: clarified status flow now matches the
    shipped pnpm work prd-ship lifecycle (draft -> in-review ->
    approved -> shipped); removed the parenthetical pointing at
    docs/superpowers/specs/ as a definition of "spec"
  - glossary.md "spec" flagged-ambiguity: rewritten to reflect that
    durable design lives in ADRs (docs/decisions/adr-NNN-*.md) and
    implementation seeds live in PRDs (docs/work/prds/*.prd.md) —
    "spec" should be avoided in this template

Preserved (legitimate refs to the SuperPowers plugin, not the dir):
  - agent-first-workflow-and-conformance.md mentions of
    `superpowers:brainstorming` — these reference the external
    plugin skill, not a file in the repo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:00:11 +02:00

12 KiB

ADR-015 — Cross-feature events and background jobs

Status: Optional — scaffold via pnpm turbo gen core-package events. When absent, ctx.bus is undefined and feature binders' bus?.subscribe/publish calls are silent no-ops. Cross-feature event fanout does not operate until core-events is scaffolded. IJobQueue (in @repo/core-shared/jobs) and the gen event/gen job generators remain fully functional without core-events.

Date: 2026-05-08

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.
  • InMemoryJobQueuesetImmediate-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 (placeholder so no-unused-vars passes). Cosmetic; removed naturally as features adopt the system.

Implementation notes

  • 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: 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.
  • ADR-008 — per-feature DI containers
  • ADR-010 — Turborepo boundaries
  • ADR-014 — Instrumentation & Sentry logging