# AGENTS.md — Vertical Feature Monorepo

This is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.

> **Vocabulary:** Every cross-cutting term used in this repo (feature, use case, manifest, slice, conformance, dispatch, etc.) is defined in [`docs/glossary.md`](./docs/glossary.md). When in doubt about what a term means **here**, check the glossary first — it's the single source for shared vocabulary between humans and agents.

> **Commits:** Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <imperative subject>` (≤72 chars). Types: `feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert`. Use `!` for breaking changes. The sandcastle implementer + reviewer prompts enforce this; agents authoring autonomously MUST honor it.

> **Releases:** Versioning is hybrid (ADR-021) — root template + 5 feature packages version independently from `0.1.0`. release-please reads Conventional Commits and opens a rolling release PR on every merge to main; merging it cuts tagged releases. See [`docs/guides/releasing.md`](./docs/guides/releasing.md).

## Agent-driven development

This template assumes agents (Claude, Codex, etc.) will author most feature work. The orchestration substrate is [Sandcastle](https://github.com/mattpocock/sandcastle) — see [ADR-019](./docs/decisions/adr-019-sandcastle-for-agent-orchestration.md). Day-to-day entry points:

- `pnpm work next` / `ready` / `blocked` — DAG-aware task selection from `docs/work/`
- `pnpm work dispatch` — print the next dispatch plan (planning mode, no agent invoked)
- `pnpm work dispatch --execute` — invoke sandcastle (requires `ANTHROPIC_API_KEY`)
- `.sandcastle/` — 5 prompt templates (PRD eliciter, ADR eliciter, decomposer, implementer, reviewer); all enforce **generator-first** (`pnpm turbo gen <kind>` over hand-rolling)

Every feature has a `src/feature.manifest.ts` declaring its use cases AND its coverage bands. Every `bindProductionX(ctx)` and `bindDevSeedX(ctx)` self-asserts at its tail via `assertFeatureConformance(...)`. Quality is enforced by two parallel multi-latency systems:

- **Conformance** (5 gates) — TypeScript brands (0s), ESLint (<1s), boot (~3s), `pnpm conformance` (~120s), `pnpm fallow` (~30–60s). Catches manifest↔code drift. See `docs/guides/conformance-quickref.md`.
- **Coverage** (4 layers, ADR-020) — L0 vitest thresholds, L1 `pnpm coverage:diff` (cover-the-diff gate), L2 `pnpm coverage:aggregate` → committed `coverage/summary.json`, L3 `pnpm mutate` (nightly). The manifest's `coverage.bands` is the single source of truth. See `docs/guides/coverage.md`.

See `docs/guides/runbook.md` for the full workflow.

---

## Package Map

| Package                 | Tag              | Purpose                                                                                                                                |
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `@repo/core-shared`     | core             | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context)                                                          |
| `@repo/core-ui`         | core             | Design system (atoms, molecules, generic organisms, templates) — **optional**, scaffold via `pnpm turbo gen core-package ui`           |
| `@repo/core-audit`      | core             | DPA-compliant audit logging (4 impls, GDPR erasure, OTel correlation) — **optional**, scaffold via `pnpm turbo gen core-package audit` |
| `@repo/core-api`        | core-composition | tRPC router aggregator — imports `@repo/<feature>/api` only                                                                            |
| `@repo/core-cms`        | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only                                                                         |
| `@repo/core-trpc`       | core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack)                                                                |
| `@repo/auth`            | feature          | Users collection + sign-in/up/out                                                                                                      |
| `@repo/blog`            | feature          | Articles collection + article use-cases                                                                                                |
| `@repo/media`           | feature          | Media collection + upload helpers                                                                                                      |
| `@repo/marketing-pages` | feature          | Pages collection + SiteSettings global                                                                                                 |
| `@repo/navigation`      | feature          | Header global                                                                                                                          |
| `@repo/core-eslint`     | tooling          | Shared ESLint 9 flat configs (base, next, react-internal, boundaries)                                                                  |
| `@repo/core-typescript` | tooling          | Shared TypeScript base configs + Vitest base                                                                                           |
| `@repo/core-testing`    | tooling          | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks)                                         |

---

## Boundary Rules

### Five tags

- **app** (4 packages) — `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook`
- **core-composition** (3 packages) — `packages/core-api`, `core-cms`, `core-trpc`
- **core** (1–2 packages) — `packages/core-shared`; `core-ui` is optional (scaffold with `pnpm turbo gen core-package ui`)
- **feature** (5 packages) — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`
- **tooling** (3 packages) — `packages/core-eslint`, `core-typescript`, `core-testing`

