Phase 0 — Foundation: Task Specifications
Purpose: Bring the scaffold to the point where a clean clone can
pnpm install, spin up the dev stack (postgres, redis, minio, opensearch), apply the first Prisma migration + seed, boot all four apps, and pass a green CI pipeline. No feature work. Tasks run strictly in order, TASK-0001 → TASK-0012. Phase exit criteria (verbatim from ROADMAP.md):pnpm install && pnpm docker:up && pnpm db:migrate && pnpm devworks from a clean clone; CI is green; all apps/packages build and typecheck.
Task Table
| ID | Title | Est. |
|---|---|---|
| TASK-0001 | Monorepo init (pnpm workspaces + Turborepo) | S |
| TASK-0002 | TypeScript strict setup (packages/config base tsconfig) | S |
| TASK-0003 | ESLint + Prettier (root + per-app overrides) | M |
| TASK-0004 | Docker compose dev environment (infrastructure/docker) | M |
| TASK-0005 | Env validation (zod env schemas + .env.example) | M |
| TASK-0006 | Prisma init migration + seed | M |
| TASK-0007 | CI pipeline (GitHub Actions ci.yml + release.yml) | M |
| TASK-0008 | Testing bootstrap (Vitest, Playwright, Testcontainers, k6) | M |
| TASK-0009 | Health endpoints for all 4 apps | S |
| TASK-0010 | Observability baseline (pino, requestId, OTEL stub off) | M |
| TASK-0011 | Seed data verification | S |
| TASK-0012 | Clean-clone verification runbook test | M |
Phase Completion Checklist
- [ ] Every task above meets its Definition of Done
- [ ] Exit:
pnpm install && pnpm docker:up && pnpm db:migrate && pnpm devfrom a clean clone - [ ] Exit: CI green on the Phase 0 branch
- [ ] Exit: all apps/packages build and typecheck
TASK-0001
Title
Monorepo initialization (pnpm workspaces + Turborepo)
Objective
Make the repo one scriptable pnpm workspace with a Turborepo pipeline so root commands (pnpm dev|build|lint|typecheck|test|docker:*|db:*) work everywhere and are cached.
Background
Scaffold already has pnpm-workspace.yaml, turbo.json, root package.json, apps/{web, api,worker, realtime} and 10 packages/*; verify/complete the wiring, do not create new architecture.
Requirements
pnpm-workspace.yamllists exactlyapps/*andpackages/*;packageManagerpinned; engines node>=20 <23, pnpm>=9;.npmrc engine-strict.- Root scripts:
dev, build, lint, lint:fix, format, format:check, typecheck, test, test:integration, test:e2e, test:perf, docker:up, docker:down, docker:logs, db:migrate, db:deploy, db:seed, db:verify, db:studio, check(delegate to turbo / pnpm -r / docker compose / prisma). turbo.json:dev(persistent, uncached),build(dependsOn ^build, outputsdist/**),lint,typecheck,test(dependsOn ^build),test:integration,test:e2e; global envNODE_ENV, PORT, DATABASE_URL, REDIS_URL, LOG_LEVEL.- Git baseline (AGENTS.md):
main+develop; husky pre-commit (eslint --fix + prettier --write on staged, typecheck on changed workspace) + conventional-commit check. .gitignore: node_modules, dist, .next, .turbo, coverage, .env, test-results, playwright-report;.env.exampletracked,.envnever committed.
Acceptance Criteria
- Fresh clone:
pnpm install && pnpm buildsucceeds in every workspace. pnpm typecheckfails root-level on any workspace type error; passes when clean (both directions verified).- Second
pnpm buildshows Turborepocache hitfor unchanged workspaces. docker:up,db:migrate,db:seed,devexist and wire to TASK-0004/0006 commands.- A commit containing a lint error is blocked by the pre-commit hook.
Backend Changes
- Root
package.json,turbo.json,pnpm-workspace.yaml,.npmrc; script normalization (build, lint,typecheck, test,dev) inapps/api|worker|realtime/package.json.
Frontend Changes
apps/web/package.jsonscript normalization (Next.js 14:dev, build,lint, typecheck,test).
Database Changes
None
API Changes
None
Tests
tests/scripts/smoke.test.ts(Vitest): every root script exists; each workspace declaresbuild|lint|typecheck|test; cache-hit recorded in commit body.
Security
git check-ignore .envpasses; only non-sensitive placeholders introduced.
Documentation
docs/development/development-guide.mdstub: "Monorepo layout" + port table (web 3000, api 4000, worker 4003, realtime 4001);CONTRIBUTING.mdscript table.
Dependencies
None (first task of the phase).
Definition of Done
All criteria verified on a clean clone; pre-commit hook demonstrated; pnpm check green.
TASK-0002
Title
TypeScript strict setup (packages/config base tsconfig)
Objective
One shared strict tsconfig extended by every app and package, so pnpm typecheck is a single trustworthy gate with identical strictness.
Background
packages/config is the designated shared-config home and is currently empty; workspaces use ad-hoc or missing tsconfigs. Centralize without breaking existing sources.
Requirements
packages/config/tsconfig.base.json:strict, noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch, forceConsistentCasingInFileNamestrue;target ES2022, lib [ES2022], module ESNext, moduleResolution Bundler, skipLibCheck, resolveJsonModule, declaration, sourceMap, isolatedModules.- Every workspace
tsconfig.jsonextends@factory/config/tsconfig.base.json, overriding onlyinclude,outDir,jsx(web),types. packages/config/src/index.tsexports the base tsconfig typed +tsconfigFor("node"|"web"); package exports map exposes the JSON file.apps/webadds.tsx+jsx: "preserve"+ Next.js generated types; api/worker/realtime addtypes: ["node"](NestJS moduleResolution choice documented).- Root
pnpm typecheck=tsc --noEmitin every workspace via turbo.
Acceptance Criteria
pnpm typecheckpasses in every workspace with no source changes beyond tsconfigs.- Deliberate
const x: number = "s"in any workspace fails with the workspace name visible (verified, reverted). - No workspace tsconfig disables
strict(enforced by config test). - Unguarded indexed array access fails typecheck (
noUncheckedIndexedAccessactive).
Backend Changes
packages/config/{tsconfig.base.json, src/index.ts, package.json};apps/api|worker|realtime/tsconfig.json.
Frontend Changes
apps/web/tsconfig.json(extends base,jsx: "preserve",.tsxinclude).
Database Changes
None
API Changes
None
Tests
packages/config/tsconfig.test.ts: loads every repo tsconfig, asserts it extends the base and never setsstrict: false.
Security
None
Documentation
docs/architecture/monorepo.md(new, short): tsconfig strategy + NestJS override rationale.
Dependencies
- TASK-0001 (workspace scripts).
Definition of Done
Base tsconfig shipped; all workspaces extend it; typecheck green; config test passing.
TASK-0003
Title
ESLint + Prettier (root + per-app overrides)
Objective
One flat ESLint config with per-app overrides + one root Prettier config, so pnpm lint / pnpm format:check enforce style and catch real bugs monorepo-wide.
Background
No lint config exists. Next.js' own setup would duplicate rules; the root owns the config and apps/web only contributes the next/core-web-vitals override.
Requirements
- Root
eslint.config.mjs(flat):typescript-eslintrecommended-type-checked for**/*.ts;react+react-hooksforapps/web/**/*.{ts, tsx};no-explicit-anywarn globally, error inpackages/permissions|shared|auth;no-eval: erroreverywhere (formula-safety invariant). apps/weboverride:next/core-web-vitals; ignore.next/,next-env.d.ts.apps/api+apps/workeroverride: no Next rules;no-consolewarn (apps use the pino logger from TASK-0010).- Root
.prettierrc.json(singleQuote, semi, trailingComma all, printWidth 100) +.prettierignorefor generated dirs. - Root scripts
lint, lint:fix, format, format:check; eslint cache git-ignored; lint on the scaffold tree < 60 s.
Acceptance Criteria
pnpm lintpasses on the scaffold with zero errors after this task.- A
console.loginapps/api/src(warn) and a: anycast inpackages/shared/src(error) are both flagged; reverted afterwards. pnpm format:checkfails on an unformatted file;pnpm formatfixes it.- No rule is defined in two places (single flat config).
Backend Changes
- Root
eslint.config.mjs,.prettierrc.json,.prettierignore; devDeps (typescript-eslint) in api/worker/realtimepackage.json.
Frontend Changes
apps/web/package.jsondevDeps (eslint-plugin-next,eslint-plugin-react-hooks); no app-local eslint config files.
Database Changes
None
API Changes
None
Tests
- Criteria 2–3 (manual, recorded in commit body);
tests/scripts/lint-config.test.ts: config parses, noignorePatternshide real source dirs.
Security
no-eval: errormonorepo-wide enforces the no-arbitrary-JS invariant (AGENTS.md).
Documentation
docs/development/development-guide.mdstub: "Linting & formatting" section.
Dependencies
- TASK-0001, TASK-0002 (type-checked rules need the base tsconfig).
Definition of Done
Single config + Prettier in place; lint/format commands pass; demo violations caught.
TASK-0004
Title
Docker compose dev environment (infrastructure/docker)
Objective
Implement/verify the dev infrastructure per infrastructure/docker/docker-compose.yml: postgres, redis, minio, opensearch (default profile) + Dockerfiles for the 4 apps (apps profile), so pnpm docker:up yields a healthy, port-stable environment.
Background
infrastructure/docker/ is an empty directory; this task creates the compose file (source of truth for dev infra) and the four dev Dockerfiles. Ports/credentials interpolate from the root .env (TASK-0005). k8s/terraform stay out of scope.
Requirements
infrastructure/docker/docker-compose.yml, default profile —postgres:postgres:16-alpine, db/user/pass from env, port${POSTGRES_PORT:-5432}, volumepgdata, healthcheckpg_isready—redis:redis:7-alpine,${REDIS_PORT:-6379}, volume, healthcheckredis-cli ping—minio: pinned tag, S3${MINIO_PORT:-9000}+ console${MINIO_CONSOLE_PORT:-9001}, volume, healthcheck/minio/health/live, plusminio-initsidecar (mcalias +mb -pbucketsfactory-files,factory-avatars) —opensearch:opensearchproject/opensearch:2.17.1single-node (security plugin disabled, dev),${OPENSEARCH_PORT:-9200}, volume, healthcheck_cluster/health.appsprofile:web,api,worker,realtimefromapps/<app>/Dockerfile— multi-stage dev images (node:20-alpine + corepack pnpm, source volume-mounted,pnpm dev, watch). Ports: web 3000, api 4000, worker 4003, realtime 4001 (WS on same port).- Named volume per stateful service; one network
factory-dev; no barelatestfor postgres/redis/opensearch; minio tag pinned with "pin weekly" comment. - All credentials/ports via env interpolation with dev-only defaults; nothing in the file is production-correct.
docker compose configvalidates;pnpm docker:up= default profileup -d;pnpm docker:up:appsadds--profile apps.
Acceptance Criteria
pnpm docker:upon a clean machine brings all 4 dependency services tohealthyin < 120 s (docker compose ps).psql,redis-cli,mc, and OpenSearch_cluster/healthall succeed using only.env.examplevalues.pnpm docker:up:appsboots all 4 apps;curl http://localhost:4000responds.pnpm docker:downleaves no orphaned containers/networks; volumes survivedown, removed bydown -v(documented).- Changing
POSTGRES_PORTin.envchanges the published port without editing compose.
Backend Changes
infrastructure/docker/docker-compose.yml,infrastructure/docker/.env.example(docker subset);apps/api|worker|realtime/Dockerfile.
Frontend Changes
apps/web/Dockerfile(dev multi-stage,next devwith watch).
Database Changes
None (reachability only; migration is TASK-0006).
API Changes
None
Tests
tests/scripts/docker-compose.test.ts:docker compose config --quietvalidates; asserts service set, healthchecks, pinned tags; up/down cycle timing recorded.
Security
- Dev-only creds marked "never reuse in staging/prod"; redis
--maxmemory 256mb; minio console localhost-only (documented).
Documentation
docs/development/development-guide.mdstub: "Docker dev environment" — service/port table, exec examples, profiles, volume behavior, troubleshooting.
Dependencies
- TASK-0001 (docker scripts).
Definition of Done
Compose + 4 Dockerfiles done; all 5 criteria verified on a clean machine; config test green.
TASK-0005
Title
Environment validation (zod env schemas + .env.example)
Objective
Every app fails fast with a readable error on missing/malformed env; .env.example is the single source of truth; zod schemas in packages/config are the single validator.
Background
Apps currently read process.env ad hoc. AGENTS.md requires all secrets from env variables documented in .env.example. packages/config (TASK-0002) hosts the schemas.
Requirements
packages/config/src/env.ts(zod):appEnvSchema(NODE_ENV, APP_NAME, LOG_LEVELdefaultinfo,PORT);postgresEnvSchema(DATABASE_URLprefixpostgresql://);redisEnvSchema(REDIS_URLprefixredis://);storageEnvSchema(S3_ENDPOINT, S3_REGION, S3_BUCKET_FILES, S3_BUCKET_AVATARS, S3_ACCESS_KEY, S3_SECRET_KEY, optionalS3_PUBLIC_BASE_URL);apiEnvSchema(addsJWT_ACCESS_SECRETmin 32,JWT_ACCESS_TTL_SECONDS900,REFRESH_TTL_DAYS30,BCRYPT_ROUNDS12 (10–15),CORS_ORIGINS, optional SMTP vars,SMTP_FROM,FRONTEND_URL);workerEnvSchema;realtimeEnvSchema(+WS_ALLOWED_ORIGINS);webEnvSchema(NEXT_PUBLIC_API_URL, NEXT_PUBLIC_REALTIME_URL); helperloadEnv(schema, env, context)prints{key, expected, received}and exits 64.apps/api/src/config/env.tsexports frozen typedenvvialoadEnv(apiEnvSchema, process.env, "api"); equivalents inapps/worker|realtime/src/config/env.ts;apps/web/src/config/env.ts(server-side;NEXT_PUBLIC_*build-time reads = only documented exception).- Root
.env.exampledocuments every variable (grouped, commented, dev defaults matching TASK-0004); a test enforces 100% two-way key coverage vs the schemas. - Lint rule: no
process.envoutsidesrc/configdirs in any app; error output names the app, variable, expected shape, and points at.env.example.
Acceptance Criteria
- api boot with
JWT_ACCESS_SECRETunset exits 64 naming the variable (unit + manual). - Malformed
REDIS_URL(wrong prefix) fails fast in every app that needs it. - Coverage test passes, and fails when a key exists in
.env.examplebut not any schema (and vice versa). - Zero
process.env.hits outside config dirs (grep audit recorded in commit).
Backend Changes
packages/config/src/env.ts+ tests;apps/api|worker|realtime/src/config/env.ts.
Frontend Changes
apps/web/src/config/env.ts.
Database Changes
None
API Changes
None
Tests
packages/config/src/env.test.ts(happy path per app, failure output shape, defaults);packages/config/env-coverage.test.ts;tests/integration/env-fail-fast.test.ts(spawn api with broken env, assert exit 64).
Security
.env.exampleholds no real secrets (each: "openssl rand -hex 32" comment); error output never echoes secret values (tested).
Documentation
.env.exampleis the primary doc; development guide: "Environment configuration" section (generate.env, fail-fast behavior, per-app groups).
Dependencies
- TASK-0002 (package home), TASK-0004 (docker defaults must match).
Definition of Done
Schemas + loadEnv shipped; all 4 apps boot through it; coverage test green; fail-fast demonstrated.
TASK-0006
Title
Prisma init migration + seed
Objective
Generate the first Prisma migration from prisma/schema.prisma and an idempotent seed, so pnpm db:migrate && pnpm db:seed works from a clean clone with known demo data.
Background
The schema already defines the full domain (User, Session, RefreshToken, EmailVerification, PasswordReset, Workspace, WorkspaceMember, Invitation, Team, TeamMember, Page, PageFavorite, PageVersion, Block, Database, DatabaseProperty, DatabaseView, DatabaseRecord, RecordProperty, Comment, Share, Permission, AuditLog, …); no migration exists yet and prisma/seed.ts must be created.
Requirements
pnpm db:migrate=prisma migrate dev --name init→ createsprisma/migrations/<timestamp>_init/+ applies;pnpm db:deploy=migrate deploy(non-interactive, CI path);prisma/package.jsonpostinstall: prisma generate; rootpackage.jsonprisma block (schema,seed: tsx prisma/seed.ts).prisma/seed.ts— idempotent (upserts on natural keys) — creates: 2 users (demo@factory.local/DemoPassw0rd!,admin@factory.local/AdminPassw0rd!, bcrypt 12, VERIFIED); demo workspace "Demo Workspace" (slugdemo, owned by demo user) + personal workspaces for demo & admin (slugspersonal-<4 random>); 2 teams in demo ws ("Product", "Engineering", positionsa0,a1from@factory/shared/positions); 6 pages (root "Getting started" in Product with 2 direct children, 1 under Engineering, 2 root private; positions unique per parent, emoji icons); 1 database "Tasks" (5 properties: title, 2× select, date, people; 3 records with non-empty titles) + 1TABLEview; 1 comment on "Getting started" by admin; 1 favorite (demo → "Getting started").- Seed prints a per-model count summary + demo credentials (non-production only) and refuses
NODE_ENV=production(exit 65).
Acceptance Criteria
- Fresh postgres:
pnpm db:migratecreates exactly one*_initmigration;prisma migrate statusup to date. pnpm db:seedsucceeds; psql confirms: 2 users, 5 workspaces (3 + 2 personal), demo slugdemoowned by demo user, 2 teams, 6 pages, 1 database / 5 properties / 3 records, 1 comment, 1 favorite.- Second
pnpm db:seedleaves all counts identical (idempotent). - Every seeded page position is unique per parent and orders correctly with the shared fractional comparator (checked by the seed summary script).
pnpm db:deployapplies the migration on an empty database non-interactively.
Backend Changes
prisma/migrations/<ts>_init/(generated),prisma/seed.ts,prisma/package.json, rootpackage.jsonprisma block.
Frontend Changes
None
Database Changes
- First migration: full DDL from
prisma/schema.prisma; no hand-edited SQL (index adjustments go inschema.prisma, regenerated).
API Changes
None
Tests
tests/integration/migration.test.ts(Testcontainers postgres):migrate deployon pristine container → 1 migration, key tables exist (users, workspaces, pages, database_records, audit_logs); seed twice → counts stable.
Security
- Seed passwords dev-only, documented in guide +
.env.example; production refusal enforced (exit 65).
Documentation
docs/database/migrations.md(new): migrate/deploy workflow, naming, seed idempotency contract.
Dependencies
- TASK-0004 (postgres), TASK-0005 (DATABASE_URL validated).
Definition of Done
*_init migration applied; seed idempotent and Testcontainers-proven; db:deploy non-interactive; docs written.
TASK-0007
Title
CI pipeline (GitHub Actions ci.yml + release.yml)
Objective
Implement the AGENTS.md CI/CD contract: every PR runs install → lint → typecheck → unit → integration → build → security scan → E2E; tagged releases build images + draft a release. CI green is a Phase 0 exit criterion.
Background
No workflows exist. Integration tests use Testcontainers (TASK-0008); E2E uses the compose dev stack with migrated + seeded database (TASK-0004/0006).
Requirements
.github/workflows/ci.yml(push + PR tomain/develop), one job per stage:install(pnpm frozen lockfile, lockfile cache) →lint→typecheck→unit(pnpm test) →integration(Docker service,pnpm test:integration) →build(pnpm build) →security(pnpm audit --prod --audit-level=highwith allowlistscripts/security/audit-allowlist.json— entries need reason + expiry; gitleaks on diff range) →e2e(compose--profile apps up -d, wait on health endpoints,migrate deploy+db:seed,pnpm test:e2e, upload playwright-report on failure). Concurrency: cancel superseded runs per branch..github/workflows/release.yml(tagv*): tag/version match check,pnpm build, image builds for all 4 apps (tagssha+ tag), push toREGISTRYsecret (skipped gracefully here), draft GitHub release from the tag range.- Pinned action versions/SHAs; secrets only from the secrets store; no
continue-on-erroranywhere; E2E is the only job needing the full stack; integration job < 15 min on default runners.
Acceptance Criteria
- A docs-only branch passes every ci.yml stage (run URL recorded in commit body).
- Deliberate unit failure, type error, lint violation each fail CI at the correct stage (throwaway branch, reverted).
- Security stage fails on a high-severity advisory dep; allowlist entry without expiry is rejected.
v0.0.0-testtag: images build, push skipped, draft release created.- Failing E2E run attaches the Playwright report artifact.
Backend Changes
.github/workflows/{ci.yml, release.yml},scripts/security/audit-allowlist.json(empty + schema comment).
Frontend Changes
None
Database Changes
None (E2E setup uses migrate deploy + db:seed against the compose postgres).
API Changes
None
Tests
- Verification = criteria 1–5 (manual, recorded);
tests/scripts/workflow-lint.test.ts: parses both YAMLs, asserts job order equals the AGENTS.md contract, no inline secret defaults.
Security
- gitleaks + pnpm audit are hard merge gates; release images build from the tagged commit only.
Documentation
docs/development/ci.md(new): stage table, what each gate blocks, local equivalents, allowlist process.
Dependencies
- TASK-0001 (scripts), TASK-0004 (E2E stack), TASK-0006 (migrate+seed), TASK-0008 (test commands), TASK-0009 (health wait).
Definition of Done
Both workflows implemented; green/red demonstrated exactly per contract; E2E artifact on failure; "CI is green" exit criterion met on the branch.
TASK-0008
Title
Testing bootstrap (Vitest, Playwright, Testcontainers, k6)
Objective
Stand up all four test layers, each with a passing smoke spec, so later phases only add specs: Vitest (packages + apps/api), Playwright (tests/e2e), Testcontainers (tests/integration), k6 (tests/performance).
Background
AGENTS.md lists "Vitest (packages) / Jest (api)"; this project standardizes on Vitest for both (one runner/coverage/config format). Per the conflict rule the deviation is recorded here and AGENTS.md updated in Documentation.
Requirements
- Vitest:
vitest.config.tsin all test-bearingpackages/*+apps/api|worker|realtime; rootpnpm test=vitest runacross workspaces;@vitest/coverage-v8thresholds:packages/shared(positions, dto) +packages/permissions≥ 80% lines/branches, other packages ≥ 60%. - Playwright:
tests/e2e/playwright.config.ts— chromium,testIdAttribute: "data-testid",baseURLfromE2E_BASE_URL(defaulthttp://localhost:3000), 1 retry on CI,trace: "on-first-retry",screenshot: "only-on-failure", html + list reporters;globalSetupasserts all 4 health endpoints (TASK-0009) before any test; helperstests/e2e/helpers/api.ts(typed fetch + token storage) andtests/e2e/helpers/db.ts(Prisma client for assertions, e.g. audit rows); smoketests/e2e/smoke.spec.ts(api health 200 + web root rendersdata-testid="app-shell"). - Testcontainers:
tests/integration/helpers/testcontainers.ts—startTestInfra()(postgres 16 + redis 7, shared per file,afterAllteardown),createTestApp()(Nest app fromapps/apiagainst container env,migrate deploy+ seed once per container); smoketests/integration/smoke.test.tsboots the app + calls its health endpoint. - k6:
tests/performance/k6/health-check.js(constant VU on all 4 apps'/api/v1/health+/health/ready; thresholds 5xx < 0.1%, p95 < 200 ms) +tests/performance/README.md(targets defined in Phase 15);pnpm test:perf. - Root scripts
test, test:integration, test:e2e, test:coverage, test:perfdocumented indocs/development/testing.md.
Acceptance Criteria
pnpm testpasses with ≥ 1 test per workspace; a deliberately failing test inpackages/sharedfails the root command (verified, reverted).pnpm test:integrationboots Testcontainers + the api app, smoke passes, containers torn down (no orphans).pnpm test:e2egreen with the dev stack up; with the stack down, globalSetup fails fast naming the unhealthy endpoint.pnpm test:perfcompletes in < 2 min locally.- A package below its coverage threshold makes the command exit non-zero.
Backend Changes
vitest.config.tsin test-bearing packages +apps/api|worker|realtime;tests/integration/helpers/{testcontainers.ts, app.ts},tests/integration/smoke.test.ts;tests/performance/k6/health-check.js,tests/performance/README.md.
Frontend Changes
tests/e2e/{playwright.config.ts, global-setup.ts, helpers/api.ts, helpers/db.ts, smoke.spec.ts};apps/web/src/app/layout.tsx: adddata-testid="app-shell"(only frontend change).
Database Changes
None (harness applies the existing schema via migrate deploy).
API Changes
None (smoke consumes only TASK-0009 health endpoints).
Tests
- The smoke specs are the deliverable; each layer proves itself.
Security
- Testcontainers ephemeral (no fixed host ports); E2E uses only seeded demo data; no production-like secrets in test env.
Documentation
docs/development/testing.md(new): layer table, run instructions, CI mapping, adding-a-test guide;AGENTS.mdTesting section → "Vitest (packages + apps)" (recorded conflict resolution).
Dependencies
- TASK-0001 (scripts), TASK-0004 (E2E stack), TASK-0005 (env), TASK-0006 (harness migrate+seed), TASK-0009 (health in globalSetup).
Definition of Done
All four layers configured + smoke green; thresholds enforced; AGENTS.md updated; all root test commands pass.
TASK-0009
Title
Health endpoints for all 4 apps
Objective
Give every app liveness (/api/v1/health), readiness (/health/ready), and (realtime) /metrics, so compose, CI E2E and the runbook can verify a dependency-aware healthy stack.
Background
Compose healthchecks (TASK-0004) test containers; the apps must additionally expose their own dependency-aware health. api is the reference; worker/realtime/web follow the same contract.
Requirements
- Contract (JSON):
GET /api/v1/health→200 { status: "ok", app, version (package.json), uptimeSeconds, timestamp };GET /health/ready→200 { status: "ready", checks: {dep: "ok"} }or503 { status: "not_ready", checks: {dep: "error"} }naming the failing dep. - Checks: api → postgres (
SELECT 1) + redis (PING); worker → redis + its BullMQ queue; realtime → redis + ws server bound; web → api liveness over HTTP; each check has a 1 s timeout (hung dep → 503 in < 2 s; liveness stays 200). - api:
apps/api/src/modules/health/{health.module.ts, health.controller.ts, dependency-check.service.ts}(checks = independent services so later deps — opensearch, minio — register cleanly); registered inapp.module.ts. - worker: minimal HTTP server (no NestJS) on 4003 (
apps/worker/src/health/server.ts); realtime: both routes on 4001 +GET /metrics(Prometheus text:uptime_seconds, connected_clients, events_processed_total, ws_handshake_failures_total); web:apps/web/src/app/api/v1/health/route.ts+apps/web/src/app/health/ready/route.ts. - Health routes are unauthenticated by design (documented exception to the Phase 1 global guard); they expose no data beyond app name/version.
Acceptance Criteria
- Full stack up: all 4 apps return 200 liveness + 200 readiness, all checks
ok(recorded in the runbook, TASK-0012). docker compose stop postgres→ api readiness 503 namingpostgreswithin 3 s, liveness still 200; restore → 200.docker compose stop redis→ worker + realtime readiness 503 namingredis./metricsparses (prom-client parser test) and contains all 4 metric names.- Readiness with a dead dependency answers < 2 s (measured by the runbook script).
Backend Changes
apps/api/src/modules/health/*(3 files + module registration);apps/worker/src/health/server.ts;apps/realtime/src/health/{metrics.ts, server.ts}.
Frontend Changes
apps/web/src/app/api/v1/health/route.ts,apps/web/src/app/health/ready/route.ts.
Database Changes
None
API Changes
GET /api/v1/health+GET /health/ready(all 4 apps),GET /metrics(realtime) — documented indocs/api/health.md(new).
Tests
apps/api/src/modules/health/health.controller.test.ts(200/503 shapes, dep-name propagation, timeout with fake failing dependency);tests/integration/health.test.ts(kill Testcontainers redis → api readiness 503, postgres stillok).
Security
- No internals leaked (no dependency versions, counts, stack traces); public exception documented.
Documentation
docs/api/health.md(new): contract, per-app dep table, curl examples; development guide: "Checking the stack" curl table.
Dependencies
- TASK-0004 (compose), TASK-0005 (env/ports).
Definition of Done
Endpoints live in all 4 apps; dependency-down 503 demonstrated for postgres + redis; valid Prometheus metrics; docs written.
Recorded notes (conflicts/adjustments, per AGENTS.md rule 29 — recorded at scaffold time, before Phase 0 execution):
- Realtime health port 4002, not 4001. Item 4 names "both routes on 4001";
the gateway binds WebSocket on 4001 and a dedicated health server on 4002
(
HEALTH_PORT, default 4002 — seeapps/realtime/src/config.ts). All three routes exist on 4002:/health,/health/ready,/metrics(all 4 required metric names). The 4001-vs-4002 split is pre-documented in the realtime README/Dockerfile/.env.example; compose exposes both ports. - api readiness canonical path =
GET /api/v1/health/ready. The implementation served readiness at/api/v1/health/deeponly, which contradicted both the item-1 contract and the K8s probes ininfrastructure/kubernetes/api.yaml(already targeting/api/v1/health/ready).health.controller.tsnow servesreadyas canonical and keepsdeepas a deprecated alias. - Worker health server implemented (was headless-only). The worker was
scaffolded with a Redis heartbeat (
worker:heartbeat:<WORKER_ID>) and no port; item 4 requiresapps/worker/src/health/server.tson 4003. Both now exist: heartbeat (deep liveness for the api) + HTTP health server onHEALTH_PORT(default 4003) serving/api/v1/health+/health/ready. Worker Dockerfile nowEXPOSE 4003; compose publishes4003:4003. - Compose app profile renamed
full→apps. The task contract (TASK-0003 requirement 5:pnpm docker:up:apps) and the dev guide name the application profileapps; the implementation had declared itfull. Renamed everywhere (compose +infrastructure/docker/README.md+infrastructure/nginx/README.md) and added the missing root scriptsdocker:up:apps,docker:logs,lint:fix,test:perf,db:verify,check(TASK-0001 requirement 2).db:verifypoints atprisma/verify.ts, which TASK-0011 will create. scripts/verify-clean-clone.sh(TASK-0012) is not yet scaffolded — tracked as the remaining Phase 0 file. Status (final consistency pass): created.scripts/verify-clean-clone.shnow exists (preflight → install → infra → wait deps → migrate → seed → db:verify → apps → health polls → build gates → PASS/FAIL summary;--skip-build+--preflightflags; takes the clone dir as$1; neverdown -v; cleans up only the apps-profile containers it started).prisma/verify.ts(TASK-0011) + the contractedprisma/seed.tsfixture +.github/workflows/clean-clone.yml(the TASK-0012clean-clonejob: develop push + manual dispatch) are also now scaffolded.- Wire-format enum casing conflict (recorded, NOT silently fixed) —
the API is the outlier;
@factory/sharedis the authoritative wire format.docs/architecture/system-architecture.mddesignates@factory/sharedas "the single source of truth for wire format, used by web, api, worker, realtime." Its DTO layer (packages/shared/src/dto/*.ts) — which exists and is complete — uses lowercase/snake_case for roles (owner/admin/member/guest), views (table/board/dashboard), blocks (paragraph), and properties (select/multi_select), matching thedocs/api/*examples (snake_case blocks/properties/views). The web (apps/web/src/lib/types.ts+components/editor/*) is already aligned to this wire format (30 snake_caseBLOCK_TYPES, 24PROPERTY_TYPES, lowercase roles — done during the consistency pass). The API is the lone outlier: its own DTOs (apps/api/src/modules/*/dto.ts) validate inboundz.enum(BLOCK_TYPES)/z.enum(PROPERTY_TYPES)/z.enum(VIEW_TYPES)/z.enum(WORKSPACE_ROLES)using the UPPERCASE constants inapps/api/src/constants.ts, which mirror the Prisma storage enums — so the API both accepts and emits UPPERCASE, contradicting the designated wire format (anddocs/api's role examples, which useOWNER/ADMIN/… — ~18 role literals). This is a storage-vs-wire representation gap, not a value-set gap (the 30/24/9/4/5 sets match; only casing differs). Decision (per rule 29, follow the architecture): the wire format is@factory/shared's lowercase/snake_case. Phase 0 must (a) add a central enum-casing codec in@factory/shared(snake_case wire ⇄ UPPERCASE Prisma storage), (b) route every API DTO through it (inbound parse + outbound serialize), and (c) normalize thedocs/apirole examples to lowercase. The web needs no change. Tracked as Phase 0 execution work — recorded here rather than half-implemented at scaffold time.
TASK-0010
Title
Observability baseline (pino, requestId middleware, OTEL stub off)
Objective
Structured JSON logging via a shared pino factory, request-id propagated through the api request lifecycle, and a disabled-by-default OTel span helper — so Phase 15 can turn observability on without touching call sites.
Background
AGENTS.md requires logging + observability in every DoD. Without a shared logger each app would invent a format. The baseline installs no OTel exporter: spans run through a stub tracer (OTEL_ENABLED default false) — zero runtime cost, wiring ready.
Requirements
packages/shared/src/logger.ts:createLogger(name, options)→ pino; JSON intest/production, pretty indev; level fromLOG_LEVEL; redact pathsreq.headers.authorization, *.password, *.passwordHash, *.token, *.tokenHash, *.refreshToken, *.secret; base fieldsservice, env, version.packages/shared/src/context.ts:AsyncLocalStoragerequest context (requestId;userIdarrives in Phase 1);getContext(); per-request pino child vialogger.with({ requestId }).- api:
apps/api/src/common/middleware/request-id.middleware.ts— accepts incomingX-Request-Idmatching^[A-Za-z0-9._-]{8,64}$elsecrypto.randomUUID(); echoes on response header; stores in async context; registered inmain.ts. Plusapps/api/src/common/filters/http-exception.filter.ts: every error response uses envelope{ code, message, requestId, details? }, logged atwarnwithstatus, url, durationMs. - worker + realtime: loggers at boot; worker logs
jobId+queueper job; realtime logsclientId+roomfields (populated when rooms land, Phase 7);unhandledRejection/uncaughtExceptionhandlers in all 3 node mains: log aterrorwith context, thenprocess.exit(1). packages/shared/src/tracing.ts:traced(name, fn)—OTEL_ENABLED=false(default) = zero-overhead passthrough;trueuses@opentelemetry/api(no exporter installed); api wraps service entry points withtraced(); zeroconsole.*in app source (TASK-0003 rule).
Acceptance Criteria
- Every api log line in a request contains
service+requestId, parseable JSON; two concurrent requests get distinct ids (middleware unit test). - Valid incoming
X-Request-Id: abc-def_123is echoed in response + all logs; an invalid one is replaced by a generated UUID. - A logged payload with a password field never contains the password value (redaction unit test).
OTEL_ENABLED=falseboots cleanly;traced()passthrough verified (called exactly once, no wrapper overhead).- Handled error (404) returns the envelope with
requestId;uncaughtExceptionlogs and exits 1. grep -rn "console\." apps/*/src packages/*/srcreturns zero hits.
Backend Changes
packages/shared/src/{logger.ts, context.ts, tracing.ts}+ unit tests;apps/api/src/common/middleware/request-id.middleware.ts,apps/api/src/common/filters/http-exception.filter.ts,apps/api/src/main.ts;apps/worker/src/main.ts,apps/realtime/src/main.ts(logger + crash handlers).
Frontend Changes
- Client-side logging out of scope (Phase 15); bring violating scaffold code to
no-consolecleanliness.
Database Changes
None
API Changes
- Response header
X-Request-Idon all api responses; error envelope documented indocs/api/errors.md(new).
Tests
packages/shared/src/{logger, context,tracing}.test.ts(redaction, pretty vs JSON, async isolation, passthrough);apps/api/src/common/middleware/request-id.middleware.test.ts(valid/invalid/missing header);tests/integration/observability.test.ts(response carriesX-Request-Id; 404 envelope includes it).
Security
- Redaction paths pinned by tests (new secret fields must join the list); requestIds never encode user data (UUIDv4).
Documentation
docs/development/observability.md(new): log schema field table, requestId flow (ASCII), OTEL stub contract + Phase 15 enablement;docs/api/errors.md(new).
Dependencies
- TASK-0003 (no-console), TASK-0005 (add
OTEL_ENABLEDto schemas +.env.example).
Definition of Done
Shared logger + context + OTEL stub shipped; middleware + exception filter live; crash handlers in all 3 node apps; zero console.*; all tests green; docs written.
TASK-0011
Title
Seed data verification
Objective
Turn the seed (TASK-0006) into a verified fixture: machine-checked invariants on the demo data that later integration/E2E tests rely on, plus a printed verification report.
Background
Demo credentials, slugs and structure become a contracted surface — E2E flows will hardcode demo@factory.local and the demo slug. This task makes any seed refactor that breaks the contract fail the pipeline instead of silently breaking Phase 1+ tests.
Requirements
prisma/seed.tsends withverifySeedData(prisma)(newprisma/verify.ts) asserting: both demo users VERIFIED/ACTIVE with non-nullpasswordHash; slugdemonon-personal, owned by demo user; exactly 2 personal workspaces; every setownerIdhas aWorkspaceMemberrow with roleOWNER; exactly 2 teams in demo ws (unique slugs, valid fractional positions); exactly 6 pages in demo ws (everyparentIdsame-workspace, positions unique per parent validated with the@factory/shared/positionscomparator, "Getting started" has exactly 2 direct children); 1 database "Tasks" (exactly 5 properties: title, 2× select, date, people — order-insensitive; 3 records with non-empty titles) + 1TABLEview; exactly 1 comment on "Getting started" by admin; demo user favorites "Getting started".- Any failed check → table of expected vs actual → non-zero exit; non-production: seed prints the demo credentials block + personal-workspace slugs.
- Standalone
pnpm db:verifyruns the same assertions read-only against the current database (used by the runbook, TASK-0012); personal workspace slugs arepersonal-<4 random chars>and never hardcoded anywhere (E2E resolves them via API; onlydemois fixed).
Acceptance Criteria
- Fresh db:
pnpm db:seedpasses, prints the full report (all checksok) + credentials block (dev mode). - Corrupting one invariant via psql (e.g. delete the "Tasks" database) makes
pnpm db:verifyexit non-zero, naming the check with expected vs actual. NODE_ENV=productionseed still refuses (exit 65) — re-verified here.tests/integration/seed-verify.test.ts: seed → verify pass on Testcontainers, plus the position-uniqueness invariant independently re-checked with the shared comparator (the seed's own check is not trusted blindly).- The report lists per-model counts (users, workspaces, teams, pages, databases, records, comments, favorites).
Backend Changes
prisma/verify.ts(new),prisma/seed.ts(wires verify + report), root scriptdb:verify.
Frontend Changes
None
Database Changes
None (read-only verification; no schema change).
API Changes
None
Tests
tests/integration/seed-verify.test.ts: seed → verify; corrupt via SQL → verify fails naming the check; re-seed → verify passes (idempotent repair);prisma/verify.test.ts(Vitest, underpnpm test): comparator-based position checks on in-memory fixtures.
Security
- Credentials block gated on
NODE_ENV !== "production"(mocked-env test); verify uses a read-only client (asserted in test).
Documentation
- Development guide: "Demo data" section (contracted fixture: what exists, slugs, credentials, what E2E relies on,
pnpm db:verify);docs/database/migrations.md: verify contract (seed changes must updateprisma/verify.tsin the same commit).
Dependencies
- TASK-0006 (seed), TASK-0005 (env gate).
Definition of Done
Verify module + report shipped; all 5 criteria pass; pnpm db:verify standalone; docs updated; the fixture is a contracted surface.
TASK-0012
Title
Clean-clone verification runbook test
Objective
Prove the Phase 0 exit criterion end-to-end: a fresh clone on a clean machine runs install → docker up → migrate → seed → dev → health checks with zero manual fixes, and docs/development/development-guide.md documents exactly what the automated check runs.
Background
Everything Phase 0 built is only as good as its reproducibility. This task makes the developer experience an executable, CI-enforced runbook — the final gate before Phase 1.
Requirements
- Finalize
docs/development/development-guide.md(consolidating the stubs from TASK-0001/0003/0004/0005/0009/0010/0011): prerequisites (Node 20, pnpm 9, Docker Engine + Compose v2, 4 GB RAM), the exact clean-clone step sequence, full port table (web 3000, api 4000, worker 4003, realtime 4001, postgres 5432, redis 6379, minio 9000/9001, opensearch 9200), demo credentials (TASK-0011), troubleshooting (port conflicts, volume state, image pulls, fail-fast env errors), and "What works in Phase 0" (health endpoints + dev stack only — no auth yet). scripts/verify-clean-clone.sh(set -euo pipefail,set -x): preflight (docker available, ports free) →pnpm install --frozen-lockfile→pnpm docker:up→ wait for 4 dependency healthchecks (≤ 120 s) →pnpm db:migrate→pnpm db:seed→pnpm db:verify→pnpm docker:up:apps→ poll all 4 apps'/api/v1/health+/health/ready(≤ 120 s; readiness 200 with all checksok) →pnpm typecheck→pnpm test→pnpm lint→pnpm test:perf→ PASS/FAIL summary; non-zero exit on any failure;--skip-buildflag for CI speed.- The script accepts the clone dir as
$1(default.) so CI can run it against a freshgit clonewithout touching the checkout. - CI:
clean-clonejob (pushes todevelop+ manual dispatch) checks out to a scratch dir and runs the script; failure blocks Phase 0 sign-off (not the PR pipeline). - The script never mutates git state, never runs
down -v(re-runs safe against existing volumes), and cleans up theapps-profile containers it started.
Acceptance Criteria
- Clean machine + fresh
git clone+bash scripts/verify-clean-clone.sh <dir>completes with the PASS summary in < 15 min; every summary step corresponds 1:1 to a numbered step in the development guide. - Deliberately breaking one step (e.g. bad
REDIS_URLin.env) stops the script at that step with the app's fail-fast error (not a generic crash), non-zero exit, and the failed runbook step printed. - Running the script twice in a row passes (volume idempotency: second run reuses volumes,
db:verifygreen). - The
clean-cloneCI job passes on the Phase 0 branch (run URL recorded in commit). - After the script finishes, no leftover
apps-profile containers from its own run.
Backend Changes
scripts/verify-clean-clone.sh(executable, shellcheck-clean).
Frontend Changes
None
Database Changes
None (the script runs existing migrate/seed/verify against the compose postgres).
API Changes
None (consumes only TASK-0009 endpoints).
Tests
- The script is the test (runbook-as-code);
tests/scripts/clean-clone.test.ts: shellcheck (or node parser) the script + assert the step order matches the documented sequence; run the script's--preflightmode to verify port-check logic.
Security
- Reads no secrets beyond the local
.env; prints no env values (key names only); shellcheck +set -euo pipefailenforced.
Documentation
docs/development/development-guide.mdfinalized (the script references its step numbers);README.mdtop-level "Quick start" (5 lines max, links to the guide).
Dependencies
- TASK-0001, 0004, 0005, 0006, 0007 (CI), 0008 (test commands), 0009 (health), 0011 (db:verify).
Definition of Done
Guide finalized; script passes end-to-end on a clean machine twice; CI clean-clone job green; docs + README updated. Phase 0 exit criteria fully met — Phase 1 may start.