Project overview and Read First now describe the Veect control plane (hosted design-to-code SaaS, ADR-027/028/029, docs/product/ as the authoritative spec bundle, glossary Veect-domain vocabulary). The versioning bullet reflects the root-only release-please policy, the Quick Start conformance-rule miscount reads sixteen, and the Read First qualifiers acknowledge events/realtime/audit cores are scaffolded. Also retargets .claude/hooks/prompt-context.sh's Releases pointer from the retired hybrid per-package scheme to the root-only policy and prunes its deleted-feature tag examples (story 02 follow-up flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
24 KiB
Veect — Control-Plane Monorepo
Quick Start
pnpm install # Install + auto-wire husky pre-commit hooks
pnpm dev # Start all dev servers
pnpm build # Build all packages
pnpm test # Run all tests
pnpm typecheck # TypeScript across all packages
pnpm lint # ESLint (incl. 16 conformance/* rules)
pnpm conformance # Cross-feature event closure
pnpm fallow # Whole-codebase: dead exports, dupes, complexity
pnpm fallow:audit # AI-change audit (run before commits)
pnpm coverage:aggregate # Merge per-package lcovs -> coverage/lcov.info + summary.json (L2)
pnpm coverage:diff # Cover-the-diff gate; JSON to stdout (L1, ADR-020)
pnpm mutate # Stryker mutation testing on entities + use-cases (L3, on-demand)
pnpm turbo boundaries # Workspace dependency graph
pnpm work status # docs/work/ epic + story state
pnpm work next # Next ready story
pnpm work dispatch # Print next dispatch plan (use --execute to invoke sandcastle)
pnpm turbo gen feature # Scaffold a new feature package
pnpm turbo gen event # Scaffold an event contract or handler
pnpm turbo gen job # Scaffold a background job
pnpm turbo gen realtime # Scaffold a realtime channel or handler
pnpm turbo gen reader # Scaffold a cross-feature reader
pnpm turbo gen core-package # Scaffold an optional core package
pnpm turbo gen core-ui-component # Scaffold an atomic-design component
docker compose up -d # Start PostgreSQL
First time? Read docs/guides/runbook.md end-to-end.
TDD
pnpm test --watch --filter @repo/<feature> # watch one feature
pnpm test:coverage # full run with coverage
pnpm test:stories # Storybook smoke tests
pnpm test:e2e # Playwright e2e
See docs/guides/tdd-workflow.md for the full cycle.
Project Overview
Veect (pronounced VEEKT) is a hosted design-to-code SaaS: it connects to a team's code repository, discovers the repo's 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 authoritative product spec bundle lives in docs/product/ — read docs/product/README.md first; it defines which document wins when two disagree. Where the bundle conflicts with the repo's ADRs, the ADRs win:
- ADR-027 — Veect v1 is a hosted cloud SaaS split into a control plane (this monorepo: auth, tenancy, workspace/project metadata, design-doc persistence, AI proxying, runner orchestration) and 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.
- ADR-028 — the iframe canvas: the board renders real repo components through runner-served iframes (preview adapter + injected agent script over postMessage). NodeRenderer survives only in the Playground (the no-repo sandbox).
- ADR-029 — DesignDoc v1 (
design.veect.json) is the canonical committed schema; the editor is rebuilt under template conventions. Checkpoint (codegen → parity check → atomic commit on the project branch) and publish (push branch + PR link) are the two git verbs.
Veect-domain vocabulary (control plane, workspace, project, runner, registry, iframe canvas, Playground, checkpoint, publish, …) is canonical in docs/glossary.md § "Veect product domain" — it wins over the product docs where they disagree.
Repo shape: Turborepo + pnpm monorepo organized by vertical features. One feature package so far (auth) owns its Clean Architecture layers; the Veect control-plane features (workspaces, projects, discovery, design-doc, editor, …) follow the same shape. Must-have core packages (core-shared, core-cms, core-api) provide foundation; eight optional cores are scaffolded and present (core-trpc, core-ui, core-events, core-realtime, core-audit, core-analytics, core-consent, core-dsr); new ones scaffold via pnpm turbo gen core-package <name> (see docs/architecture/template-tiers.md). Three tooling packages (core-eslint, core-typescript, core-testing) provide shared configs and test utilities. Three apps: apps/web-next (Next.js — the hosted editor shell + landing page), apps/cms (Payload admin), apps/storybook. Control-plane persistence is Payload CMS + Postgres (ADR-027). Workspace boundaries are enforced by ESLint (lint-time) and Turborepo (build-graph time).
Read First
docs/glossary.md— Canonical vocabulary for the monorepo, including the "Veect product domain" section (control plane, runner, runner protocol, iframe canvas, Playground, checkpoint, publish, …). Resolves "what does X mean here?" for every cross-cutting term. Shared between humans and agents.docs/product/README.md— Entry point to the authoritative product spec bundle indocs/product/(tech spec, design spec, UI gap spec, PRD, strategy docs) and the authority table for when documents disagree — as amended by ADR-027/028/029docs/decisions/adr-027-hosted-saas-and-runner-split.md— hosted SaaS topology: control plane / workspace runners / runner protocoldocs/decisions/adr-028-iframe-canvas.md— the one-runtime invariant on the board; canvas protocol; Playground boundarydocs/decisions/adr-029-designdoc-v1-and-editor-rebuild.md— DesignDoc v1 committed schema; editor rebuilt under template conventionsAGENTS.md— Package map, boundary rules, per-package conventionsdocs/architecture/overview.md— High-level architecture and package responsibilitiesdocs/architecture/vertical-feature-spec.md— Design spec with rationale and decision logdocs/guides/scaffolding-a-feature.md—turbo gen featurereference (fast path; prefer this over the manual walkthrough)docs/guides/adding-a-feature.md— End-to-end new feature walkthrough (manual path; for cases the generator's scope doesn't cover)docs/guides/events-and-jobs.md— publish/consume/schedule cookbook (cross-feature events + background jobs;core-eventsis scaffolded in this repo)docs/guides/realtime.md— Socket.IO channels, broadcasts, handlers (core-realtimeis scaffolded in this repo)docs/guides/audit-and-compliance.md— DPA-compliant audit logging cookbook (core-auditis scaffolded in this repo)docs/guides/coverage.md— 4-layer coverage cookbook (L0 vitest thresholds, L1pnpm coverage:diff, L2 aggregate, L3 mutation; ADR-020)docs/guides/releasing.md— release-please workflow: how Conventional Commits become the single root product version +CHANGELOG.md, taggedv*(ADR-021, root-only since the ADR-027 retrofit)docs/architecture/template-tiers.md— must-have vs optional packages and how to scaffold the optionalsdocs/guides/building-feature-ui.md— Feature UI components, hooks, data fetching (tRPC + React Query), SSR prefetch/hydration, seed data, DI wiringdocs/guides/compliance-overview.md— hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
Conformance system
Every feature has a src/feature.manifest.ts declaring its use cases, audits, publishes, consumes, reads (cross-feature reader deps), required cores, rateLimit?: RateLimitBudget[] (when applicable, for per-use-case rate-limit budgets), and (when applicable) requiresConsent: ConsentCategory[] for features that gate behaviour behind user consent. Drift is caught at five latencies:
| Layer | Latency | Catches |
|---|---|---|
| TypeScript brands | 0s | forgotten withSpan / withCapture / withAudit at bind time |
| ESLint rules | <1s | manifest ↔ code drift; undeclared bus.publish / auditLog.record; missing manifest; missing sibling test |
Boot assertion (pnpm dev) |
~3s | binding without required brand at runtime; manifest edited without rebinder |
CI drift gate (pnpm conformance) |
~120s | orphan event consumers across features |
Fallow (pnpm fallow) |
~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
The sixteen conformance ESLint rules: feature-must-have-manifest (error), usecase-must-have-test-file (error), required-cores-installed (error), usecase-must-be-wired (error), no-undeclared-event-publish (warn), no-undeclared-audit (warn), no-undeclared-analytics-event (warn), no-undeclared-reader (warn), pii-declaration-must-be-complete (warn), component-must-have-story (warn), component-must-have-test (warn), atomic-tier-import-direction (warn), no-undeclared-consent-check (warn), no-undeclared-rate-limit (warn), entity-must-have-test (warn), no-relative-parent-import-in-tests (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
See docs/architecture/agent-first-workflow-and-conformance.md for the full design and docs/guides/conformance-quickref.md for the day-to-day reference.
Sibling architecture: coverage (ADR-020)
Coverage runs in parallel to the 5-gate conformance system above — same multi-latency philosophy, different signal. Each feature's feature.manifest.ts declares a coverage.bands section that vitest (test-time), pnpm coverage:diff (CI/agent-loop), and pnpm mutate (nightly) all read from. Four layers:
| Layer | Catches | Surface |
|---|---|---|
| L0 Per-layer vitest thresholds | Drift below declared bands (entities/use-cases/controllers at 100%) | pnpm test:coverage |
| L1 Diff coverage | Changed line not exercised by tests | pnpm coverage:diff — CI-gated on PRs + dispatch post-task |
| L2 Aggregate trend | Codebase coverage drifted over time | pnpm coverage:aggregate → committed coverage/summary.json |
| L3 Mutation testing | Tests that exist + execute the code but assert nothing | pnpm mutate — on-demand + nightly GH Action |
See docs/guides/coverage.md for the cookbook and ADR-020 for the full rationale. Agents running in sandcastle: run pnpm coverage:diff before reporting complete — the implementer and reviewer prompts enforce this.
Key Conventions
- Conventional Commits (non-negotiable) — Every commit message MUST follow the Conventional Commits spec:
<type>(<scope>): <imperative subject>(≤72 chars). Types:feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert. Use!after type/scope for breaking changes. Body explains WHY if non-obvious. Examples:feat(auth): hash password before persisting,test(auth): assert invalid credentials error,refactor(docs)!: consolidate scaffolding into guides. The sandcastle implementer + reviewer prompts both enforce this; agents authoring commits autonomously MUST honor it. Commits become versions + changelog entries automatically via release-please (ADR-021 /docs/guides/releasing.md). - Versioning is root-only (ADR-021, amended by the ADR-027 retrofit) — Veect is a hosted SaaS deploying continuously, so release-please tracks a single root product version from
0.1.0with plainv*tags (include-component-in-tag: false). It reads Conventional Commits since the last tag and opens a rolling release PR on every merge to main; merging it cuts the tag (v0.2.0) + GitHub release + rootCHANGELOG.md. Every commit path bumps the root — there are no per-package versions or tags. Pre-1.0 policy:feat:→ patch,feat!:→ minor. - Relative imports in
src/— Source files use relative paths (../repositories/...), not@/alias @/alias in tests — Test files (*.test.ts) use@/to import fromsrc/vitest.config.ts— Every package must defineresolve.alias: { "@": path.resolve(__dirname, "./src") }tsconfig.jsonrootDir — Set"rootDir": "."so TypeScript finds bothsrc/and test files- File layout convention — Entities live at
entities/models/<x>.ts; errors atentities/errors/<domain>.ts+entities/errors/common.ts; mock siblings use the.mock.tssuffix (<x>.repository.mock.ts); real repository impls drop thepayload-prefix (<x>.repository.ts); interface filenames are dot-separated (<x>.repository.interface.ts) - Factory-function use cases & controllers — Every use case and controller is
(deps) => async (input) => result; each exportsexport type I*UseCase = ReturnType<typeof xUseCase>(and the analogousI*Controller); one controller per use case (no multi-method controllers) - DI uses
.toDynamicValue()for factories —bind<IXUseCase>(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...))); mocks remain the default binding - Tests inject mocks directly — Construct
MockXRepositoryand pass into the factory:signInUseCase(mockUsers, mockAuth)(input). No container rebinding in unit tests - Schemas in the use-case file — Every use case exports
xInputSchema(az.ZodObjectwith.strict();z.object({}).strict()for void inputs) and, for non-void use cases,xOutputSchema. Types:XInput = z.infer<typeof xInputSchema>andXOutput. Use case body ends withxOutputSchema.parse(result)before returning (runtime guarantee against malformed repository data) - Controllers receive
unknown+ presenter — ControllerssafeParse(xInputSchema)from the use-case file and throwInputParseErroron failure. Non-void controllers define a top-levelfunction presenter(value: XOutput)and returnPromise<ReturnType<typeof presenter>>(identity is fine —return value); void controllers returnPromise<void>with no presenter - Feature-scoped tRPC error mapping — Each feature has
integrations/api/procedures.tsexportingxProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))from@repo/core-shared/trpc/define-error-middleware. Routers usexProcedure.input(xInputSchema)— schemas are imported from the use-case file, never redefined inline.core-sharednever enumerates feature error classes - Public surface split — Feature root (
.) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (hooks, components, query builders) live behind./ui(src/ui/index.ts). Apps import hooks/components from@repo/<feature>/ui, schemas/types from@repo/<feature> - Feature UI owns its data fetching — Each feature's
src/ui/hooks/contains"use client"hooks that wrapuseTRPC+useSuspenseQuery. Connected components insrc/ui/components/call these hooks. App pages prefetch viaappRouter.createCaller({})and hydrate viaHydrationBoundary+dehydrate+setQueryData. Seedocs/guides/building-feature-ui.md - Payload repositories via constructor — Feature packages receive Payload config at constructor time, not as a direct dependency
- Three binding modes per feature — Each feature exports two binders:
./di/bind-production(real Payload) and./di/bind-dev-seed(populated mock). The app'sbindAll()dispatcher inapps/web-next/src/server/bind-production.tspicks one by env:USE_DEV_SEED="true"→ dev seed;NODE_ENV="production"→ production; otherwise → dev seed (developer default sopnpm devboots without Payload). Dev seed lives insrc/__seeds__/dev.tsas a lazybuildDev<Entities>()function that uses the feature's existing factory - Binders take a
ctxarg fromcore-shared/di—bindProductionX(ctx: BindProductionContext)for production binders;bindDevSeedX(ctx: BindContext)for dev-seed. Required fields:tracer,logger, plusconfigfor production. Optional fields:bus,queue,realtime,realtimeRegistry(correspond to optional core packages — guard with?.orif (bus) { ... }when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders - App bootstrap — Each app calls
bindAll()from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent - Instrumentation lives in
core-shared/instrumentation/— Three interfaces (ITracer,ILogger,IMetrics), three implementation pairs (Noop*,Otel*, andRecording*fromcore-testing). The OTel SDK is the substrate; Sentry is wired as the exporter via@sentry/opentelemetry. Feature packages MUST NOT import@opentelemetry/sdk-*or@sentry/*directly (ESLint-enforced); the vendor-neutral@opentelemetry/apifamily is the import surface for advanced cases (ADR-017) - Spans + capture composed at DI bind time — Use cases + controllers are wrapped at DI bind time in this order (outermost → innermost):
withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps). ApplywithAuditwhen the manifest declaresaudits,withAnalyticswhen it declaresanalyticsEvents,withConsentwhen it declaresrequiresConsent.withSpanis always outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they callthis.tracer.startSpan(...)andthis.logger.captureException(...)inline per method because they own per-call attributes - Capture at throw sites only, with double-report guard — Repos capture infra errors inline; use cases + controllers capture via
withCaptureat bind time;defineErrorMiddlewarenever captures. Each error gets a non-enumerable__sentryReportedflag the first time it's captured;withCapture,OtelLogger, andRecordingLoggerall bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper atcore-shared/instrumentation/reported-flag.ts) - PII handling is non-negotiable —
sendDefaultPii: falseeverywhere (CI grep gate); replay default-masks all text/inputs/media (allowlist starts empty);setUser({ id })only — no email/username; server-side PII scrubbing happens at the OTel processor layer (PiiScrubSpanProcessor+PiiScrubLogRecordProcessor) before any exporter sees the data (ADR-017 §7) - Two Sentry projects —
WEB_NEXT_SENTRY_DSN,CMS_SENTRY_DSN(storybook is not instrumented). The browser DSN uses theNEXT_PUBLIC_prefix (web-next) - Instrumentation binding is orthogonal to repo binding —
bindAll()'s Rule 0 (DSN → OTel+Sentry vs Noop) is independent ofUSE_DEV_SEED/NODE_ENV. Runpnpm devwithWEB_NEXT_SENTRY_DSNset to test the integration locally - Cross-feature events go through
IEventBus(E0) — In-feature reactions are direct use-case calls, not bus publishes. The bus is for crossing feature boundaries (e.g. anauthsigned-up event consumed by another feature) - Event contracts are public; handlers are private (E1) — Publisher's
events/<x>.event.tsis exported from the feature root barrel. Consumer'sevents/handlers/on-<publisher>-<event>.handler.tsis never re-exported (ESLint-enforced viacore-eslint/rules/no-handler-reexport) - Jobs are for deferred work, not abstraction (J0) — Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via
IJobQueueonly — directpayload.jobs.queue()is ESLint-blocked outsidecore-shared/jobs/ - Realtime is for state delivery, not for replacing tRPC (R0) — Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
- Realtime channel descriptors are exported; handlers are private (R1) — A feature's
realtime/<name>.channel.tsis re-exported from the root barrel;realtime/handlers/*.handler.tsis wired only in bind-* files and never re-exported (ESLint-enforced viano-realtime-handler-reexport) socket.iolives in@repo/core-realtimeonly (R2) — Feature packages MUST NOT importsocket.ioorsocket.io-client. ESLint ruleno-direct-socket-ioenforces this; allowlist coverscore-realtime/src/socket-io-*.tsandapps/*/server.ts- Cross-feature domain queries go through readers (Q0) — When a use case needs another vertical's domain-evaluated answer on the request path (e.g., permission check), use a reader (
I<Feature>Reader). For raw data joins, use PayloadrelationTo. For reactions/side effects, use the event bus - Reader contracts are public; implementations are private (Q1) — The owning feature exports
I<Feature>Readerfrom./readersubpath (integrations/readers/). The implementation (<Feature>Reader) is internal, constructed by the binder. Consumers import the type only - Readers are strictly read-only; cross-feature writes go through events (Q2) — A reader may only wrap use cases declared
mutates: false. Enforced byReadOnly<F>brand at compile time andassertReaderPurityat boot time - Reader cycles are a design error (Q3) — If Feature A reads from Feature B and vice versa, the boundaries are wrong. Break via: (a) UI composition at app layer, (b) event for one direction, (c) merge the features
- Readers wrap existing use cases, not repositories — The reader is a thin facade; if the domain logic doesn't exist as a use case yet, create the use case first (manifest-first). No
MockReaderneeded — same class works in dev-seed because the use cases beneath it are backed by mock repos - Manifest
readsfield — Use cases that query another feature's reader declarereads: ["<feature>"]infeature.manifest.ts. Verified byassertFeatureConformanceat boot andno-undeclared-readerESLint rule - Binders return readers;
bindAll()threads them —bindProductionAuth(ctx)returns{ reader: IAuthReader }.bindAll()passes it:bindProductionX(ctx, { authReader: authResult.reader }). Ordering inbindAll()is explicit — owning feature first, consumers after - Manifest-first ordering — for any new use case, the workflow is (1) manifest entry → (2) contracts (
xInputSchema,xOutputSchema,IXUseCase) → (3) tests (red) → (4) implementation (green). The generator emits the manifest + a self-assertingbind-production.tsso new features are conformance-compliant by default - Self-asserting
bindProductionX(ctx)— every feature's bind-production callsassertFeatureConformance(container, manifest, symbols, ctx)at its tail.pnpm devrefuses to boot on drift pnpm conformance— cross-feature event-closure and reader-closure check; fails CI on orphan consumers or unresolvablereadsentries- New runtime dependencies require a library trace — adding a runtime dependency to a feature- or core-tier package requires a trace at
docs/library-decisions/<date>-<name>.mdproduced by the/evaluate-libraryskill; see ADR-022 anddocs/guides/adding-a-library.md - CI security + supply-chain enforcement — Renovate for bumps + Action SHA pinning, Socket for supply-chain behavior, weekly trace revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 +
docs/guides/ci-security.md
MCP Servers
Start Storybook before UI work: pnpm dev --filter @repo/storybook
Storybook MCP available at http://localhost:6006/mcp — use list-all-documentation to discover existing components before creating new ones.
Key Ports
| Service | Port |
|---|---|
| Next.js | 3000 |
| Payload CMS | 3001 |
| PostgreSQL | 5432 |
| Storybook | 6006 |