### Allowed dependency directions

| Tag              | May depend on                                 |
| ---------------- | --------------------------------------------- |
| app              | app, core, core-composition, feature, tooling |
| core-composition | core, core-composition, feature, tooling      |
| core             | core, core-composition, tooling               |
| feature          | core, tooling                                 |
| tooling          | tooling                                       |

### Composition exceptions

1. **`core-api`** may import `@repo/<feature>/api` subpath exports only (to compose tRPC routers).
2. **`core-cms`** may import `@repo/<feature>/cms` subpath exports only (to compose Payload collections).
3. **`core-trpc`** reaches features transitively through `core-api`'s `AppRouter` type.

No other cross-package boundary deviations are permitted.

### Four enforcement layers

1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time.
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` only; no deep source paths exist.
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/`:
   - Enforces the five-tag rules at linting
   - Feature packages may import from `core` and tooling only.
   - `core-shared`, `core-ui` may not import any feature.
   - `core-api` restricted to `@repo/<feature>/api` imports.
   - `core-cms` restricted to `@repo/<feature>/cms` imports.
   - No `../../../` cross-package relative imports.
4. **Turborepo `boundaries`** (build-graph time) — configured in root `turbo.json`:
   - Validates the entire workspace dependency graph, including transitive dependencies
   - Catches issues ESLint might miss (e.g., transitive feature reaches through composition packages)
   - Run with `pnpm turbo boundaries`

---

## Adding a Feature

**Fast path — use the generator.** `pnpm turbo gen feature` scaffolds a package under `packages/<name>/` (single entity, single `getX` use case) matching the `navigation` reference shape. It emits package files, entities, use case + controller (with input/output schemas + presenter), mock + real repositories, DI container, both binders (`bind-production` / `bind-dev-seed`), tRPC procedures + router with tests, contract suite, dev seed, and an empty `ui/` barrel — all wired with the span + capture sandwich at bind time.

```bash
pnpm turbo gen feature                                  # interactive
pnpm turbo gen feature --args widgets Widget widgets    # non-interactive: <name> <Entity> <entities-plural>
```

The generator does NOT wire aggregators or emit Payload CMS templates / faker factories / multi-entity layouts. After running, hand-edit `apps/web-next/src/server/bind-production.ts`, `packages/core-api/src/root.ts`, and the two `package.json` files (the generator prints the exact checklist on success). See `docs/guides/scaffolding-a-feature.md` for the full reference.

**Manual path.** When the generator's scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow `docs/guides/adding-a-feature.md` — a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / lint validation.

---

## Key Commands

```bash
pnpm install           # Install all dependencies
pnpm dev               # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
pnpm typecheck         # Type-check all packages
pnpm lint              # Lint all packages (ESLint boundaries enforced)
pnpm turbo boundaries  # Validate workspace dependency graph (Turbo boundaries)
pnpm turbo gen feature       # Scaffold a new feature package (see docs/guides/scaffolding-a-feature.md)
pnpm turbo gen core-package  # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/guides/scaffolding-core-package.md)
pnpm turbo gen core-ui-component  # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/guides/scaffolding-core-ui-component.md)
pnpm test              # Run all unit + integration tests (Vitest)
pnpm test:e2e          # Run e2e tests (Playwright across both apps)
pnpm build             # Build all packages (Turborepo)
docker compose up -d   # Start PostgreSQL

# Filtered commands
pnpm dev --filter @repo/web-next              # Only Next.js app
pnpm dev --filter @repo/cms                   # Only CMS admin
pnpm dev --filter @repo/storybook             # Only Storybook
pnpm typecheck --filter @repo/blog            # Only blog feature
pnpm test --filter @repo/blog                 # Only blog unit/integration tests
```

---

## Per-Package Conventions

> Canonical summary: `CLAUDE.md` § Key Conventions.
> Decision records: `docs/decisions/adr-012-feature-conventions.md` and `docs/decisions/adr-013-input-output-unification.md`.

### Source files use RELATIVE imports (not @/)

Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it):

```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { Article } from "../../entities/models/article";
```

Entity models live at `entities/models/<x>.ts`; domain errors at `entities/errors/<domain>.ts`; the shared `InputParseError` at `entities/errors/common.ts`.
Mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `Payload` prefix (`articles.repository.ts`); interface filenames are dot-separated (`articles.repository.interface.ts`).

