Files
agentic-dev/AGENTS.md
Danijel Martinek b96cce5d74 feat: hybrid versioning + automated CHANGELOG via release-please
Closes the user's ask: versioning + a changelog generated on merging
to main, building on the just-mandated Conventional Commits substrate
(CLAUDE.md Key Conventions).

Architecture: ADR-021. Cookbook: docs/guides/releasing.md.

Initial state — six tracked packages at v0.1.0:
  - .                          -> template-vertical  (tag: template-v...)
  - packages/auth              -> @repo/auth         (tag: auth-v...)
  - packages/blog              -> @repo/blog         (tag: blog-v...)
  - packages/media             -> @repo/media        (tag: media-v...)
  - packages/marketing-pages   -> @repo/marketing-pages (tag: marketing-pages-v...)
  - packages/navigation        -> @repo/navigation   (tag: navigation-v...)

Core packages, tooling, and apps are NOT independently versioned
(ADR-021 rationale: core bumps cascade; apps aren't consumables;
surfacing them would create noise without information).

Configuration:
  - release-please-config.json   - 6 tracked packages, hybrid scope,
                                   pre-1.0 conservative bump policy
                                   (feat: -> patch, feat!: -> minor),
                                   conventional-commit type mapping
  - .release-please-manifest.json - baseline 0.1.0 for all 6 packages
  - .github/workflows/release-please.yml - googleapis/release-please-
                                   action@v4 on push to main,
                                   concurrency-gated, write
                                   permissions for the rolling PR

Workflow: on every push to main, release-please scans commits since
the last release tag PER PACKAGE (using commit-path, not the
conventional-commit scope), updates a single rolling release PR with
version bumps + per-package CHANGELOG entries. Merging that PR cuts
per-package tags + GitHub releases.

CHANGELOG files seeded at v0.1.0 baseline:
  - CHANGELOG.md (root)
  - packages/<feature>/CHANGELOG.md (5 features)
Subsequent versions are appended by release-please from commit
history. Do not edit manually.

Visibility surfaces updated (every agent entry point):
  - CLAUDE.md Read First + new "Versioning is hybrid" Key Conventions
    bullet (with bump policy summary)
  - AGENTS.md preamble - new "Releases:" callout alongside Commits
  - docs/glossary.md - new Releasing section with 8 terms (Conventional
    Commits, release-please, Hybrid versioning, Tag prefix, Rolling
    release PR, Bump targeting, Pre-1.0 bump policy, Release-As trailer,
    CHANGELOG.md)
  - docs/README.md - guides tree updated with releasing.md
  - .claude/hooks/session-start.sh - one-line release reminder
  - .claude/hooks/prompt-context.sh - new keyword group for
    release/version/bump/semver/tag prompts

Package.json version bumps:
  - root: name "template" -> "template-vertical", version "0.1.0"
  - packages/auth, blog, media, marketing-pages, navigation: "0.0.0" -> "0.1.0"

Root rename rationale: release-please tags use the package-name + the
component prefix; "template-vertical" matches the repo identity (and
the user's question preview).

First release-please PR after this lands will sweep all subsequent
post-baseline commits into 0.1.1 / 0.2.0 bumps as appropriate.

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

564 lines
32 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.

# 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` (~3060s). Catches manifestcode 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** (12 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`