Files
agentic-dev/AGENTS.md
Danijel Martinek b090e26701 feat(runner): WS protocol server with handshake
apps/runner scaffold (app-tier, walking-skeleton story 04): WS server
speaking @repo/core-runner-protocol. Every inbound/outbound frame is
envelope-wrapped and zod-parsed; hello/ready handshake gates on the
workspace-scoped token (constant-time compare, redacted token on
rejection replies); named error events for version/schema/auth
rejections. Config via env only (token never argv); port announced on
stdout for the story-06 provisioner. Runtime deps: ws (the standard
Node WS server; ADR-022 traces do not apply to app-tier) and zod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
2026-07-12 22:50:41 +02:00

645 lines
42 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 — Veect Control-Plane Monorepo
This repo is **Veect's control plane** — the hosted, multi-tenant half of a design-to-code SaaS ([ADR-027](./docs/decisions/adr-027-hosted-saas-and-runner-split.md)). Veect connects to a team's code repository, discovers its real components and design tokens, lets a designer compose screens on a canvas constrained to that system, and publishes real TSX as an ordinary pull request. The control plane owns auth, tenancy, workspace/project metadata, design-doc persistence, AI proxying, and orchestration of **workspace runners** (**cloud runner**: one isolated container per workspace; **local runner**: a CLI the developer runs against a local checkout), joined by a single **runner protocol**. The board renders repo components through the **iframe canvas** served by the runner's preview adapter ([ADR-028](./docs/decisions/adr-028-iframe-canvas.md)); **DesignDoc v1** (`design.veect.json`) is the committed schema and the editor is rebuilt under template conventions ([ADR-029](./docs/decisions/adr-029-designdoc-v1-and-editor-rebuild.md)). The authoritative product spec bundle lives in [`docs/product/`](./docs/product/README.md) — its README defines which document wins when two disagree.
Structurally 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, tRPC client platform, events, realtime, audit, analytics, consent, and DSR.
> **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) — including the **"Veect product domain"** section (control plane, workspace, project, runner, runner protocol, iframe canvas, Playground, registry, checkpoint, publish). When in doubt about what a term means **here**, check the glossary first; its Veect section wins over the product docs where they disagree.
> **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 root-only (ADR-021, amended by the ADR-027 retrofit) — release-please tracks a single root product version from `0.1.0` with plain `v*` tags. It opens a rolling release PR on every merge to main; merging it cuts the tag + GitHub release. There are no per-package versions or tags. 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, instrumentation interfaces, jobs, rate-limit, security headers, DI bind contexts) |
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) the home of Veect's monochrome "ink instrument" language |
| `@repo/core-events` | core | `IEventBus` `InMemoryEventBus` (dev/test) + `PayloadJobsEventBus` (prod) (ADR-015) |
| `@repo/core-realtime` | core | Socket.IO server + `IRealtimeBroadcaster` + handler registry (ADR-016) |
| `@repo/core-audit` | core | DPA-compliant audit logging (GDPR erasure, OTel correlation) (ADR-018) |
| `@repo/core-analytics` | core | Product analytics capture channel (`IAnalytics`) (ADR-024) |
| `@repo/core-consent` | core | Consent-category runtime gate + cookie banner (ADR-025) |
| `@repo/core-dsr` | core | Data-subject rights: export, delete, rectify, restrict (ADR-025) |
| `@repo/core-runner-protocol` | core | Versioned runner-protocol wire contract envelope + `.strict()` zod message schemas shared by control plane, editor, and runners (ADR-027) |
| `@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 + Next.js provider |
| `@repo/auth` | feature | Users collection + sign-in/up/out + sessions control-plane identity (ADR-027: email/password accounts; repo access is a per-workspace credential, not a user identity) |
| `@repo/editor` | feature | Rebuilt editor UI (ADR-029): React Flow board, iframe frame node, canvas-protocol client, selection overlay, zustand store (registry + selection) |
| `@repo/core-eslint` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) + the 16 conformance rules |
| `@repo/core-typescript` | tooling | Shared TypeScript base configs + Vitest base |
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks, `Recording*` doubles) |
Apps:
| App | Port | Purpose |
| ---------------- | --------- | ---------------------------------------------------------------------------------------------- |
| `apps/web-next` | 3000 | Next.js the hosted editor shell + landing page; custom `server.ts` hosts Next.js + Socket.IO |
| `apps/cms` | 3001 | Payload admin |
| `apps/storybook` | 6006 | Storybook component workshop for `core-ui` + feature UI |
| `apps/runner` | ephemeral | Workspace runner (ADR-027) WS runner-protocol server; clone/install/scan/preview stages |
`auth` and `editor` are the feature packages today (`editor` is UI-only empty `useCases`, no binders). The remaining Veect control-plane features (workspaces, projects, discovery, design-doc, …) land as sibling packages under `packages/` following the same shape. Per ADR-029, `packages/editor` is rebuilt under template conventions the prototype codebase under `docs/product/reference/` is reference material, never vendored.
---
## Boundary Rules
### Five tags
- **app** (4 packages) `apps/web-next`, `apps/cms`, `apps/storybook`, `apps/runner`
- **core-composition** (3 packages) `packages/core-api`, `core-cms` (must-have); `core-trpc` (optional, scaffolded)
- **core** (9 packages) `packages/core-shared` (must-have); `core-ui`, `core-events`, `core-realtime`, `core-audit`, `core-analytics`, `core-consent`, `core-dsr` (optional cores, all currently scaffolded new ones via `pnpm turbo gen core-package <name>`); `core-runner-protocol` (hand-scaffolded outside the generator's snapshot set)
- **feature** (1 package) `packages/auth`
- **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, feature, 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` (plus `./di/container` + `./di/symbols` for app-side controller resolution) 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`, tooling, and other features' public exports (the `@repo/<feature>` contract barrel e.g. an event contract a consumer subscribes to). They must not reach another feature's internals (the `exports` map seals those) or call its use cases directly cross-feature behaviour flows through `IEventBus`.
- `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 `auth` 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.
### Known generator staleness (warnings, not yet fixed)
The generators predate the ADR-027 retrofit. Three known traps check the generator's output before committing:
- **release-please registration collides with the root-only policy.** `pnpm turbo gen feature` calls `turbo/generators/lib/release-please-utils.ts`, which registers the new feature as a release-please component (`packages/<name>` manifest entry + per-package config block component-prefixed tags). This repo now tracks a **single root version with plain `v*` tags** per-feature components would collide with it. Until the generator is updated, revert the changes it makes to `release-please-config.json` / `.release-please-manifest.json` after scaffolding.
- **Pre-shipped library traces can clobber curated ones.** `pnpm turbo gen core-package <name>` force-writes its pre-shipped traces into `docs/library-decisions/` and has overwritten an enriched trace before (the zod trace lost its `last-revalidated` / sub-processor / socket-risk fields during story 05; restored in commit `e4a3b65`). After any core-package run, `git diff docs/library-decisions/` and restore curated fields the templates dropped.
- **The trpc core-package template is stale.** `turbo/generators/templates/core-package/trpc/` still emits the removed `@trpc/react-query` dependency (`package.json.hbs`, trace templates, snapshot self-consistent but stale). The scaffolded `packages/core-trpc` in this repo is already correct; don't re-run the trpc generator expecting current deps.
---
## Key Commands
```bash
pnpm install # Install all dependencies
pnpm dev # Start all dev servers (web-next :3000, CMS :3001, Storybook :6006)
pnpm typecheck # Type-check all packages
pnpm lint # Lint all packages (ESLint boundaries + the 16 conformance rules)
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 (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, web-next)
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/auth # Only auth feature
pnpm test --filter @repo/auth # Only auth unit/integration tests
```
> **Known warn-severity lint backlog:** `pnpm lint` currently reports ~93 warnings repo-wide (99 when flagged during the story-03 review), all from warn-by-design rules — `conformance/pii-declaration-must-be-complete` on test fixtures and `turbo/no-undeclared-env-vars` on test-only env keys. This is noted, accepted debt: don't treat it as a failure signal, and don't mass-fix it as a side effect of unrelated work. Note that lint-staged runs `--max-warnings=0` on **staged files only**, so touching a file that carries one of these warnings means clearing that file's warnings in the same commit.
---
## 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/auth/src/application/use-cases/sign-in.use-case.ts
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
import { AuthenticationError } from "../../entities/errors/auth";
import { sessionSchema } from "../../entities/models/session";
```
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 (`users.repository.ts`); interface filenames are dot-separated (`users.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/auth/src/application/use-cases/sign-in.use-case.test.ts
import { signInUseCase } from "@/application/use-cases/sign-in.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/auth/src/application/use-cases/sign-in.use-case.ts (condensed)
import { z } from "zod";
import { cookieSchema } from "../../entities/models/cookie";
import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signInInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
clientIp: z.string().optional(),
})
.strict();
export type SignInInput = z.infer<typeof signInInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const signInUseCase =
(
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
rateLimit: IRateLimit,
) =>
async (input: SignInInput): Promise<SignInOutput> => {
// … rate-limit consumption + credential checks (throw domain errors) …
return signInOutputSchema.parse({ session, cookie });
};
```
Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Void-output use cases (e.g. `signOutUseCase`) export only `xInputSchema` no `xOutputSchema`.
Tests inject mocks directly no container rebinding:
```typescript
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
const result = await useCase({ username: "alice", password: "testpassword" });
```
### 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/auth/src/interface-adapters/controllers/sign-in.controller.ts
import { InputParseError } from "../../entities/errors/common";
import {
signInInputSchema,
type ISignInUseCase,
type SignInOutput,
} from "../../application/use-cases/sign-in.use-case";
function presenter(value: SignInOutput) {
return value.cookie;
}
export type ISignInController = ReturnType<typeof signInController>;
export const signInController =
(signInUseCase: ISignInUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", {
cause: parsed.error,
});
}
const result = await signInUseCase(parsed.data);
return presenter(result);
};
```
Void controllers (e.g. `signOutController`) 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()`. In practice the binders go through the `wireUseCase` helper (`@repo/core-shared/conformance/wire-use-case`), which composes the instrumentation wrappers and binds the branded result:
```typescript
// packages/auth/src/di/bind-production.ts (excerpt)
const wrappedSignIn = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignInUseCase,
factory: signInUseCase,
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
feature: "auth",
layer: "use-case",
name: "signIn",
tracer,
logger,
rateLimit: ctx.rateLimit ?? new NoopRateLimit(),
});
```
### 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/auth/src/integrations/api/procedures.ts
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
TooManyRequestsError,
} from "../../entities/errors/auth";
import { InputParseError } from "../../entities/errors/common";
export const authProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[AuthenticationError, "UNAUTHORIZED"],
[UnauthenticatedError, "UNAUTHORIZED"],
[UnauthorizedError, "FORBIDDEN"],
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
]),
);
```
The router then uses `authProcedure.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` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
| `./cms` | Payload collections | `@repo/core-cms` only |
| `./reader` | `I<Feature>Reader` type (cross-feature domain query contract; no feature exposes one yet) | Other feature packages |
| `./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 |
| `./di/container` | The feature's Inversify container (controller resolution at the app edge) | App packages only |
| `./di/symbols` | The feature's DI symbol table | App packages only |
Apps import schemas/types from `@repo/<feature>` (root) and hooks/components from `@repo/<feature>/ui`. Deep source paths are not accessible the `exports` map enforces this.
### Feature UI structure
Each feature's `src/ui/` follows this layout:
```
src/ui/
index.ts # Barrel — exports server components as public API
query.ts # Query builder functions (framework-agnostic)
hooks/
use-<entity>.ts # "use client" — wraps useTRPC + useSuspenseQuery
components/
<entity>-list.server.tsx # Server — DI + prefetch + HydrationBoundary (public)
<entity>-list.client.tsx # "use client" — calls hook (internal only)
<entity>-card.tsx # Presentational (receives props)
```
Server components (`.server.tsx`) are the public API the barrel exports them under clean names (`UserList`, not `UserListServer`). Client components (`.client.tsx`) are internal only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<UserList />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide. (`auth`'s `src/ui/` currently ships only the barrel + query builders; this layout is the convention new feature UI follows.)
### Payload-backed features use constructor injection
Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:
```typescript
// packages/auth/src/infrastructure/repositories/users.repository.ts
@injectable()
export class UsersRepository implements IUsersRepository {
constructor(
config: SanitizedConfig,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
) {
this.config = config;
this.tracer = tracer;
this.logger = logger;
}
async getUserByUsername(username: string): Promise<User | undefined> {
const payload = await getPayload({ config: this.config });
// ...
}
}
```
Class names carry no `Payload` prefix `UsersRepository`, not `PayloadUsersRepository`. The config comes from the app at boot time (see below).
### Apps call `bindAll()` per feature at boot
Any app that resolves feature controllers (today: `web-next`; `cms` is Payload admin only and doesn't) 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 (condensed)
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
export async function bindAllProduction(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0: DSN → OTel+Sentry vs Noop
const { queue } = await resolveJobsProduction(); // PayloadJobQueue
const resolvedConfig = await config;
const ctx: BindProductionContext = {
config: resolvedConfig,
tracer,
logger,
queue,
rateLimit: new NoopRateLimit(),
};
bindProductionAuth(ctx);
}
export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation();
const { queue } = resolveJobsDevSeed(); // InMemoryJobQueue
const ctx: BindContext = {
tracer,
logger,
queue,
rateLimit: new NoopRateLimit(),
};
await bindDevSeedAuth(ctx);
// ... (same for each additional feature)
}
```
The dispatcher does not construct an `IEventBus` or realtime deps yet. When a feature needs them, construct them here and thread them via `ctx.bus` / `ctx.realtime` / `ctx.realtimeRegistry` `core-events` and `core-realtime` are scaffolded and ready.
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`.
**Cross-feature readers:** Features that expose domain queries return a reader from their binder: `bindProductionAuth(ctx)` would return `{ reader: IAuthReader }`. Consuming features accept readers as a second parameter: `bindProductionX(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit owning feature first, consumers after. Reader cycles are a design error (rule Q3). Readers live at `integrations/readers/`, exported via `./reader` subpath (no feature exposes one yet; `pnpm turbo gen reader` scaffolds it). See the cross-feature readers ADR (ADR-026) for full design.
---
### 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 follow the same `USE_DEV_SEED` / `NODE_ENV` swap rules as repositories; `bindAll()` constructs the queue today and constructs the bus once a feature consumes it (see the `bindAll()` note above).
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: "auth.signIn", op: "use-case" },
withCapture(
logger,
{ feature: "auth", layer: "use-case", name: "auth.signIn" },
signInUseCase(repo, authService, rateLimit),
),
);
const wrappedCtrl = withSpan(
tracer,
{ name: "auth.signIn", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signIn" },
signInController(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. In feature binders, use cases go through the `wireUseCase` helper from `@repo/core-shared/conformance` (which applies this composition plus the manifest-driven wrappers); controllers compose `withSpan(withCapture(...))` by hand as above.
**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
- **Product spec bundle** `docs/product/README.md` the authoritative Veect product/design/technical specification; its authority table decides which document wins (amended by ADR-027/028/029)
- **ADR-027** `docs/decisions/adr-027-hosted-saas-and-runner-split.md` hosted SaaS + control-plane/runner split
- **ADR-028** `docs/decisions/adr-028-iframe-canvas.md` iframe canvas; canvas protocol; Playground boundary
- **ADR-029** `docs/decisions/adr-029-designdoc-v1-and-editor-rebuild.md` DesignDoc v1 schema; editor rebuild
- **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` has none yet)
- `packages/core-ui/AGENTS.md`, `core-events/AGENTS.md`, `core-realtime/AGENTS.md`, `core-audit/AGENTS.md`, `core-analytics/AGENTS.md`, `core-consent/AGENTS.md`, `core-dsr/AGENTS.md`, `core-runner-protocol/AGENTS.md`
- `packages/auth/AGENTS.md`
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `storybook/AGENTS.md`