This keeps source code portable and avoids circular alias issues.

### Test files use @/ alias

Test files (`*.test.ts`) use the `@/` alias to import from `src/`:

```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
```

### vitest.config.ts MUST declare @/ alias

Every package's `vitest.config.ts` must define the alias:

```typescript
import path from "path";
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { environment: "node", globals: true },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});
```

### tsconfig.json rootDir = "."

TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files to coexist:

```json
{
  "extends": "@repo/core-typescript/base.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist"
  },
  "include": ["src/**/*", "tests/**/*"],
  "exclude": ["node_modules", "dist"]
}
```

### Use cases own input + output schemas

Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract.

```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import { z } from "zod";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";

// ── Input ────────────────────────────────────────────────────────────────
export const getArticlesInputSchema = z
  .object({ status: z.string().optional(), limit: z.number().int().optional() })
  .strict();
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;

// ── Output ───────────────────────────────────────────────────────────────
export const getArticlesOutputSchema = z.array(articleSchema);
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;

// ── Use case ─────────────────────────────────────────────────────────────
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;

export const getArticlesUseCase =
  (articlesRepository: IArticlesRepository) =>
  async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
    const result = await articlesRepository.getArticles(input);
    return getArticlesOutputSchema.parse(result);
  };
```

Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Void-output use cases (e.g. `signOutUseCase`, `deleteMediaUseCase`) export only `xInputSchema` — no `xOutputSchema`.

Tests inject mocks directly — no container rebinding:

```typescript
const repo = new MockArticlesRepository([]);
const useCase = getArticlesUseCase(repo);
const articles = await useCase({ status: "published" });
```

### Controllers receive `unknown` + presenter

Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value` — but the function form is always present so adding a transform later is a one-line edit.

```typescript
// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts
import { InputParseError } from "../../entities/errors/common";
import {
  getArticlesInputSchema,
  type GetArticlesOutput,
  type IGetArticlesUseCase,
} from "../../application/use-cases/get-articles.use-case";

function presenter(value: GetArticlesOutput) {
  return value;
}

export type IGetArticlesController = ReturnType<typeof getArticlesController>;

export const getArticlesController =
  (getArticlesUseCase: IGetArticlesUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    const parsed = getArticlesInputSchema.safeParse(input);
    if (!parsed.success) {
      throw new InputParseError("Invalid input", { cause: parsed.error });
    }
    return presenter(await getArticlesUseCase(parsed.data));
  };
```

Void controllers (e.g. `signOutController`, `deleteMediaController`) return `Promise<void>` and skip the presenter entirely. One controller file per use case — no multi-method controller files.

DI binds each factory with `.toDynamicValue()`:

```typescript
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
  (ctx) =>
    getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)),
);
```

### Feature-scoped tRPC error mapping

Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes.

```typescript
// packages/blog/src/integrations/api/procedures.ts
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";

export const blogProcedure = t.procedure.use(
  defineErrorMiddleware([
    [InputParseError, "BAD_REQUEST"],
    [ArticleNotFoundError, "NOT_FOUND"],
  ]),
);
```

The router then uses `blogProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.

### Per-feature public-API surface

Each feature package exposes exactly these subpath exports:

| Subpath                | What it exports                                                                                    | Who consumes            |
| ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
| `.` (root)             | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer            |
| `./ui`                 | Query builders (`queryOptions`), UI components                                                     | App packages            |
| `./api`                | tRPC router (`xRouter` + `XRouter` type)                                                           | `@repo/core-api` only   |
| `./cms`                | Payload collections                                                                                | `@repo/core-cms` only   |
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl                                            | App packages only       |
| `./di/bind-dev-seed`   | App boot side-effect — swaps empty mock for populated mock                                         | App packages, storybook |

Apps import schemas/types from `@repo/<feature>` (root) and React Query builders from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.

### Payload-backed features use constructor injection

Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:

```typescript
// packages/blog/src/infrastructure/repositories/articles.repository.ts
@injectable()
export class ArticlesRepository implements IArticlesRepository {
  constructor(private config: SanitizedConfig) {}

  async getArticles(options?: {
    status?: string;
    limit?: number;
  }): Promise<Article[]> {
    const payload = await getPayload({ config: this.config });
    // ...
  }
}
```

Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below).

### Apps call `bindAll()` per feature at boot

Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment:

