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
42 KiB
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.0with plainv*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. Seedocs/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 fromdocs/work/pnpm work dispatch— print the next dispatch plan (planning mode, no agent invoked)pnpm work dispatch --execute— invoke sandcastle (requiresANTHROPIC_API_KEY).sandcastle/— 5 prompt templates (PRD eliciter, ADR eliciter, decomposer, implementer, reviewer); all enforce generator-first (pnpm turbo gen <kind>over hand-rolling)
Every feature has a src/feature.manifest.ts declaring its use cases AND its coverage bands. Every bindProductionX(ctx) and bindDevSeedX(ctx) self-asserts at its tail via assertFeatureConformance(...). Quality is enforced by two parallel multi-latency systems:
- Conformance (5 gates) — TypeScript brands (0s), ESLint (<1s), boot (~3s),
pnpm conformance(~120s),pnpm fallow(~30–60s). Catches manifest↔code drift. Seedocs/guides/conformance-quickref.md. - Coverage (4 layers, ADR-020) — L0 vitest thresholds, L1
pnpm coverage:diff(cover-the-diff gate), L2pnpm coverage:aggregate→ committedcoverage/summary.json, L3pnpm mutate(nightly). The manifest'scoverage.bandsis the single source of truth. Seedocs/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 viapnpm 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
core-apimay import@repo/<feature>/apisubpath exports only (to compose tRPC routers).core-cmsmay import@repo/<feature>/cmssubpath exports only (to compose Payload collections).core-trpcreaches features transitively throughcore-api'sAppRoutertype.
No other cross-package boundary deviations are permitted.
Four enforcement layers
package.jsondependencies — only allowed deps are declared; illegal imports fail at install time.exportsmaps — feature packages expose.,./ui,./cms,./api,./di/bind-production,./di/bind-dev-seed(plus./di/container+./di/symbolsfor app-side controller resolution) only; no deep source paths exist.- ESLint
eslint-plugin-boundaries(lint-time) — configured inpackages/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 (theexportsmap seals those) or call its use cases directly — cross-feature behaviour flows throughIEventBus. core-shared,core-uimay not import any feature.core-apirestricted to@repo/<feature>/apiimports.core-cmsrestricted to@repo/<feature>/cmsimports.- No
../../../cross-package relative imports.
- Turborepo
boundaries(build-graph time) — configured in rootturbo.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 featurecallsturbo/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 plainv*tags — per-feature components would collide with it. Until the generator is updated, revert the changes it makes torelease-please-config.json/.release-please-manifest.jsonafter scaffolding. - Pre-shipped library traces can clobber curated ones.
pnpm turbo gen core-package <name>force-writes its pre-shipped traces intodocs/library-decisions/and has overwritten an enriched trace before (the zod trace lost itslast-revalidated/ sub-processor / socket-risk fields during story 05; restored in commite4a3b65). 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-querydependency (package.json.hbs, trace templates, snapshot — self-consistent but stale). The scaffoldedpackages/core-trpcin 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 lintcurrently reports ~93 warnings repo-wide (99 when flagged during the story-03 review), all from warn-by-design rules —conformance/pii-declaration-must-be-completeon test fixtures andturbo/no-undeclared-env-varson 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=0on 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.mdanddocs/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 anyNODE_ENV)NODE_ENV === "production"→ production (real Payload)- otherwise → dev seed (developer default;
pnpm devboots 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.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:
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.tsis re-exported from the package root barrel;realtime/handlers/*.handler.tsis wired only in the feature's own bind-* files and never re-exported (ESLint-enforced viano-realtime-handler-reexport). - R2:
socket.iolives in one package only. Feature packages MUST NOTimport "socket.io"orimport "socket.io-client". Allowlist:packages/core-realtime/src/socket-io-*.ts+apps/*/server.ts. ESLint ruleno-direct-socket-ioenforces 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 toITracer(NoopTracer/OtelTracer)INSTRUMENTATION_SYMBOLS.ILogger— bound toILogger(NoopLogger/OtelLogger)INSTRUMENTATION_SYMBOLS.IMetrics— bound toIMetrics(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/RecordingMetricsfrom@repo/core-testing/instrumentation - Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by
core-testing/setup/no-instrumentation.ts; old aliasno-sentrykept 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 featurereference (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.mdpackages/core-api/AGENTS.md,core-cms/AGENTS.md(core-trpchas 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.mdpackages/auth/AGENTS.mdpackages/core-eslint/AGENTS.md,core-typescript/AGENTS.md,core-testing/AGENTS.mdapps/cms/AGENTS.md,web-next/AGENTS.md,storybook/AGENTS.md