Coding Standards — AI Software Factory
Binding conventions for all code in this monorepo. These implement the AGENTS.md rules as checkable practice; where a rule is "enforced", a lint rule, CI check, or coverage gate backs it (see testing.md for the gates). Conventions exist to make code reviewable in 10 minutes — if a file needs a walkthrough to understand, the file is wrong.
1. TypeScript strict rules
Base config: packages/config/tsconfig.base.json, extended by every
workspace. Non-negotiables:
{
"compilerOptions": {
"strict": true, // the floor, not the ceiling
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined — handle it
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false, // pragmatic: explicit `?` is enough
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true, // `import type` where it matters
"isolatedModules": true,
"noEmit": true // apps emit via their builders; packages via tsc to dist
}
}
Rules:
| # | Rule |
|---|---|
| TS1 | strict: true everywhere; no strict: false override in any workspace (CI typecheck fails on the flag itself). |
| TS2 | No any except an explicit unknown → narrowed, or a single // type-escape: <one-line reason> comment (lint counts them; the count may only ratchet down). |
| TS3 | Public package APIs (packages/* exports) use readonly types for outputs (readonly Block[]), and exported types are explicit (no leaking of Prisma model types across package boundaries — map to packages/shared DTOs at the repository edge). |
| TS4 | Discriminated unions over optional-object flags: type View = { type: "board"; group: PropertyId } | { type: "table" } — never { type?: "board"; group?: PropertyId }. |
| TS5 | Promise everywhere for I/O; no callback style; async functions must handle their own errors or declare them (no floating promises — lint @typescript-eslint/no-floating-promises). |
| TS6 | Enums: prefer packages/shared Zod-validated string-literal unions mirroring the Prisma enums (single source: the Prisma schema; shared types are generated-verified in CI). |
| TS7 | Ids are string (cuid) with a prefix convention at creation time (blk_, pg_, rec_, file_, tok_) — the prefix is display/telemetry only; never parse it for logic. |
2. File & naming conventions per layer
2.1 General
- Directories
kebab-case, fileskebab-case.ts(Next.js componentsPascalCase.tsx). One primary export per file; the file name matches the export (block.service.ts→export class BlockService). - No
index.tsbarrels insideapps/*/src(explicit relative imports — tree-shaking and refactor safety). Packages (packages/*/src/index.ts) are the only barrels. - Constants
SCREAMING_SNAKEat module top; private class members#x(native private) or a leading underscore only where TS requires it. - Max file size guideline: 400 lines (a file you can't hold in your head is two files). Tests may be longer (they are tables).
2.2 NestJS (apps/api) — module pattern
Every domain is a module with this fixed layout:
apps/api/src/modules/pages/
├── pages.module.ts # @Module: controllers, services, imports (repo providers)
├── pages.controller.ts # HTTP surface ONLY: params, auth context, DTO → service call, ApiError mapping
├── pages.service.ts # domain operations: transaction boundaries, events, audit, permission calls
├── pages.repository.ts # interface PageRepository (types) — implemented in common/repositories
├── dtos/
│ ├── create-page.dto.ts # Zod schema import + @nestjs/mapped-types class (for OpenAPI)
│ └── page-query.dto.ts
└── events.ts # event names + payload builders for this domain (re-exported to shared)
| Layer | May do | Must not do |
|---|---|---|
| Controller | parse/validate DTO (pipes), read auth context (@CurrentUser), call one service method, map result to response, throw ApiError from service results |
business logic, repository access, event publishing, transactions, this.prisma.* |
| Service | orchestrate: permission check (this.permissions.can(...)) → repository calls (in a prisma.$transaction via the repository's unit-of-work when multi-write) → domain event publish → audit log. Owns the business rule. |
raw SQL, Prisma client directly, HTTP concerns (status codes), JSON serialization details |
Repository (interface in module, impl in common/repositories/*-prisma.ts) |
translate domain operations to Prisma; tenant scoping is applied here (every method takes ctx: RequestContext and adds workspaceId); owns index-affecting query shape |
business rules, permission checks, events, cross-domain orchestration |
| Module | wire providers; expose exports for other modules (interface providers, not concrete Prisma classes) |
import another module's service directly when an interface exists |
Cross-module rule: modules talk through interfaces (@Inject(PAGE_REPOSITORY))
or through domain events (prefer events for side effects — ARCHITECTURE
invariant 5). Direct ServiceA → ServiceB calls are allowed only for read
queries with a documented reason; circular module imports fail the lint
boundary check.
2.3 Next.js (apps/web)
apps/web/
├── app/
│ ├── (auth)/login/page.tsx # route group = feature boundary
│ ├── (app)/[workspace]/[pageId]/page.tsx
│ ├── share/[token]/page.tsx
│ ├── api/…/route.ts # BFF route handlers (thin: forward + cache tags)
│ └── layout.tsx # providers: QueryClient, Theme, WS, Toaster
├── components/ # app-level composites (dialogs, topbar, palette)
│ └── <feature>/<Name>.tsx
├── editor/ database/ workspace/ search/ settings/ collaboration/ # feature dirs
├── hooks/
│ ├── use-page.ts # TanStack Query wrappers (server state)
│ └── use-something.ts # client-state hooks (Zustand-backed or local)
├── stores/
│ ├── editor-state.ts # Zustand store: editorState (caret, selection, history)
│ ├── ui-state.ts # panels, palette open, toasts
│ ├── presence-store.ts
│ └── offline-queue.ts # IndexedDB-backed mutation queue
├── lib/ # api client, ws client, utils, formatters
└── types/ # frontend-only types (DTOs come from @factory/shared — never redefined)
| Kind | Convention |
|---|---|
| Component | PascalCase.tsx; one component per file; props typed with an explicit interface <Name>Props; default export for pages, named export otherwise; server components by default — "use client" only where interactivity requires it (and as high in the tree as possible). |
| Hook | use-kebab.ts, export function usePage(id: string): UsePageResult; hooks own local behavior; server data goes through TanStack Query (useQuery/useMutation in hooks/), never fetch inside a component body. |
| Store (Zustand) | kebab-store.ts, export const useEditorStore = create<EditorState>()(devtools(persist(…))); stores hold client-only state (caret, selection, panels, presence, offline queue). Server data in a store = design smell (use Query). Selectors exported as selectX functions to keep subscriptions minimal. |
| Mutation | Every write is a TanStack Query mutation with onMutate (optimistic where the spec says so: todo, checkbox, board drag), onError (rollback + toast), onSuccess (invalidate keys). The mutation fn lives in lib/api/ (api.createPage, api.moveBlocks). |
| Query keys | hierarchical factory in lib/query-keys.ts: qk.page(id), qk.blocks(id), qk.dbRecords(dbId, viewId) — invalidation is by prefix. |
2.4 Worker & realtime
- Worker: one file per queue processor (
apps/worker/src/queues/search.ts), exported as a registration function; each job handler: parse payload (Zod, fromshared) → idempotency check → work →job.updateProgressfor long jobs. No HTTP imports in the worker except outbound (webhooks/AI) via the sharedHttpClient. - Realtime: connection handler (
ws.ts) → room manager (rooms.ts) → doc service (docs.ts→ Yjs). Handlers stay thin; the Yjs doc lifecycle lives indocs.ts. Every socket frame type is a Zod schema inshared(client+server share the protocol).
2.5 Packages
src/index.tsexports the public API; everything else isinternal(not exported, lint-enforced "no deep imports" from outside the package).- Pure engines (
database-engine,permissions) export pure functions + types — no classes with hidden I/O, no singletons, noDate.now()without an injected clock.
3. Import rules & package boundaries
Enforced by eslint import/no-restricted-paths + no-restricted-imports
(NFR-M6, zero-exceptions file docs/development/boundary-exceptions.md must
stay empty).
┌────────────────────────────────────────────┐
│ apps (web, api, │
│ worker, realtime) │
└──────┬──────────────┬──────────────┬───────┘
imports │ │ │ imports
┌──────────────────▼──┐ ┌────────▼────────┐ ┌───▼──────────────┐
│ UI-facing packages │ │ server packages│ │ shared │
│ ui, editor, │ │ auth, │ │ (DTOs, Zod, │
│ collaboration, │ │ permissions, │ │ events, errors,│
│ search, ai, │ │ database-engine,│ │ constants) │
│ storage │ │ search, ai, │ └──────────────────┘
└─────────────────────┘ │ storage │ ▲
(never import └─────────────────┘ │ imports nothing
apps/*, or │ │ (internal)
server-only packages │ │
except the codec │ │
┌───────────────────────────────▼────────────────────┴───┐
│ packages/config │
│ (eslint/prettier/tsconfig presets) │
└─────────────────────────────────────────────────────────┘
The precise matrix:
| Importer | May import | Must not import |
|---|---|---|
apps/web |
ui, editor, database-engine, collaboration, search, ai, storage, shared |
auth, permissions (server-only: they carry Prisma-adjacent types/keys); any apps/* |
apps/api |
auth, permissions, database-engine, search, ai, storage, shared |
ui, editor, collaboration (client-only; the Yjs codec is the one exception — it lives in packages/editor/codec and is importable from api and realtime because the block-tree↔Yjs mapping must be identical client/server) |
apps/worker |
same as api |
same as api |
apps/realtime |
auth, shared, editor/codec, collaboration(protocol types) |
permissions (permission decisions happen in api; realtime checks the token + a lightweight room-level level passed in the join payload, validated by api), ui, editor (except codec) |
packages/* (any) |
shared (and config in devDeps) |
any apps/*; no package may import a sibling package except: editor → ui (components), database-engine → nothing internal (pure), search/ai/storage → nothing internal (thin I/O over shared types). Cross-sibling imports require a recorded exception. |
packages/shared |
nothing internal | anything |
Additional import rules:
- IM1: DTOs are imported from
@factory/sharedeverywhere — never re-declared in an app (the Zod schema is the type). - IM2: Relative import depth ≤ 3 levels (
../../..is a smell — move the file or use a workspace package). - IM3: No
import * as X(namespace imports) — named imports only (tree-shaking + refactor safety). - IM4: React imports:
import { useState } from "react"; no default React import (jsx: react-jsx). - IM5: Side-effect imports only for:
import "reflect-metadata"(api bootstrap, first line), CSS in web layouts,dotenvin bootstrap files.
4. Error handling conventions
The rule: no raw throws, no raw catches.
4.1 ApiError (defined in packages/shared/errors.ts)
class ApiError extends Error {
constructor(
public readonly code: ApiErrorCode, // stable machine string
message: string, // user-safe, translatable key
public readonly status: number, // HTTP status
public readonly details?: Record<string, unknown> // field errors, ids
) { super(message); }
}
Stable codes (the contract — extending requires a shared-package change + docs update):
| Code | Status | Meaning |
|---|---|---|
VALIDATION_ERROR |
400 | Zod failed; details.fields = {path: [messages]} |
BAD_REQUEST |
400 | Malformed (not field-level) |
UNAUTHORIZED |
401 | Missing/invalid/expired token |
PERMISSION_DENIED |
403 | Authz failed (code is constant; never reveal which check failed) |
NOT_FOUND |
404 | Resource doesn't exist or isn't visible to the caller (same code for both — no existence oracle across tenants) |
CONFLICT |
409 | State conflict (last owner, duplicate slug, optimistic version mismatch, replayed refresh) |
LIMIT_EXCEEDED |
402 | Plan/usage limit |
RATE_LIMITED |
429 | Retry-After header set |
GONE |
410 | Purged/closed (form closed, link expired) |
UNSUPPORTED_MEDIA_TYPE |
415 | Import/file type not accepted |
SEARCH_UNAVAILABLE |
503 | Degrade mode (NFR-R3) |
AI_UNAVAILABLE |
503 | Provider down / no key configured |
INTERNAL |
500 | Unexpected — never a domain decision |
Rules:
| # | Rule |
|---|---|
| E1 | Services throw ApiError (or return typed results that controllers map to ApiError). Controllers never construct a 4xx/5xx themselves except by rethrowing service errors. |
| E2 | One global exception filter (apps/api/src/common/filters/api-exception.filter.ts) maps ApiError → JSON envelope, unknown errors → INTERNAL (logged, details hidden). Response envelope: { error: { code, message, details?, requestId } }. No Nest default error shape ever leaks. |
| E3 | Unknown/unexpected errors are caught once (the filter); inside services, catch only what you can translate into a typed ApiError (e.g. Prisma.PrismaClientKnownRequestError P2025 → NOT_FOUND, P2002 → CONFLICT). Translating Prisma errors happens in the repository layer (it's the only layer that sees Prisma). |
| E4 | Never leak: stack traces, SQL, Prisma messages, file paths, tenant ids of other tenants, or permission-evaluation internals into responses/logs visible to users. |
| E5 | Client (web): the API client (lib/api/client.ts) normalizes the envelope into ApiError instances; TanStack Query onError branches on code (e.g. RATE_LIMITED → toast with countdown; PERMISSION_DENIED → 403 screen on page routes). No component does err.message string-matching. |
| E6 | Worker/queue: a failed job translates errors to the run/delivery model (FAILED + error JSON) — the stack goes to logs (structured error field), not to the payload. |
| E7 | Abort/timeout: use AbortSignal.timeout(ms) for all outbound HTTP; TimeoutError → typed ApiError at the call site (e.g. AI → AI_UNAVAILABLE). |
5. Validation (Zod everywhere)
| # | Rule |
|---|---|
| V1 | Every boundary validates with Zod: API inputs (DTOs), queue payloads, WS frames, webhook inbound bodies, file metadata, import files, AI tool-call results, env (apps/api/src/config/env.ts — boot fails on invalid env with a red diff). |
| V2 | Schemas live in packages/shared — the same schema validates the API request, the queue event, and the client's optimistic mutation, and generates the TS types (z.infer). A domain schema file: packages/shared/src/schemas/page.ts exporting CreatePageSchema, PageDTO, etc. |
| V3 | In NestJS, DTO classes exist only to feed OpenAPI generation (via class-validator-style metadata from the Zod schema through a small generator in packages/shared/openapi.ts) — runtime validation is the Zod pipe (@ZodPipe(CreatePageSchema)). No hand-written @IsString() parallel worlds. |
| V4 | JSONB payload shapes (block content/properties, view filter/sort/group, formula ast, property config, automation config) are all Zod schemas in shared — the DB stores what the schema accepts; the schema is the migration for JSONB. |
| V5 | Validation is total: schemas define the full shape (.strict() on external input: API bodies, webhooks, imports — unknown keys rejected at the boundary, stripped with .passthrough() only for forward-compat fields we own). |
| V6 | Errors: the Zod pipe converts issues → ApiError(VALIDATION_ERROR, …, details.fields) in one place; no per-endpoint issue mapping. |
| V7 | Client-side forms (React Hook Form) use zodResolver on the same shared schema — client and server can never disagree. |
6. Logging conventions
Transport: pino (api, worker, realtime; web logs via console → browser only, never to a backend). Format: structured JSON, one object per line.
Required fields on every log line (pino base):
{ "ts": 1736940000123, "level": 30, "msg": "page created",
"service": "api", "request_id": "req_01J…", "workspace_id": "ws_…",
"user_id": "usr_…" }
| # | Rule |
|---|---|
| L1 | Levels: fatal (process dying, unhandled rejection) · error (operation failed, user-visible impact, 5xx) · warn (degraded: retry, rate-limit hit, search fallback, validation-strict reject) · info (domain events: login, page created, file READY, automation run, webhook delivered) · debug (dev only: query shapes, WS frames, decision traces — off in prod by default). |
| L2 | Correlation ids: every HTTP request gets request_id (from x-request-id or generated); it propagates: request → service spans → repository queries → published events (payload requestId) → queue jobs (job customData) → webhook deliveries. Realtime: doc_id + conn_id instead of request_id. Any log you can't correlate to a request/event is a bug. |
| L3 | No secrets, no PII (NFR-P2): never log tokens, passwords, API keys, webhook secrets, full emails (log email_domain + hash suffix at most), names, or file contents. A pino redact list is configured centrally (paths: ["req.headers.authorization", "*.password", "*.token"]) and the log pipeline runs the PII scrubber as defense in depth. |
| L4 | Log decisions, not data dumps: msg: "permission denied", resource: "page:pg_…", required: "EDIT", granted: "VIEW" — not the whole grant table. |
| L5 | One msg is a short static string (grep-friendly, no interpolation in msg — data goes into fields): logger.info({ pageId, blocks: n }, "page created"). |
| L6 | Audit-sensitive operations log and write an AuditLog row (audit ≠ log: audit is a queryable, retained, actor-attributed record — see ../security/security-architecture.md §12). A feature can have both. |
| L7 | Errors are logged with the error field set to the Error object (pino serializes the stack) — and error is reserved for actual errors (a warn with an error field is a contradiction CI lint flags). |
7. Observability (spans)
OpenTelemetry spans are manual where the auto-instrumentation can't name things well. Contract (NFR §7.1):
| Surface | Span naming | Attributes (minimum) |
|---|---|---|
| HTTP handler | auto (GET /api/v1/pages/:id) |
route, code, workspace_id |
| Service domain op | service.<domain>.<op> e.g. service.page.create |
page_id, block_count, from_trash |
| Repository query | db.<table>.<op> e.g. db.blocks.list_children |
table, op, rows_affected? (set by prisma middleware), query hash |
| Queue job | queue.<name>.<job> e.g. queue.search.index |
queue, job_id, attempt |
| WS operation | ws.<op> e.g. ws.doc.update |
doc_id, room, bytes |
| External call | ext.<vendor>.<op> e.g. ext.s3.put, ext.opensearch.search, ext.ai.chat |
vendor, op, model?, tokens_in/out?, status |
Rules: spans inherit the trace context from the request/event (context
propagation across the queue: event payloads carry traceparent); record
exceptions via span.recordException + span.setStatus(ERROR); never put
secrets/PII in span attributes (same redact list as logs); sampling per
NFR §7.1 (100 % errors, 10 % rest).
8. Commit & PR standards
Commits (Conventional Commits, scope = package or app or infra/prisma):
type(scope): imperative summary ≤ 72 chars
[optional body: what + why, ≤ 5 lines, references TASK-xxxx / PR-xxx / NFR-x]
[optional footer: Fixes #123 · BREAKING CHANGE: …]
Types: feat, fix, test, docs, refactor, perf, chore, build, ci, security.
One logical change per commit; "wip", "fix", "stuff" are not messages.
PRs (template enforced):
- Task link (
docs/tasks/phase-XX-….md+ the task id) — a PR without a task link is not reviewed (phase discipline, AGENTS.md). - What & why: 2–5 sentences; for UI, before/after screenshots or a screen-recording link.
- Migration note (if schema touched): the migration name + expand/ migrate/contract step + backward-compat statement.
- Checklist (all required): tests written (which layer, which files) ·
pnpm typecheck && pnpm lint && pnpm test:unitgreen locally · docs updated (which of product/ux/security/database/api) · DoD items from the task file ticked · no new dependencies (or justification in §10). - Reviewers: ≥ 1; PRs touching
permissions,auth,sharedschemas, or migrations require ≥ 2 (one must be the domain owner of the package). - CI green on all 8 stages (testing.md §8) before merge; squash-merge into
develop(the squash commit re-states the conventional-commit subject).
Sizing: a PR diff > 800 lines (excluding generated/migration files) is split. Generated files (prisma client, openapi.json, lockfile) are reviewed by the CI contract checks, not by humans.
9. Dependency policy
| # | Rule |
|---|---|
| D1 | Justification required for any new dependency: the PR body must state (a) the need, (b) why an existing dependency (or stdlib) can't do it, (c) the maintained-alternatives considered. AGENTS.md rule 17 — unexplained deps are rejected. |
| D2 | Major-version bumps are treated as new dependencies: renovate/dependabot opens a PR with the changelog excerpt; a human runs the affected suites; the CHANGELOG.md entry is required. |
| D3 | Pinning: exact versions in package.json are allowed for direct deps of packages/*; ^ is fine for app devDeps. The lockfile is the source of truth — pnpm install --frozen-lockfile in CI. |
| D4 | Runtime footprint: prefer zero-runtime deps for packages/shared (it's imported by everything, including the browser bundle). Anything added to shared must be < 1 kB gz or justified. |
| D5 | Security SLA: critical CVE patched ≤ 72 h (auto-PR + expedited review), high ≤ 30 d; an unpatched critical blocks release (SCA gate). |
| D6 | No dependencies in packages/config; tooling deps are devDeps at the root where possible (hoisted, cached). |
| D7 | Native modules (node-gyp) are avoided; if required, the CI matrix must build them (the "clean clone works" test, NFR-H1). |
10. Security coding rules
The design lives in ../security/security-architecture.md; this section is the per-file contract:
| # | Rule |
|---|---|
| S1 | No secrets in code, config, tests, logs, or URLs — env/secret store only (AGENTS.md rule 8). Test secrets: dotenv from .env.test (git-ignored) or inline fixtures clearly marked FIXTURE — not a real secret for hashed/derived values (e.g. a known bcrypt hash of Factory!2345 is fine; a live API key is not). |
| S2 | No dynamic code execution: eval, new Function, vm.runIn*, setTimeout(string) are banned repo-wide by lint (zero-exception). The formula engine is the sanctioned AST evaluator. |
| S3 | Parameterized queries only: Prisma (always parameterized) or, in the rare raw case, prisma.$queryRaw with tagged template + parameters — string concatenation into SQL fails lint (no-restricted-syntax on template literals containing `SELECT |
| S4 | Trust boundaries: untrusted input = API bodies, query params, URLs, filenames, file contents, WS frames, webhook payloads, import files, AI output, email addresses in comments. Everything else (DB, own cookies validated server-side) is trusted. Sanitize at the boundary: Zod validation on entry; block-content sanitization (DOMPurify-equivalent allow-list per block type) before render; markdown/HTML import sanitization (see security doc §8); output encoding is the renderer's job (React escapes by default — never dangerouslySetInnerHTML with un-sanitized data; the only sanctioned uses: search snippets (tags stripped), rendered markdown (sanitized pipeline), KaTeX output). |
| S5 | Authorization before access: every service method that touches tenant data starts with the permission check (or is documented as public-by-design: health, share-token routes, public forms — those validate their own token/scope). "The controller already checked" is not a reason to skip the service check (defense in depth; NFR-SE2). |
| S6 | Ids, never names, for references across resources (pageId, not title); slugs are for humans and are validated/normalized (slugify in shared) before use. |
| S7 | Rate limiting is per (user, endpoint-class) via the Redis sliding-window guard; new state-changing endpoints declare their tier in the guard config (default tier applies if undeclared — undeclared ≠ unlimited, undeclared = standard). |
| S8 | Cookies: session/refresh cookies are set only by the auth module with HttpOnly; Secure; SameSite per the security doc; no other code touches cookies. |
| S9 | CORS: single allow-list (WEB_URL + API docs origin) configured once in bootstrap; no per-route CORS. |
| S10 | Uploads: size + mime checks at two layers (API metadata, then pipeline magic-byte sniff); filenames are stored but the object key is server-generated (<wsId>/<fileId>/<v>); user-supplied filenames are display-only (and sanitized for log use). |
| S11 | Webhook/external URLs: validated (https: required in prod, hostname allow-deny, private-IP/SSRF block — see security doc §10.4) before the first request, and re-validated on DNS resolution at dispatch time. |
11. Documentation rules
| # | Rule |
|---|---|
| DOC1 | Per feature (AGENTS.md DoD "Documentation"): update the task file's status section; if the feature changes user-visible behavior → the relevant docs/product/* section (or a new one with PR ids); if API → docs/api/ (generated spec + endpoint notes); if schema → ../database/database-architecture.md field tables; if security-sensitive → the security doc; if UI → design-system/ux doc. A PR that changes behavior and touches no doc is rejected. |
| DOC2 | Code comments: explain why (invariants, tricky algorithms — the fractional-position cap, the refresh-replay rule, the deny-wins permission walk), never what (the code says that). No commented-out code (git has it). No TODO without a task id: // TASK-0214: … (lint allows only TODO(TASK-\d+)). |
| DOC3 | Public API surface (packages): every exported symbol gets a one-line TSDoc. Engine invariants get a block comment at the module head (e.g. the formula evaluator's budget + purity contract). |
| DOC4 | Migrations: the migration file's header comment (or the task file) states what changed and why; long-running migrations carry a -- long-running: <estimate> marker. |
| DOC5 | CHANGELOG.md: auto-seeded from conventional commits at release; manual entries for user-facing notes ("Board cards now show 3 properties by default") go in under the tag. |
| DOC6 | Conflicts: when two docs disagree, ARCHITECTURE.md wins (AGENTS.md), and the conflict is recorded in the task file with a link — never silently resolved (rule 29). |