- `USE_DEV_SEED === "true"` → dev seed (explicit override; works in any `NODE_ENV`)
- `NODE_ENV === "production"` → production (real Payload)
- otherwise → dev seed (developer default; `pnpm dev` boots without Payload)

```typescript
// apps/web-next/src/server/bind-production.ts
// Slim default template — no optional packages scaffolded yet.
// After running e.g. `pnpm turbo gen core-package events`, the full
// IEventBus type can be plugged via the generic args
// `BindProductionContext<IEventBus, ...>` and the bus/queue construction
// (resolveEventsAndJobsProduction) wires back in per the printed next-steps.
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";

export async function bindAllProduction(): Promise<void> {
  const { tracer, logger } = resolveInstrumentation();
  const resolvedConfig = await config;

  const ctx: BindProductionContext = {
    config: resolvedConfig,
    tracer,
    logger,
  };

  bindProductionAuth(ctx);
  bindProductionBlog(ctx);
  bindProductionMarketingPages(ctx);
  bindProductionNavigation(ctx);
  bindProductionMedia(ctx);
}

export async function bindAllDevSeed(): Promise<void> {
  const { tracer, logger } = resolveInstrumentation();

  const ctx: BindContext<
    IEventBus,
    IRealtimeBroadcaster,
    IRealtimeHandlerRegistry
  > = {
    tracer,
    logger,
    bus,
    queue,
    realtime,
    realtimeRegistry,
  };

  await bindDevSeedAuth(ctx);
  await bindDevSeedBlog(ctx);
  // ... (same for marketing-pages, navigation, media)
}
```

Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.

Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.

---

### Conformance contract (every feature)

Every feature package MUST declare a `src/feature.manifest.ts` using `defineFeature` from `@repo/core-shared/conformance`. The manifest declares the use cases, what they audit/publish/consume, and which optional cores they require.

The feature's `src/di/bind-production.ts` MUST call `assertFeatureConformance(container, manifest, symbols, ctx)` at the tail of `bindProduction<Name>` so `pnpm dev` refuses to boot if a binding loses its brand.

Re-export the manifest from `src/index.ts`:

```ts
export { fooManifest, type FooManifest } from "./feature.manifest";
```

See `docs/guides/conformance-quickref.md` for the canonical pattern; the generator (`pnpm turbo gen feature <name>`) emits all of this correctly by default.

---

### Cross-feature events and background jobs (ADR-015)

Three rules:

- **E0:** Events are for cross-feature decoupling. In-feature reactions are direct use-case calls — do not use the bus.
- **E1:** Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-\* files (never re-exported, ESLint-enforced).
- **J0:** Jobs are for _deferred_ work, not abstraction. Synchronous code stays synchronous.

`@repo/core-events` provides `IEventBus` (`InMemoryEventBus` for dev/test, `PayloadJobsEventBus` for prod). `@repo/core-shared/jobs` provides `IJobQueue` (`InMemoryJobQueue` / `PayloadJobQueue`). Both are swapped by `bindAll()` using the same `USE_DEV_SEED` / `NODE_ENV` rules as repositories.

Per-feature folders (all optional): `events/<x>.event.ts`, `events/handlers/on-<publisher>-<event>.handler.ts`, `jobs/<x>.job.ts`, `integrations/cms/jobs/<x>.task.ts`.

Use the generators: `pnpm turbo gen event {publish|consume}`, `pnpm turbo gen job`. They insert at six fixed `// <gen:*>` anchor comments present in every feature.

See `docs/guides/events-and-jobs.md` and `docs/decisions/adr-015-events-and-jobs.md`.

---

### Realtime layer (ADR-016)

Three rules:

- **R0:** Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures. Use realtime when the server needs to push without a request, or the data is too high-frequency for HTTP.
- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`).
- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this.

`@repo/core-realtime` provides `IRealtimeBroadcaster` (server → client), `IRealtimeHandlerRegistry` (client → server), and the `SocketIORealtimeServer` adapter. `apps/web-next/server.ts` replaces `next start`/`next dev` with a custom Node http server hosting both Next.js and Socket.IO on port 3000.

Use the generators: `pnpm turbo gen realtime channel`, `pnpm turbo gen realtime handler`. They insert at three fixed `// <gen:realtime-*>` anchor comments per feature.

See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.

---

## Instrumentation conventions

Substrate: **OpenTelemetry SDK** (ADR-017). Sentry is wired as the exporter via `@sentry/opentelemetry`. Vendor swaps are exporter swaps — feature code never touches Sentry or OTel SDK directly.

