Files
agentic-dev/AGENTS.md
Danijel Martinek 15dc6b9128 feat(core-runner-protocol): scaffold protocol package
Hand-scaffolded from the packages/core-events shape: the core-package
turbo generator only covers its eight pre-curated snapshot names
(analytics, audit, consent, dsr, events, realtime, trpc, ui) and
rejects 'runner-protocol' at the name prompt. Flagged in the package
AGENTS.md as a future generator snapshot candidate.

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

42 KiB
Raw Blame History

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). 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); DesignDoc v1 (design.veect.json) is the committed schema and the editor is rebuilt under template conventions (ADR-029). The authoritative product spec bundle lives in docs/product/ — 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 — 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: <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.

Agent-driven development

This template assumes agents (Claude, Codex, etc.) will author most feature work. The orchestration substrate is Sandcastle — see ADR-019. 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 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, 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 IEventBusInMemoryEventBus (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/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

auth is the only feature package today. The Veect control-plane features (workspaces, projects, discovery, design-doc, editor, …) land as sibling packages under packages/ following the same shape. Per ADR-029, packages/editor starts empty — the prototype codebase under docs/product/reference/ is reference material, never vendored.


Boundary Rules

Five tags

  • app (3 packages) — apps/web-next, apps/cms, apps/storybook
  • 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.

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

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):

// 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/:

// 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:

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:

{
  "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.

// 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:

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.

// 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:

// 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.

// 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 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:

// 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)
// 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.realtimeRegistrycore-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:

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):

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

Repository method body (every public async method):

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):

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 bundledocs/product/README.md — the authoritative Veect product/design/technical specification; its authority table decides which document wins (amended by ADR-027/028/029)
  • ADR-027docs/decisions/adr-027-hosted-saas-and-runner-split.md — hosted SaaS + control-plane/runner split
  • ADR-028docs/decisions/adr-028-iframe-canvas.md — iframe canvas; canvas protocol; Playground boundary
  • ADR-029docs/decisions/adr-029-designdoc-v1-and-editor-rebuild.md — DesignDoc v1 schema; editor rebuild
  • Vertical Feature Specdocs/architecture/vertical-feature-spec.md — full design, rationale, decision log
  • Architecture Overviewdocs/architecture/overview.md — package responsibilities, data flow
  • Dependency Flowdocs/architecture/dependency-flow.md — allowed directions and composition pattern
  • Scaffolding a Featuredocs/guides/scaffolding-a-feature.mdturbo gen feature reference (fast path)
  • Adding a Feature Guidedocs/guides/adding-a-feature.md — step-by-step new feature walkthrough (manual path)
  • Events and Jobs Guidedocs/guides/events-and-jobs.md — publish, consume, schedule background work
  • Realtime Guidedocs/guides/realtime.md — declare channels, broadcast, receive
  • Testing Strategydocs/guides/testing-strategy.md — test placement, Vitest per-package, Playwright e2e
  • TDD Workflowdocs/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