**Symbols (in `core-shared/instrumentation/symbols.ts`):**

- `INSTRUMENTATION_SYMBOLS.ITracer` — bound to `ITracer` (`NoopTracer` / `OtelTracer`)
- `INSTRUMENTATION_SYMBOLS.ILogger` — bound to `ILogger` (`NoopLogger` / `OtelLogger`)
- `INSTRUMENTATION_SYMBOLS.IMetrics` — bound to `IMetrics` (`NoopMetrics` / `OtelMetrics`)

**Repository constructor signature (every feature):**

```ts
constructor(
  config: SanitizedConfig,
  tracer: ITracer = new NoopTracer(),
  logger: ILogger = new NoopLogger(),
)
```

**Repository method body (every public async method):**

```ts
return this.tracer.startSpan(
  { name: "<entity>.<method>", op: "repository", attributes: { /* ... */ } },
  async (span) => {
    try {
      const result = await /* payload op */;
      span.setAttribute("count", /* ... */);
      return result;
    } catch (err) {
      this.logger.captureException(err, {
        tags: { feature: "<feature>", repo: "<entity>", method: "<method>" },
      });
      span.setStatus("error", err instanceof Error ? err.message : String(err));
      throw err;
    }
  },
);
```

**Use case + controller spans + capture (applied at DI bind time):**

```ts
const wrappedUC = withSpan(
  tracer,
  { name: "blog.getArticles", op: "use-case" },
  withCapture(
    logger,
    { feature: "blog", layer: "use-case", name: "blog.getArticles" },
    getArticlesUseCase(repo),
  ),
);
const wrappedCtrl = withSpan(
  tracer,
  { name: "blog.getArticles", op: "controller" },
  withCapture(
    logger,
    { feature: "blog", layer: "controller", name: "blog.getArticles" },
    getArticlesController(wrappedUC),
  ),
);
```

`withSpan` is outermost; `withCapture` is between span and factory so the error is captured before the span closes with error status. Bodies stay vendor-clean — neither use cases nor controllers call `tracer` / `logger` inline.

**Capture rules** (each error captured exactly once via the `__sentryReported` flag from `core-shared/instrumentation/reported-flag.ts`):

| Layer                   | Captures                                                                                      | Doesn't capture                                       |
| ----------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Repository              | Infra/Payload errors that originate here (inline in catch)                                    | Bubbled errors                                        |
| Use case                | Business-rule violations + output-schema failures originated in this body (via `withCapture`) | Errors from repos — flag set, `withCapture` bails     |
| Controller              | `InputParseError` from `safeParse` failure (via `withCapture`)                                | Errors from use cases — flag set, `withCapture` bails |
| `defineErrorMiddleware` | Nothing — maps domain → TRPCError only                                                        | —                                                     |

**Boundary rules (eslint-enforced):**
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:

- `@sentry/*`: `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}`
- `@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`: `**/instrumentation/otel/**`

The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`.

**Test rules:**

- Default to `NoopTracer` / `NoopLogger` / `NoopMetrics` (constructor defaults)
- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` from `@repo/core-testing/instrumentation`
- Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-instrumentation.ts`; old alias `no-sentry` kept for one release)

---

## Specification & Guides

- **Vertical Feature Spec** — `docs/architecture/vertical-feature-spec.md` — full design, rationale, decision log
- **Architecture Overview** — `docs/architecture/overview.md` — package responsibilities, data flow
- **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern
- **Scaffolding a Feature** — `docs/guides/scaffolding-a-feature.md` — `turbo gen feature` reference (fast path)
- **Adding a Feature Guide** — `docs/guides/adding-a-feature.md` — step-by-step new feature walkthrough (manual path)
- **Events and Jobs Guide** — `docs/guides/events-and-jobs.md` — publish, consume, schedule background work
- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive
- **Testing Strategy** — `docs/guides/testing-strategy.md` — test placement, Vitest per-package, Playwright e2e
- **TDD Workflow** — `docs/guides/tdd-workflow.md` — red-green-refactor cycle, mocking decision tree, coverage targets

Per-package documentation lives in each `AGENTS.md`:

- `packages/core-shared/AGENTS.md`
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
- `packages/core-ui/AGENTS.md` (optional — generated by `pnpm turbo gen core-package ui`; see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`)
- `packages/auth/AGENTS.md`, `blog/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `web-tanstack/AGENTS.md`, `storybook/AGENTS.md`
