diff --git a/CLAUDE.md b/CLAUDE.md index feb833d..6769079 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,129 +9,52 @@ tree-care industry. TypeScript pnpm + Turborepo monorepo implementing the **spli from [`docs/architecture/ADR-001-boilerplate-architecture.md`](docs/architecture/ADR-001-boilerplate-architecture.md). Read ADR-001 before any structural change — it is the binding architecture contract. -**Every versioned document in this repo is v1.3.** Older revisions (v1.0/v1.1/v1.2) were removed -on 2026-07-26 — if you find a reference to one, it is stale, and the v1.3 file is authoritative. - -Two layers of documents, deliberately not the same thing: - -- **Repo root** — raw generator artifacts, large and unedited: `prd_report_v1.3.{md,json}`, - `arboscus_teamplaner_finale_prd_de_v1.3.md`, `traceability_matrix_v1.3.csv`, - `prd_validation_summary_v1.3.txt`. -- **`docs/`** — curated, human-maintained condensations of the same material. These are what you - read and cite: `docs/prd/CURRENT_PRD_v1.3.md`, `docs/traceability/REQUIREMENT_TO_JIRA_v1.3.csv`, - `docs/validation/PRD_VALIDATION_v1.3.md`. - -Product source of truth is `docs/prd/CURRENT_PRD_v1.3.md`. The root artifacts above are generator -and validation evidence, not a competing authority — where they disagree with the curated PRD, -the PRD wins and the divergence is a defect. Architecture is split across three documents, -none of which supersedes another: [ADR-001](docs/architecture/ADR-001-boilerplate-architecture.md) -(boilerplate — the binding contract), `docs/architecture/ARCHITECTURE_DECISIONS_v1.3.md` -(provider, hosting, retention, pilot) and -[ADR-002](docs/architecture/ADR-002-integration-canonical-repo.md) (EYT-78 — this repo is the -one canonical product repository; "Arborga" is its **former name**, not a second repo, and -"Arboscus Teamplaner" is the product name while `easytree`/`@easytree/*` is the technical -namespace). Work is tracked as Jira `EYT-*` tickets; commits and code comments reference -those IDs. - -**Für den MVP-Scope gilt zusätzlich Confluence PRD v1.4 (Seite 7766017, Stand 28.07.2026).** -Diese Seite ist die **fachliche Produktbaseline** und ersetzt v1.3 in §10 hinsichtlich MVP-Scope -und Phasen: „Die gesamte Planungsökonomie ist nicht mehr pauschal Post-MVP." Konkret führt v1.4 -§7 **interne Kostensätze, Plan-/Ist-Kosten und den Excel-Export unter „Im MVP"**. Weiterhin -**Post-MVP** bleiben Maschinenverleiherlöse, Rechnung/Steuer/Zahlung/Buchung und Payroll — die -Grenze wird also enger gezogen, nicht aufgehoben. Grundlage: PO-Beschluss vom 30.07.2026 -(`CONTRA-S5-001` = `FALSE_POSITIVE_SOURCE_SCOPE_ERROR` / `RESOLVED_NO_SCOPE_CHANGE`) und die enge -Sprint-6-Freigabe vom 18.08.2026 für den bereits implementierten EYT-109-Personalkosten-Snapshot. -v1.3 bleibt für alles Übrige die kanonische Repository-Autorität; v1.4 liegt in Confluence und -nicht in diesem Repository, weshalb diese Notiz existiert. +**Every versioned document here is v1.3**; a reference to v1.0/v1.1/v1.2 is stale. The repo root +holds raw generator artifacts, `docs/` the curated condensations — cite `docs/`: +`docs/prd/CURRENT_PRD_v1.3.md` (**product source of truth**), +`docs/traceability/REQUIREMENT_TO_JIRA_v1.3.csv`, `docs/validation/PRD_VALIDATION_v1.3.md`. Where +a root artifact disagrees with the PRD, the PRD wins and the divergence is a defect. + +Architecture spans three documents, none superseding another: ADR-001 (binding), +`docs/architecture/ARCHITECTURE_DECISIONS_v1.3.md` (provider, hosting, retention) and +[ADR-002](docs/architecture/ADR-002-integration-canonical-repo.md) (this repo is the one canonical +product repository; "Arborga" is its **former name**, not a second repo). ADR-003 adds the `costs` +module. `docs/architecture/zwei-client-shells.md` records the **implemented** state of the two +client shells (EYT-113). Work is tracked as Jira `EYT-*`. + +**Für den MVP-Scope gilt zusätzlich Confluence PRD v1.4 (Seite 7766017)** als fachliche +Produktbaseline: sie ersetzt v1.3 §10 und führt **interne Kostensätze, Plan-/Ist-Kosten und den +Excel-Export „Im MVP"**; **Post-MVP** bleiben Maschinenverleiherlöse, Rechnung/Steuer/Zahlung und +Payroll. Die Grenze wird enger gezogen, nicht aufgehoben; sonst bleibt v1.3 die kanonische +Repository-Autorität. Design-Baseline für alles Frontend ist Confluence **Basisdesign v2.0** +(Seite 8814623), die Zwei-Client-Architektur Seite 8486960. [`docs/handoff/AGENT_HANDOFF_v1.3.md`](docs/handoff/AGENT_HANDOFF_v1.3.md) holds the agent -guardrails: required working method, prohibited actions, evidence rules, stop conditions and the -human review checkpoints. **Read it before changing code** — the rules below are a summary of the -parts that bite most often, not a replacement. Its mandatory sections are guarded by -`apps/api/test/handoff-guardrails.test.ts`, because they were once removed without anyone -noticing (EYT-89). +guardrails: required working method, prohibited actions, evidence rules, stop conditions, human +review checkpoints. **Read it before changing code** — the rules below summarise what bites most +often, they do not replace it. Its mandatory sections are guarded by +`apps/api/test/handoff-guardrails.test.ts`, because they were once removed unnoticed (EYT-89). ## Where the work is tracked Jira project `EYT`, board 72 — https://dyai2026.atlassian.net/jira/software/c/projects/EYT/boards/72 -Reach it with the `claude_ai_Atlassian` MCP tools (`searchJiraIssuesUsingJql`, `getJiraIssue`, -`transitionJiraIssue`). The `/jira-automation` skill documents Rube MCP, which is **not** -connected here — use the native Atlassian tools instead. Do not trust the search `count` mode; -it has reported "8 not done" for a sprint in which all 8 were done. Query with `issues` mode -and read the statuses. - -### Snapshot — 28.07.2026 (verify before relying on it) - -Everything in this subsection is a point-in-time record and rots. Re-measure with -`gh pr view `, `git rev-list --left-right --count origin/master...HEAD` and a Jira query -before acting on it. Do **not** use local `master` as the reference — compare against -`origin/master`. - -- **`origin/master` tip is `205fb41`** (merge of PR #24, Sprint-2 retro hardening: EYT-89, - EYT-70, EYT-71, EYT-90). Master has **nine** CI jobs; `read-through` exists only on the - branch below. -- **Sprint 3 merged to master:** EYT-86 (`c1dbf50`, org settings + workforce + audit outbox + - catalogue meta gate), EYT-49 (`acccf3b`, planning invariants — no overlapping published - assignments, published rows immutable), EYT-61 (`2f15770`, temporal edges + property tests + - static no-local-time guard), EYT-50 part 1/2 (`5e07115`, `5f88703`, contract-derived client, - branded idempotency key, tenant query runner). -- **PR #23 — `feat/eyt-50-read-through-slice`, still OPEN and Draft.** Head `a559df2`, - **24 ahead / 0 behind `origin/master`**. Read-through slice: browser → Next rewrite → Nest → - PostgreSQL, proven end-to-end in the `read-through` CI job against a real Supabase stack (ten - jobs green on run `30300223577`, measured at head `3773ecf3` — **four commits have landed - since, so that run no longer covers the current head**). -- **PR #25 was merged into PR #23's branch, not into master** (base `feat/eyt-50-read-through-slice`, - merged 2026-07-28T00:45Z). It opens Sprint 4 as a Plumbline feature (planning draft + - conflict slice) and adds `docs/vision/`, `docs/canvas/`, - `docs/prd/planning-draft-conflict-slice.prd.md` and `docs/traceability.md`. Those last two sit - beside the existing authorities `docs/prd/CURRENT_PRD_v1.3.md` and - `docs/traceability/REQUIREMENT_TO_JIRA_v1.3.csv` — **which one wins is not yet decided; ask - before citing either as source of truth.** -- **Commit `a559df2` ("WE") committed the working tree wholesale.** It tracked `.claude-flow/`, - `.claude/homunculus/observations.jsonl` (~3000 lines of agent state), the whole of `docs/ux/`, - the PDF, and the four root duplicate documents that the Conventions section below still - describes as untracked. That section is therefore stale on that one point, and the duplicate-file - warning matters more now, not less: the root copies are tracked but still not authoritative. -- **Open admin step (needs repository admin, cannot be done from here):** - `DRY_RUN=true ./scripts/setup-branch-protection.sh`, then apply, then - `./scripts/verify-branch-protection.sh` must show `enforcement=active`, - `strict_required_status_checks_policy=true` and `read-through` among the required checks. - Until that read-after-write exists, **do not claim `read-through` blocks a merge.** Measured - 27.07.2026, the verifier printed `FAIL AC3 — fehlende Pflichtchecks: read-through`. -- **EYT-91 (Bug, open) blocks closing EYT-50/EYT-62:** `supabase/seed.sql` uses IDs that - `IdSchema` rejects as invalid UUID v4, so contract response validation returns 500 on seeded - data. The e2e harness works around it with its own v4 fixtures - (`apps/web/e2e/harness/seed.sql`) — that is a workaround, not the fix. -- **Sprint 4 is the open sprint** (measured against Jira 28.07.2026, `sprint in openSprints()`), - and it holds exactly six issues: EYT-50 _In Arbeit_ (Story, vertical slice up to draft + - conflict check), EYT-62 _In Arbeit_ (E2E proof), EYT-79 _In Arbeit_ (PlanningGateway), - EYT-88 _Zu erledigen_ (Bug), EYT-91 _Zu erledigen_ (Bug), EYT-92 _Zu erledigen_ (Story, - clickable week view). EYT-75/87/72/81/11 are **not** in the open sprint. -- **Correction (03.08.2026, measured on `origin/master` + EYT-107):** an earlier revision said - "eight of the nine contract operations are still unimplemented". That count is stale. Measured - now, `NOT_YET_IMPLEMENTED` holds **five** entries, all under `/einsatz/`, **four** of them - waiting on the subject model from EYT-14 — `GET /einsatz/plan` gives its own reason (the - employee read view, without which AK9 cannot be evidenced) and does not name EYT-14. - Implemented are the planning window read, the draft validation, the - assignment write and, since EYT-107, `POST /planung/versionen` (publish). - **Correction (14.08.2026, measured on `feat/eyt-109-daily-plan-cost-snapshot`):** the contract - now carries **19** operations, not nine. `NOT_YET_IMPLEMENTED` still holds exactly the same - **five**, all under `/einsatz/`; the other **14** have routes — three under `/auth/`, four - under `/planung/` and **seven under `/kosten/`** (`GET /kosten/mitarbeiter`, - `/kosten/planversionen`, `/kosten/planversionen/{planVersionId}/baustellen`, - `/kosten/snapshots/{snapshotId}`, `/kosten/stundensaetze/{employeeId}`, `POST /kosten/snapshots`, - `POST /kosten/stundensaetze`). Count them from the artifact, never from this file. The list is - not documentation — it is the - `NOT_YET_IMPLEMENTED` map inside `apps/api/test/openapi-route-conformance.test.ts`, each entry - carrying its reason, and the test fails both ways: a contract operation with neither a route - nor an entry, **and** an entry for an operation that has since been implemented. Writes and - the draft/conflict path are **EYT-50** (Story); three of the five additionally wait on the - subject model from EYT-14. **Correction (28.07.2026, measured against Jira):** an earlier - revision of this file said "writes and publish are EYT-88". That is false. **EYT-88 is the - Bug "Planungswochen-Schlüssel erlaubt ungültige ISO-Wochen in Vertrag und Datenbank"** — the - `^\d{4}-W\d{2}$` pattern accepts `W00`/`W54`/`W99`, to be fixed by a new forward migration - plus one shared deterministic validator. **EYT-91** is the seed-UUID bug. Publish itself is - explicitly deferred out of Sprint 4 ("Bewusst später" in EYT-50). +Use the `claude_ai_Atlassian` MCP tools; the `/jira-automation` skill documents Rube MCP, which is +**not** connected here. Do not trust the search `count` mode — it once reported "8 not done" for a +sprint in which all 8 were done; query with `issues` mode. Sprint contents, ticket states and +branch/PR positions come from Jira and `git`/`gh`, never from here — likewise the contract +operations and the `NOT_YET_IMPLEMENTED` list, **counted from +`apps/api/test/openapi-route-conformance.test.ts`**, which fails both ways: an operation without +route or entry, and an entry for one since implemented. + +**Establish repository identity before any mutation** (EYT-137, +`docs/evals/agent-repository-identity.eval.json`, guarded by the handoff test): `pwd`, +`git remote -v`, `git rev-parse --show-toplevel`, `git fetch --prune`, +`git rev-parse origin/master`. Wrong repository → stop, no file edit, no branch, no commit. +Compare a branch against **`origin/master`, never local `master`** — local `master` once lay 25 +commits behind and made four evidence findings wrong. Several worktrees of this repo exist side +by side (`git worktree list`); a checkout can sit on a branch that is already merged. +`docs/prd/planning-draft-conflict-slice.prd.md` and `docs/traceability.md` (Plumbline) sit beside +the v1.3 authorities — **which wins is undecided; ask before citing either.** ## Sprint 7 — Binding UI/UX Acceptance Gate (temporary) @@ -150,7 +73,8 @@ can never create human PO acceptance — the highest agent state is `READY_FOR_PO_VISUAL_REVIEW`; golden visual baselines require explicit PO approval (never an automatic `--update-snapshots`); re-read the current Jira/GitHub/Confluence state before acting. Guarded by -`apps/api/test/sprint7-acceptance-rule-guardrails.test.ts`. +`apps/api/test/sprint7-acceptance-rule-guardrails.test.ts`. `.claude/rules/` is therefore +**tracked**; only `.claude/settings.local.json` is ignored. ## Commands @@ -158,7 +82,6 @@ Node 22 (`.nvmrc`), pnpm 10.28.0 via corepack. **pnpm is the only permitted pack ```bash pnpm install --frozen-lockfile # deterministic install (what CI does) - pnpm format # prettier --check . (pnpm format:fix writes) pnpm lint # turbo run lint pnpm typecheck # turbo run typecheck (depends on ^build) @@ -167,306 +90,271 @@ pnpm build # turbo run build ``` **⚠️ Four of those five run through Turbo, and a Turbo replay is not a run.** `pnpm test` can -print `Cached: 10 cached, 10 total >>> FULL TURBO`, show a complete green summary — "1061 -passed" — and have executed **not one package** (measured 08.08.2026). It bites hardest right -after a counter-mutation: reverting the file restores the pre-mutation cache key, so the very -run that is supposed to prove the test went red replays the green one. **Any number that is -meant to serve as evidence needs `pnpm exec turbo run --force`** (or the direct package -call), **and a look at the `Cached:` line** — `0 cached` is the only value that proves the work -happened. Two further traps in the same family: the cache is shared across git worktrees, so a -"cache hit" can be a replay from a worktree where the task never ran at all; and a `vitest` path -filter that matches nothing exits 0 without having checked anything. - -`pnpm build` at the repository root **used to fail and no longer does** (EYT-126). The old -reason was real: `apps/web` resolved `EASYTREE_API_PROXY_TARGET` inside `next.config.ts` -`rewrites()` at build time, Turbo's strict env mode stripped the variable, and the build -refused. Since the proxy moved into Route Handlers the build does not read that variable at -all. Measured 22.08.2026 with the variable explicitly unset: -`env -u EASYTREE_API_PROXY_TARGET pnpm build` → exit 0, `Tasks: 6 successful, 6 total`, -`Cached: 0 cached, 6 total` (so it really ran, it was not a replay). These two are green as -well, and neither needs the variable any more: +print `Cached: 10 cached, 10 total >>> FULL TURBO`, show a green summary, and have executed **not +one package** — worst right after a counter-mutation, where reverting the file restores the +pre-mutation cache key. **Any number meant as evidence needs `--force` plus a look at the +`Cached:` line.** The cache is shared across worktrees too, so a +"cache hit" can be a replay from a worktree where the task never ran. And a `vitest` path filter +that matches nothing **exits 0 without having checked anything** — read the printed file count, +never the exit code. Two Turbo settings you will trip over: the `test` task lists `CLAUDE.md`, +`.claude/rules/**`, `docs/handoff/**` and `docs/evals/**` as **inputs**, because three guard suites +read those files — editing them invalidates the test cache on purpose. And Turbo runs tasks in +**strict env mode**: `EASYTREE_TENANT_TESTS`/`EASYTREE_TEST_*` never reach a task through +`pnpm test`, so the integration suites silently run in `local` mode and skip. Either call the +package directly (as CI does) or add `--env-mode=loose`. + +Two local-only red herrings. First, `pnpm format` flags files CI never sees — **not** because +`penpot/` is unignored (Prettier 3 reads `.gitignore`; measured 23.08.2026, `penpot/**/*.md` gives +0 findings by default and 127 with `--ignore-path .prettierignore`), but because untracked-yet- +unignored files under `docs/plans/` are checked — 8 of them that day, and +`prettier --check apps packages docs` does **not** filter them out. Check the tracked set instead, +as in _Verification_. Second, `.nvmrc` pins Node 22 while `engines.node` says only `>=22`. No build needs +`EASYTREE_API_PROXY_TARGET` (EYT-126). ```bash -pnpm --filter @easytree/web... build -pnpm --filter @easytree/api... build +pnpm --filter @easytree/api exec vitest run test/health.e2e.test.ts # one file (filter is a substring) +pnpm --filter @easytree/api exec vitest run -t "returns 503" # one case +pnpm --filter @easytree/web dev # Next dev server :3000 +pnpm --filter @easytree/api... build # package + workspace deps +pnpm --filter @easytree/contracts run openapi:write # regenerate openapi/v1.json ``` -Two more local-only red herrings: `pnpm format` reports untracked working files that CI never -sees (scope it with `pnpm exec prettier --check apps packages docs`), and `.nvmrc` pins Node 22 -while `engines.node` only says `>=22` — a local Node 24 therefore runs silently on a different -major version than every CI proof. +`typecheck`/`test` depend on `^build`, so a package consuming another needs it built +(`pnpm --filter @easytree/config build`) before its tests resolve `dist/`. A red build leaves the +**old** `dist/` in place (`noEmitOnError` is set nowhere), so a single-file test can go green +against stale compiled code. -Six workspace packages — `@easytree/api`, `@easytree/web`, `@easytree/contracts`, -`@easytree/domain`, `@easytree/ui`, `@easytree/config`: +Playwright has **four** configs, and `pnpm --filter @easytree/web run test:e2e -- ` does +**not** filter — pass the file to `exec playwright test` instead. The first three start `next start` against a production build (port 3000 must be free); the +staging suite starts nothing: ```bash -pnpm --filter @easytree/api test # whole package suite -pnpm --filter @easytree/api exec vitest run test/health.e2e.test.ts # single file -pnpm --filter @easytree/api exec vitest run -t "returns 503" # single case -pnpm --filter @easytree/web dev # Next dev server :3000 -pnpm --filter @easytree/web run test:e2e # Playwright (needs prior build) -pnpm --filter @easytree/api... build # package + its workspace deps -pnpm --filter @easytree/contracts run openapi:write # regenerate openapi/v1.json +EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3001 \ + pnpm --filter @easytree/web exec playwright test # web-smoke: shell-smoke + planungswerkbank +pnpm --filter @easytree/web exec playwright test -c e2e/auth-journey/config.ts # real GoTrue + API + DB +pnpm --filter @easytree/web exec playwright test -c e2e/staging/staging.config.ts # against VPS staging +bash scripts/read-through-harness.sh # read-through.spec via harness config ``` -`typecheck`/`test` depend on `^build`, so a package consuming `@easytree/config`, -`@easytree/contracts`, `@easytree/domain` or `@easytree/ui` needs those built -(`pnpm --filter @easytree/config build`) before its tests resolve `dist/`. +Without a proxy target the smoke's `webServer` times out — `instrumentation.ts` refuses the start +fail-closed, and the error text does not say why. The auth journey and the staging suite use the +`.pwtest.ts` suffix so neither the default Playwright match nor vitest picks them up; the auth +journey needs the local Supabase stack plus a built API (read the `auth-journey` job for the exact +environment), the staging suite needs `EYT_STAGING_URL` (**HTTPS enforced**), +`EYT_JOURNEY_WOCHE` and `EYT_SATZ_FEHLT_WOCHE` as **fresh** ISO weeks — a published week is +immutable even for a superuser, so every run consumes its weeks. -Database (Docker required; Supabase CLI is a devDependency, always `pnpm exec supabase`): +Database (Docker required; always `pnpm exec supabase`): ```bash -pnpm exec supabase start # local stack (API 54321, PG 54322, pooler 54329) +pnpm exec supabase start # local stack (API 54321, PG 54322, pooler 54329) pnpm exec supabase migration new -pnpm exec supabase db reset # rebuild from migrations + seed.sql -pnpm exec supabase test db # pgTAP (supabase/tests/*.sql) -pnpm exec supabase status # local URLs/keys -``` - -Tenant suites take their connection from their own variables, not from `DATABASE_URL`: -`EASYTREE_TEST_DB_URL` (default `postgresql://postgres:postgres@127.0.0.1:54322/postgres`) and -`EASYTREE_TEST_POOLER_URL` (Supavisor; CI derives it from the running container, so locally you -must build it yourself — user `postgres.`, port 54329). Against a running -stack, the **direct-connection half** of the CI gate is: - -```bash -pnpm --filter @easytree/config build # exec bypasses turbo's ^build; dist/ must exist -EASYTREE_TENANT_TESTS=required \ - pnpm --filter @easytree/api exec vitest run test/tenant-isolation.integration.test.ts +pnpm exec supabase db reset # rebuild from migrations + seed.sql +pnpm exec supabase test db # pgTAP (supabase/tests/*.sql) +pnpm exec supabase status # local URLs/keys ``` -That is **not** the whole gate: `db-gates` also runs `test/tenant-pooling.integration.test.ts` -against the transaction pooler with `EASYTREE_TEST_POOLER_URL` set, and asserts a -`[tenant-pooling] mode=required executed=… skipped=0` line. Only the CI run covers both. - -Runtime smokes and branch protection: - -```bash -NODE_ENV=test API_PORT=3001 EXPECT_READY=200 DATABASE_URL=... SUPABASE_URL=... \ - SUPABASE_ANON_KEY=... bash scripts/smoke-api.sh # boots dist, /health, /ready, SIGTERM -bash scripts/smoke-worker.sh # worker opens no port, shuts down clean - # (same strict test-preset env as above) -bash scripts/smoke-api-role-gate.sh # API must NOT start when the DB role cannot be - # verified as RLS-bound (EYT-45, fail-closed) -bash scripts/read-through-harness.sh # whole EYT-50 read path, Docker + browser (CI job) -bash scripts/verify-branch-protection.sh # reads effective GitHub ruleset (gh CLI) -``` +Tenant suites read their own variables, not `DATABASE_URL`: `EASYTREE_TEST_DB_URL` and +`EASYTREE_TEST_POOLER_URL` (Supavisor; see the database runbook). The direct connection is only +**half** the gate — `db-gates` also runs the pooling suite, so only CI covers both. + +## Verification + +One command per kind of change, and the string that makes it evidence. A green tick, an exit code +behind a pipe, or "looks correct" is not a done-condition; print the number, not a judgement. + +| Kind of change | Command | Done when | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Formatting | `git ls-files -z \| xargs -0 pnpm exec prettier --ignore-unknown --check` | exit 0 | +| Lint / types | `pnpm exec turbo run lint --force` · `… typecheck --force` | `Cached: 0 cached` **and** `Tasks: N successful` | +| Any test number cited as evidence | `pnpm exec turbo run test --force` | `Cached: 0 cached`; a replay proves nothing | +| One package / one file | `pnpm --filter @easytree/api test` · `… exec vitest run test/.test.ts` | deps built first; printed **file count ≥ 1** | +| Build | `env -u EASYTREE_API_PROXY_TARGET pnpm build` | exit 0, `Cached: 0 cached` | +| Contract schema | `pnpm --filter @easytree/contracts run openapi:write`, then `… test` | `v1.json` regenerated **and committed**, `openapi-drift.test.ts` green | +| Migration | `pnpm exec supabase db reset` twice, then `pnpm exec supabase test db` | both resets reproduce, pgTAP green twice | +| Tenant isolation | `pnpm --filter @easytree/config build`, then `EASYTREE_TENANT_TESTS=required pnpm --filter @easytree/api exec vitest run test/tenant-isolation.integration.test.ts` | `[tenant-isolation] mode=required … skipped=0` | +| API runtime | `NODE_ENV=test API_PORT=3001 EXPECT_READY=200 DATABASE_URL=… … bash scripts/smoke-api.sh` | exit 0 (`/health`, `/ready` 200, clean SIGTERM) | +| Worker / role gate | `bash scripts/smoke-worker.sh` · `bash scripts/smoke-api-role-gate.sh` | both exit 0 — "probe could not run" is RED, and the role gate passes only if the API **refused** to start | +| Read path e2e | `bash scripts/read-through-harness.sh` | exit 0 (Docker + browser) | +| Identity e2e | `pnpm --filter @easytree/web exec playwright test -c e2e/auth-journey/config.ts` | exit 0 against the real GoTrue signup and cookies | +| Staging acceptance | `EYT_STAGING_URL=https://… EYT_JOURNEY_WOCHE=… EYT_SATZ_FEHLT_WOCHE=… pnpm --filter @easytree/web exec playwright test -c e2e/staging/staging.config.ts` | `ids.json` written — only a fully green run writes it, anything else leaves `ids.partial-…` | +| Container / deploy | `EASYTREE_CONTAINER_SMOKE=required bash scripts/smoke-container.sh` | `[container-smoke] mode=required … skipped=0` | +| Branch protection | `bash scripts/verify-branch-protection.sh` | `=== Ergebnis: 0 offen` | +| What CI proved | `gh run view --log --job \| grep -oE '\[[a-z-]+\] mode=[a-z]+ executed=[0-9]+ passed=[0-9]+ skipped=[0-9]+' \| sort -u` | every gate line present with `skipped=0` | ## Architecture ``` -apps/api @easytree/api — NestJS, ONE code package, TWO entrypoints -apps/web @easytree/web — Next.js 16 App Router / React 19, mobile-first PWA shell -packages/contracts @easytree/contracts — transport contract: Zod schemas, gateway ports, - generated openapi/v1.json (EYT-47, EYT-79) -packages/domain @easytree/domain — framework-free core: types, invariants, state models - (EYT-46, EYT-61) -packages/config @easytree/config — strict Zod env validation + secret redaction -packages/ui @easytree/ui — domain-free primitives (Button, Card, VisuallyHidden) -supabase/ config.toml, migrations (schema source of truth), seed.sql, pgTAP tests -docs/ ADRs, plans, runbooks, retros, traceability — mostly German +apps/api NestJS, ONE code package, TWO entrypoints +apps/web Next.js 16 App Router / React 19, one app, TWO client shells (Werkbank, Feld) +packages/contracts Zod schemas, gateway ports, generated openapi/v1.json +packages/domain framework-free core: types, invariants, state models +packages/config strict Zod env validation + secret redaction +packages/ui domain-free primitives + basisdesign-v2.css (the design tokens) +supabase/ migrations (schema source of truth), seed.sql, pgTAP tests +docs/ ADRs, plans, runbooks, retros, evidence — mostly German ``` -Declared workspace dependencies (`package.json`, verifiable): `api` → `config` + `contracts` + -`domain`; `web` → `contracts` + `ui`. **`contracts` and `domain` depend on no workspace package -at all** — `contracts` has only `zod`, `domain` has no runtime dependency whatsoever. - -That `contracts` does not import `domain` is deliberate, not an oversight (see the file header -of `packages/contracts/src/planning/schemas.ts`): `domain` models `TimeInterval` as a **class -with private fields**, which cannot travel over the wire. `contracts` therefore carries its own -transport types and `apps/api` maps between the two. Do not "simplify" this by making the -contract reuse a domain class. - -**Two entrypoints, one module graph** (ADR-001 §2): `apps/api/src/main.ts` boots `AppModule` -via `NestFactory.create` and listens on `API_PORT`; `src/worker.ts` boots the _same_ -`AppModule` via `createApplicationContext` — identical DI and config, **no HTTP listener, no -port**. Anything added to `AppModule` lands in both processes. Both call -`enableShutdownHooks()`; the worker holds the event loop open with a `setInterval` so SIGTERM -can close it gracefully. - -**Config is validated once, at bootstrap, and injected.** `packages/config` exports -`loadConfig(env)` with a `z.strictObject` per `NODE_ENV` — unknown variables are rejected so -typos fail loudly. `ConfigModule` (`apps/api/src/config/config.module.ts`) narrows -`process.env` to the keys in `ENV_VAR_META` before validating, then provides `AppConfig` -under the `APP_CONFIG` token. Presets differ deliberately: `development` has localhost -defaults, `test` has **no** connection defaults, `production` has no defaults _and_ rejects -localhost URLs. `ConfigValidationError` names variables only, never values — do not attach -the ZodError or log raw config; use `redact()`. - -The canonical variable set is exactly seven: `NODE_ENV`, `DATABASE_URL`, `SUPABASE_URL`, -`SUPABASE_ANON_KEY`, `API_PORT`, `LOG_LEVEL`, and since EYT-106 `DATABASE_SSL_ROOT_CERT` -(secret; **required in `production`**, optional in `development`/`test`; accepts raw PEM, -PEM with literal `\n`, or base64 — normalized once in `packages/config/src/certificate.ts`). -Because the schema is strict _and_ the `test` preset has no defaults, **adding or renaming -one touches every site below** — miss any and the build or a whole suite fails: -`packages/config/src/schema.ts` (`ENV_VAR_META` _and_ all three presets _and_ the -`AppConfig` interface), the mapping at the end of `packages/config/src/load.ts`, -`apps/api/test/setup.ts` (sets every mandatory variable explicitly), `.env.example`, and -the `env:` blocks of the `db-gates` job in `.github/workflows/ci.yml` plus -`scripts/smoke-api.sh` / `scripts/smoke-worker.sh`. A secret variable additionally needs its -camelCase key in `SECRET_CONFIG_KEYS` (`redact.ts`) — `redact.test.ts` couples that list to -`ENV_VAR_META` and goes red if you forget. `EASYTREE_*` variables are _not_ part of this -set — they steer tests, never the app, and must never enter `ENV_VAR_META`. - -**PostgreSQL connections are built in exactly one place** (EYT-106, fix(deploy)): -`apps/api/src/platform/database/pg-connection.ts`. With a root certificate the factory -returns `ssl: { ca, rejectUnauthorized: true }` and strips the **entire query** from -`DATABASE_URL` — measured 31.07.2026: pg 8.22 merges the parsed connection string OVER the -explicit `ssl` object (`Object.assign`), so `?ssl=no-verify`, percent-encoded names and -`?sslnegotiation=direct` defeat any parameter denylist. Never reintroduce SSL parameters in -`DATABASE_URL`, never build a `pg` config outside the factory — static guards in -`apps/api/test/pg-connection.test.ts` (including one asserting the EFFECTIVE pg config) -go red if you try. - -**The login role is part of the security boundary** (EYT-107, migrations `0015`/`0016`). -Publishing a plan version is a domain command, not an ordinary table UPDATE — and the -database enforces that itself, because `supabase/config.toml` exposes schema `public` as a -PostgREST **Data API**. Three rules, all on `public.plan_versions`: - -- `app.is_runtime_channel()` compares `session_user` against `easytree_app` and sits in - **both** `using` and `with check` of the update policy. API and worker log in as that role - and then `set local role authenticated`; `session_user` never changes. PostgREST logs in as - `authenticator` and is not a member of `easytree_app` (measured 04.08.2026). Measured, not - assumed: with the condition only in `with check` the attack is still refused — loudly - instead of silently — so the two clauses are independent bolts, not a copy. -- Column grants, the same shape `assignments` got in `0010`: `update` reaches only - `published_at`/`published_by`, `insert` only `(id, org_id, week_key)`, and there is no - `delete` grant at all. A plan version is therefore **born a draft**. -- `app.reject_assignment_in_published_plan()` is `security definer` — a `select … for share` - additionally checks the UPDATE policy's `using` clause, so an invoker read would have gone - blind outside the runtime channel and silently disarmed the trigger. - -Consequences worth knowing before you touch any of this: moving the app onto the Supavisor -transaction pooler makes `session_user` `postgres.` and is **expected to break -publishing** — loudly, via the zero-rows exception in `planning-write.repository.ts` — and, -since migration `0018`, to break **creating cost snapshots** too, there via a typed -`WRITE_CHANNEL_REJECTED` return rather than a throw. Reading snapshots survives: the `_select` -policies carry no channel condition. All three statements are **derived** from `session_user` -plus the policies and are **not measured against a pooler connection** — pgTAP runs as -`postgres`, not `postgres.`. And `service_role`/`postgres` -carry `BYPASSRLS`, so none of it constrains them. See -[`docs/runbooks/planning-publish.md`](docs/runbooks/planning-publish.md). - -**Web never talks to Supabase or bare `fetch`.** Components get an `ApiClient` and — measured -14.08.2026 on `providers.tsx` — **three** gateways from React context: `PlanningGateway`, -`AuthGateway` and, since EYT-109, `CostsGateway` (`lib/api-client-provider.tsx`, -`lib/planning-gateway-provider.tsx`, `lib/auth-gateway-provider.tsx`, -`lib/costs-gateway-provider.tsx`), wrapped by `SessionProvider`. That provider does **not** hand -the selected organisation to the costs gateway itself: it _reports_ it upward via -`onOrganisationChange`, the composition root stores it in a ref (`providers.tsx:45,56-58`), and -the gateway reads that ref on **every** call to send `X-EasyTree-Organization-Id`. The handover -is therefore not synchronous — the report runs in a parent effect, so a costs request fired from -a child effect in the same commit goes out **without** the header. The single construction site -for all of them is the composition root `app/providers.tsx` (ADR-001 §5). `apps/web/test/no-supabase-import.test.ts` is a static -guard that fails if the Supabase JS SDK name appears anywhere under `apps/web` — that guard -string is assembled from parts on purpose, so don't "fix" it by inlining the literal. - -**There is no `NEXT_PUBLIC_API_URL` any more** (removed in EYT-50) — do not reintroduce it. -The browser calls **relative** paths and `providers.tsx` passes the empty origin; the Route -Handlers under `apps/web/app/api/[[...pfad]]`, `app/health` and `app/ready` forward them -server-side to `EASYTREE_API_PROXY_TARGET`, read fresh on **every request** (EYT-126 — -`next.config.ts` no longer carries a `rewrites()` block). Two reasons, both deliberate: one -visible origin means the API needs no CORS and no extra public surface, and a `NEXT_PUBLIC_*` -value would be baked into the browser bundle at build time. `lib/api-proxy-target.ts` validates -that target strictly (absolute http/https, no credentials, no query/fragment, no trailing slash) -and has **no default in production** — `apps/web/instrumentation.ts` therefore refuses the -server start instead of silently proxying to localhost, which would look like an empty week in -the browser rather than an error. The single pass-through is `lib/proxy-durchreichen.ts`; it is -also the one place that keeps the internal address off the wire — no `x-middleware-rewrite` -exists, an absolute `location` header pointing at the configured target is rewritten to a -relative path (an external redirect is left alone), **every other response header whose VALUE -names the configured target is dropped** rather than rewritten (`X-Upstream-Url`, -`Link: <…>; rel="self"`, `Content-Location`, a `Set-Cookie` carrying an internal `Domain` — -external addresses stay, multiple `set-cookie` stay multiple), and a connection failure becomes -a 502 that names no host. That match targets the **address, not the word**: header names are not -inspected at all, URLs in the value are parsed with `new URL()` and compared by origin, the -`host:port` authority matches only at a character boundary and only when the target declares a -port, and the bare hostname matches only as the **whole** value. A bare-substring match would -swallow `X-Api-Version: 1` for a target named `api` — the guard would then be a silent outage -rather than a protection. Each gateway's URL is assembled in exactly one place — its own -factory — because the test that checks it must call the same function production does; an -earlier version built its own gateway and would have stayed green whatever `providers.tsx` did. -There are **three** such factories (`lib/planning-gateway-factory.ts`, -`lib/auth-gateway-factory.ts`, `lib/costs-gateway-factory.ts`), and measured 14.08.2026 only the -planning one is **asserted** by that test (`apps/web/test/api-base-path.test.ts`). Mind the -distinction: `buildCostsApiBaseUrl` and `buildAuthApiBaseUrl` are **executed** on every page -load — `app/layout.tsx` renders ``, so every browser e2e run goes through them — but -**no test names them or says anything about the URLs they build**. Executed is not asserted; the -guarantee this paragraph describes does **not** hold for those two yet (EYT-109 Task 16, open). - -**The contract is generated from Zod, and the generated file is checked in.** -`packages/contracts/src/**/schemas.ts` are the source; `packages/contracts/openapi/v1.json` is -the build product, regenerated with `pnpm --filter @easytree/contracts run openapi:write` and -committed so reviewers can read the diff. `test/openapi-drift.test.ts` compares the serialized -document **byte for byte** against the committed file — change a schema without regenerating and -it goes red (EYT-47 AC 4). `apps/api/test/openapi-route-conformance.test.ts` checks the Nest -routes against the same document. Never hand-edit `v1.json`. - -**Gateway ports model failure in the return type, not in exceptions.** -`packages/contracts/src/gateway.ts` returns `GatewayResult` — `{ ok: true, value }` or -`{ ok: false, failure, problem }` with `failure` one of `UNAVAILABLE`, `CONTRACT_VIOLATION`, -`UNAUTHENTICATED`, `FORBIDDEN`, `STALE_VERSION`, `REJECTED`. Bare `Promise` was rejected on -purpose: it would push error, empty and stale-version handling back into every component. There -is deliberately **no `loading` state** on the port — waiting on the promise _is_ the loading -state, so a `{ state: "loading" }` variant would never be observable; that belongs to the -component holding the promise. +Workspace dependencies (read them from `package.json`, they move): `api` → `config` + +`contracts` + `domain`; `web` → `contracts` + `domain` + `ui`; **`contracts` and `domain` depend +on no workspace package at all**. `contracts` deliberately does not import `domain`: `domain`'s +`TimeInterval` is a class with private fields that cannot travel over the wire — do not +"simplify" that away. + +**Two entrypoints, one module graph** (ADR-001 §2): `src/main.ts` listens on `API_PORT`, +`src/worker.ts` boots the _same_ `AppModule` via `createApplicationContext` with **no HTTP +listener and no port**. Anything added to `AppModule` runs in both. +Both call `enableShutdownHooks()`; the worker holds the event loop open with a `setInterval` so +SIGTERM can close it gracefully. + +**Config is validated once, at bootstrap, and injected**: `loadConfig(env)` uses a +`z.strictObject` per `NODE_ENV`, so unknown variables are rejected. `test` has **no** connection +defaults, `production` has none _and_ rejects localhost. `ConfigValidationError` names variables +only — never attach the ZodError or log raw config; use `redact()`. + +The canonical set is exactly seven: `NODE_ENV`, `DATABASE_URL`, `SUPABASE_URL`, +`SUPABASE_ANON_KEY`, `API_PORT`, `LOG_LEVEL`, `DATABASE_SSL_ROOT_CERT` (secret, **required in +`production`**). Strict schema plus defaultless `test` means **adding or renaming one touches all +of**: `packages/config/src/schema.ts` (`ENV_VAR_META`, all three presets, `AppConfig`), the +mapping in `load.ts`, `apps/api/test/setup.ts`, `.env.example`, the `env:` blocks of `db-gates`, +`scripts/smoke-api.sh`, `scripts/smoke-worker.sh` — plus `SECRET_CONFIG_KEYS` (`redact.ts`) for a +secret. Miss one and a suite fails. `EASYTREE_*` variables steer tests, never the app, and must +never enter `ENV_VAR_META`. + +**PostgreSQL connections are built in exactly one place**, +`apps/api/src/platform/database/pg-connection.ts`: with a root certificate it returns +`ssl: { ca, rejectUnauthorized: true }` and strips the **entire query** from `DATABASE_URL`, +because `pg` merges the connection string OVER the explicit `ssl` object, so a parameter denylist +is defeatable. Never reintroduce SSL parameters there or build a `pg` config elsewhere — +static guards in `apps/api/test/pg-connection.test.ts`, including one asserting the EFFECTIVE pg +config, go red if you try. The chain verifies against `DATABASE_SSL_ROOT_CERT` (Supabase Root +2021 CA, cryptographically verified against the live chain before use). +`DATABASE_URL` must connect as `easytree_app`: on the hosted project `postgres` carries +**BYPASSRLS**, which the EYT-45 start gate refuses. + +**The login role is part of the security boundary** (EYT-107, migrations `0015`/`0016`): because +`supabase/config.toml` exposes `public` as a PostgREST **Data API**, publishing is enforced by the +database, not only by the application. Three rules on `public.plan_versions`: +(1) `app.is_runtime_channel()` compares `session_user` against `easytree_app` and sits in **both** +`using` and `with check` of the update policy — API and worker log in as that role and only then +`set local role authenticated`, so `session_user` never changes; two independent bolts. +(2) Column grants: `update` reaches only `published_at`/`published_by`, `insert` only +`(id, org_id, week_key)`, no `delete` grant — a plan version is **born a draft**. (3) +`app.reject_assignment_in_published_plan()` is `security definer`, because a locking +`select … for share` also checks that `using` clause and an invoker read would be blinded, +disarming the trigger. Consequence — **derived** from `session_user` plus the policies and **not measured against a +pooler connection**, because pgTAP runs as `postgres`, not `postgres.`: the +transaction pooler makes `session_user` +`postgres.` and **breaks publishing** and cost-snapshot creation; reading survives, and +`service_role`/`postgres` bypass RLS +([`docs/runbooks/planning-publish.md`](docs/runbooks/planning-publish.md)). + +**Web never talks to Supabase or bare `fetch`.** Components get an `ApiClient` plus the gateways +(`PlanningGateway`, `AuthGateway`, `CostsGateway`) from React context; the single construction +site is the composition root `app/providers.tsx` (ADR-001 §5). The selected organisation is +**not** handed to the costs gateway synchronously: `SessionProvider` reports it upward via +`onOrganisationChange`, the root stores it in a ref, and the gateway reads that ref on every call +to send `X-EasyTree-Organization-Id` — so a costs request fired from a child effect in the same +commit goes out **without** the header. +`apps/web/test/no-supabase-import.test.ts` fails if the Supabase SDK name appears under +`apps/web` — its guard string is assembled from parts, so don't inline the literal. + +**One Next app, two client shells** (EYT-113, `docs/architecture/zwei-client-shells.md`): the +route group `apps/web/app/(werkbank)/` (`/`, `/anmelden`, `/planung`, `/kosten`, +`/kosten/stundensaetze`; desktop-first, 1440/1920 px, `AppShell` from `@easytree/ui`) and +`apps/web/app/feld/` (employee shell, mobile-first, 320/375 px, `FeldShell`). Same API, same +gateways, same composition root; the root layout decides no shell. `lib/sitzung-server.ts` reads +the session server-side through the runtime proxy target and yields **three** states — +`angemeldet`, `abgemeldet`, `unbekannt` — and **not knowing is not logged out**: `feld/layout.tsx` +redirects on `abgemeldet`, renders an `ErrorState` on `unbekannt`, mounts the shell only on a +verified session. `lib/feld/start-shell.ts` derives the start shell from the verified membership +roles (owner/manager → Werkbank, otherwise Feld); no client flag decides. Choosing a shell or a +route grants nothing — every request still passes application-layer authorisation and RLS. +`lib/kosten-freigabe.ts` is the one server-side read of the `costs.read` load boundary, keyed on +the organisation selected via `lib/organisations-auswahl-cookie.ts`; a broken selector cookie +falls closed. Because Turbopack ships every route-entry chunk unconditionally, a server gate keeps +cost **chunks** out of the browser only behind a client-side `next/dynamic` boundary — prove it +with the build manifest and a network capture, not with the gate's source. + +**Design tokens have one source**: `packages/ui/src/basisdesign-v2.css` (Basisdesign v2.0), exported +as `@easytree/ui/basisdesign-v2.css` — the `ui` build copies it into `dist/` by hand because `tsc` +never would, and the `exports` map needs the subpath. `apps/web/app/globals.css` imports the colour +roles from there; `packages/ui/test/basisdesign-tokens.test.ts` and +`apps/web/test/basisdesign-tokens.test.ts` go red on a second palette. In the browser the minifier +shortens `#ffffff` to `#fff` and `getComputedStyle` does not normalise custom properties — compare +tokens as colours, never as strings. + +**There is no `NEXT_PUBLIC_API_URL` any more** (EYT-50) — do not reintroduce it: one visible +origin needs no CORS, and a `NEXT_PUBLIC_*` value would be baked into the bundle at build time. +The browser calls **relative** paths; the Route Handlers under `apps/web/app/api/[[...pfad]]`, +`app/health` and `app/ready` forward them server-side to `EASYTREE_API_PROXY_TARGET`, read fresh +on **every request** (EYT-126 — no `rewrites()`). `lib/api-proxy-target.ts` validates that target +strictly (absolute http/https, no credentials, no query/fragment, no trailing slash) with **no +default in production**, so `instrumentation.ts` refuses the server start. The single pass-through +is `lib/proxy-durchreichen.ts`, also the one place keeping the internal address off the wire +(_Deployment_). Each gateway's URL is built in one factory, so its test calls the same function +production does — but executed is not asserted: read `apps/web/test/api-base-path.test.ts` for +which factories it names. + +**The contract is generated from Zod and the generated file is checked in.** +`packages/contracts/src/**/schemas.ts` are the source, `openapi/v1.json` the build product, +regenerated with `openapi:write` and committed; `test/openapi-drift.test.ts` compares it **byte +for byte**, so never hand-edit it. The drift test cannot see strictness: `z.strictObject` and +`z.object` emit the same `additionalProperties: false`, so a schema that loses `strict` needs its +own runtime case. Gateway ports model failure **in the return type, not in +exceptions** (`GatewayResult` = `{ ok: true, value }` | `{ ok: false, failure, problem }`), and +carry deliberately **no `loading` state**. **Modules are hexagonal and their names are frozen.** Each module under -`apps/api/src/modules//` splits into `domain/` (pure), `application/` (ports), `infrastructure/` -(adapters) and `interface/http/` (controllers). `MODULE_SLUGS` in `src/modules/module-catalogue.ts` -is the one frozen list of **eleven** slugs (`tenancy`, `workforce`, `worksites`, `planning`, -`resources`, `timekeeping`, `communications`, `weather`, `files`, `audit`, `costs`) — the first ten -derived from ADR-001 Z. 60, `costs` added by ADR-003 (EYT-105). Measured 03.08.2026; an earlier -revision of this file said "ten slugs" and was stale. The -directory name, the registry entry and every type parameter are the same string. That list is -what stops `architecture.test.ts` from running vacuously: `SCAFFOLDED_MODULES` demands real files -per named module instead of counting whatever files a glob happens to find. +`apps/api/src/modules//` splits into `domain/` (pure), `application/` (ports), +`infrastructure/` (adapters) and `interface/http/`. `MODULE_SLUGS` in +`src/modules/module-catalogue.ts` is the one frozen list of **eleven** slugs (`tenancy`, +`workforce`, `worksites`, `planning`, `resources`, `timekeeping`, `communications`, `weather`, +`files`, `audit`, `costs`); directory, registry entry and type parameter are one string. Only the +**five** in `SCAFFOLDED_MODULES` (`workforce`, `worksites`, `planning`, `costs`, `tenancy`) have +directories today, and for those it demands real files per layer — which keeps +`architecture.test.ts` non-vacuous; the other six are reserved names, not empty folders. +Cross-module access goes **only through the module's `index.ts`** (`module-public-api-only`), and +that includes tests: a new module file without a barrel export has no writable test. **One connection owner, three statements per transaction.** `src/platform/database/tenant-query-runner.ts` is the only file allowed to hold `pg` — the rule `api-dependency-allowlist` permits the driver exclusively under `apps/api/src/platform/`, so no module can route a query past transaction boundary, role switch and RLS. Each run is one -transaction and sets, in this order: (1) `set_config('request.jwt.claims', …, true)` — -transaction-local, which is what `app.current_user_id()` / `app.user_org_ids()` read; -(2) `set local role authenticated` — `easytree_app` is `NOINHERIT` (migration `0003`), so without -the switch a query fails with "permission denied" rather than silently returning everything; -(3) the body. Transaction-local because the transaction-mode pooler survives nothing else — -proven in CI against a real Supavisor (`[tenant-pooling] …`). This layer sets context, it does -**not** decide: server-side authorization belongs in the module's application layer, RLS is -defense-in-depth. Repositories see only the `TenantQuery` interface, never a driver type. - -**The read path is proven end to end, not assembled from unit tests** (EYT-50). Browser → -same-origin Next Route Handler (`lib/proxy-durchreichen.ts`; it was a `rewrites()` entry until -EYT-126) → NestJS → `TenantQueryRunner` → RLS → PostgreSQL, exercised by -`scripts/read-through-harness.sh` (CI job `read-through`) with `apps/web/e2e/read-through.spec.ts`. -Only the subject resolver and the access policy are substituted (`apps/api/test/harness/server.ts`, -built via `pnpm --filter @easytree/api run build:harness`); repository, runner, pool and controller -are the real ones. It is **one** script rather than several workflow steps because a `trap` only -covers its own process — split across steps, a failure in the last one would leave the API, the web -server and Docker running. It waits for a **response** plus `kill -0`, never a fixed `sleep`, so a -process that died at startup is reported as crashed instead of being polled until timeout. - -**Readiness is indicator-based.** `src/health/readiness.ts` defines `ReadinessIndicator` and -the `DATABASE_PING` token; production wires `PgDatabasePing` (real `SELECT 1`), tests override -the token via DI. `GET /health` = liveness, `GET /ready` = 503 when any indicator is down. -Cross-cutting: `CorrelationIdMiddleware` echoes/creates `x-correlation-id`; -`HttpExceptionFilter` emits RFC-7807-shaped JSON with no stack traces or secret values. - -**Multi-tenancy is enforced in the database.** Every tenant-owned table carries -`org_id NOT NULL`; composite `unique (id, org_id)` targets make tenant-bound FKs possible, so -a cross-tenant reference fails at FK level (23503) independent of RLS. Identity comes only -from the verified JWT via `app.current_user_id()` / `app.user_org_ids()` (security definer, -`search_path = ''`, active memberships only). Server-side authorization is primary, RLS is -defense-in-depth; normal runtime credentials must never bypass RLS. See -`supabase/migrations/20260723222457_0002_tenancy.sql` and -`docs/architecture/tenant-isolation-report.md`. +transaction: (1) `set_config('request.jwt.claims', …, true)`, transaction-local because the +transaction pooler survives nothing else; (2) `set local role authenticated` (`easytree_app` is +`NOINHERIT`, so without the switch a query fails "permission denied" instead of returning +everything); (3) the body. This layer sets context, it does **not** decide: authorization belongs +in the module's application layer, RLS is defense-in-depth. Repositories see only the +`TenantQuery` interface, **never a driver type**. + +**The read path is proven end to end, not assembled from unit tests** (EYT-50): browser → +Route Handler → NestJS → `TenantQueryRunner` → RLS → PostgreSQL in +`scripts/read-through-harness.sh`, with only the subject resolver and access policy substituted (harness built via +`pnpm --filter @easytree/api run build:harness`). It is **one** script rather than several +workflow steps because a `trap` covers only its own process, and it waits for a **response** plus +`kill -0`, never a fixed `sleep`. +Readiness is indicator-based: `src/health/readiness.ts` defines `ReadinessIndicator` and the +`DATABASE_PING` token — production wires the real `SELECT 1`, tests override the token via DI. +`/health` = liveness, `/ready` = 503 when any indicator is down; +`CorrelationIdMiddleware` echoes or creates `x-correlation-id`, and `HttpExceptionFilter` emits +RFC-7807 JSON without stack traces or secret values. + +**Multi-tenancy is enforced in the database.** Every tenant-owned table carries `org_id NOT NULL`, +and composite `unique (id, org_id)` targets make tenant-bound FKs possible, so a cross-tenant +reference fails at FK level (23503) independent of RLS. Identity comes only from the verified JWT +(`app.current_user_id()` / `app.user_org_ids()` — `security definer`, `search_path = ''`, active +memberships only; every new security-definer function needs the same), and normal runtime +credentials must never bypass +RLS. ## Non-negotiable rules - **SQL migrations under `supabase/migrations/` are the only schema source.** Schema changes via the Supabase dashboard/Studio are forbidden, including "just to try it". Merged migrations are append-only — fix forward with a new migration, never edit or delete. - `supabase/seed.sql` holds synthetic data with fixed UUIDs only. Definition of done: two + `supabase/seed.sql` holds synthetic data with fixed UUIDs only; it is dev data, was **not** + applied to production, and must never be applied there. Definition of done: two consecutive `db reset` runs plus green `supabase test db`. Filenames follow `_NNNN_.sql` (`20260723222457_0002_tenancy.sql`); `supabase migration new` emits only the timestamp, so **rename the new file to continue the `NNNN` sequence**. See @@ -494,324 +382,241 @@ skipped=…` line that CI asserts. `apps/api/test/tenant-gate.fail-closed.test.t `.github/workflows/ci.yml` runs on every PR. **Eleven** jobs on `master`: `format`, `lint`, `typecheck`, `unit-tests`, `build-web`, `web-smoke`, `build-api`, `secret-scan`, `db-gates`, -`read-through`, `auth-journey` (job ids counted 03.08.2026 on `d9b9607`; an earlier revision said -"ten" and omitted `auth-journey`, which landed with EYT-106/EYT-134). None uses -`continue-on-error`. - -`auth-journey` is the only job that proves IDENTITY: it starts the real `apps/api/dist/main.js`, -logs in through the real GoTrue signup and real HttpOnly cookies, and drives a second browser -context. `read-through` substitutes `REQUEST_IDENTITY` in its harness and therefore proves the -data path, never the login. Do not cite `read-through` as evidence that authentication works. - -**A green job is not automatically a blocking job — but as of 14.08.2026 every one of them is.** -An earlier revision of this file said `read-through` "passes but is _not_ a required status -check" and that the verifier "currently prints `FAIL AC3 — fehlende Pflichtchecks: -read-through`". **Both statements are stale.** Measured 14.08.2026 with -`bash scripts/verify-branch-protection.sh` (read-only, reads GitHub's _effective_ rules): - -``` -PASS AC3 — alle 11 Pflichtchecks sind als required_status_checks gesetzt. -PASS AC3 — strict_required_status_checks_policy=true; der Branch muss vor dem Merge aktuell sein. -PASS AC1 — kein Bypass-Akteur in allen 1 gelesenen Ruleset(s); die Sperre gilt auch fuer Repo-Admins. -=== Ergebnis: 0 offen, 1 uebersprungen === -``` - -The ruleset was re-applied at some point after the note above was written; nobody recorded when. -So **all eleven** `ci.yml` jobs — `read-through` and `auth-journey` included — are required -status checks in ruleset `19718704` (`enforcement: active`, `bypass_actors: []`, -`~DEFAULT_BRANCH`), alongside `pull_request`, `non_fast_forward` and `deletion`. - -That the gates actually block is measured, not assumed: negative PR #7 carried a -deliberately unformatted file → `format` failed, `mergeable_state` went to `blocked`, and the -merge attempt was rejected server-side with `the base branch policy prohibits the merge` -(EYT-67 AC 8; PR closed without merging). Re-check any time, read-only, with -`bash scripts/verify-branch-protection.sh` — it reads GitHub's _effective_ rules -(`GET /repos/{owner}/{repo}/rules/branches/{branch}`), not the intended config. Caveat: the -"applies to admins too" claim is inferred from `bypass_actors: []`, not measured — nobody has -attempted an `--admin` override merge, and doing so would land the bad commit on `master` if -the inference were wrong. - -`scripts/setup-branch-protection.sh` aborts if `ci.yml` contains a job that isn't in the -required-checks list — **adding a CI job means updating that script and the runbook**, or the -new job silently becomes optional. `scripts/verify-branch-protection.sh` keeps a **second, -independent copy** of that list on purpose (a verifier reading the setter's list would confirm -the very drift it exists to catch), and guards it the same way. So a new job means editing -**both** lists: update only one and `verify` aborts with an internal drift error instead of -reporting the real ruleset state — which is exactly what happened when `read-through` was -added (fixed 27.07.2026). - -`db-gates` is the heavy one: starts the real Supabase stack, resets twice, runs pgTAP twice, -proves the catalogue meta gate is not vacuous (creates a deliberately open table and requires -the suite to go red _naming that table_), resets again, drops and re-creates `easytree_app` to -prove the `create` branch of migration `0003`, restarts the stack to re-provision the Supavisor -tenant, then runs the tenant gate against both a direct connection and the transaction-mode -pooler (tenant id and port are derived from the running container, not hardcoded), then the -planning-invariants gate (EYT-49 — two real connections, because one session can show a -constraint _rejecting_ but not _serialising_), then the API/worker process smokes. Every gate -asserts its own greppable `[…] mode=required executed=… skipped=0` line. - -That sentence names the first gates, not all of them. Measured 14.08.2026 the job has **32 -steps** (30 with a `name:`, plus `actions/checkout` and `setup-pnpm`) and emits **13 gate -lines** — count the lines, not the steps, because one step can emit two (the EYT-108 step runs -both `rate-timestamp` and `rate-http-contract`, and the tenant gate appears twice: direct -connection and transaction pooler). On run `31759500751` (head `4e409fb`) they were: - -``` -[cost-access] [cost-snapshot] [planning-invariants] [planning-publish] -[planning-published-reads] [planning-write] [rate-http-contract] [rate-succession] -[rate-timestamp] [snapshot-http] [snapshot-immutability] [tenant-isolation] [tenant-pooling] -``` - -each `mode=required`, `passed=executed`, `skipped=0`. Read the job, not this paragraph, before -claiming what `db-gates` covers — and read the gate LINES, not the job's green tick. What a red -`db-gates` does and does not tell you, measured on two real red runs: the step list **does** name -the failing step (`23 failure — Kosten-Snapshot … EYT-138`), and `Tenant report -> step summary` -still runs because it carries `if: always()`. What is lost is everything **after** the red step — -those gates are `skipped`, so a red run says nothing about whether they would have held. Extract -the lines with: - -```bash -gh run view --log --job \ - | grep -oE '\[[a-z-]+\] mode=[a-z]+ executed=[0-9]+ passed=[0-9]+ skipped=[0-9]+' | sort -u -``` +`read-through`, `auth-journey`. None uses `continue-on-error`, and all eleven are required status +checks in ruleset `19718704` (`enforcement: active`, `bypass_actors: []`). Re-check read-only with +`bash scripts/verify-branch-protection.sh`, which reads GitHub's _effective_ rules; "applies to +admins too" is **inferred** from `bypass_actors: []`, not measured. A second workflow, +`release-images.yml`, runs on every push to `master` (and by hand) and pushes both container +images to `ghcr.io//easytree-{api,web}` tagged with the commit SHA and `master` — it is not +a status check. + +`auth-journey` is the only job that proves IDENTITY: real `dist/main.js`, real GoTrue signup, real +cookies. `read-through` substitutes `REQUEST_IDENTITY` and proves the data path, never the login — +do not cite it as evidence that authentication works. + +`scripts/setup-branch-protection.sh` aborts if `ci.yml` has a job that isn't in the +required-checks list, and `scripts/verify-branch-protection.sh` keeps a **second, independent +copy** of that list on purpose. **A new CI job means editing both lists**: update only one and +`verify` aborts with an internal drift error instead of the real ruleset state; update neither and +the job silently becomes optional. + +`db-gates` is the heavy one: real Supabase stack, a meta-gate counter-probe (a deliberately open +table the suite must go red _naming_), and the tenant gate against a direct connection **and** the +transaction pooler. Every gate asserts its own greppable +`[…] mode=required executed=… skipped=0` line +([`docs/runbooks/database-workflow.md`](docs/runbooks/database-workflow.md)). **Read the gate +LINES, not the green tick**, and read the job, not this file, for which gates exist. A red +`db-gates` names the failing step, but everything **after** it is `skipped` and unproven. ## Deployment — Container first (Entscheidung 21.08.2026) Kanonisch ist Confluence **"EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify + Docker"** (Seite 30998530): **primär eigener VPS mit Coolify und Docker/OCI, sekundär Railway, -Cloudflare Workers ist kein Zielruntime mehr.** Coolify ist Orchestrator, nicht Teil der -Facharchitektur. Die Cloudflare-Artefakte (`apps/*/wrangler.jsonc`, `apps/web/open-next.config.ts`, -die Schritte in `build-web`/`build-api`) bleiben vorerst stehen und werden erst nach belegter -Container-Parität entfernt — das ist EYT-149, nicht dieser Slice. +Cloudflare Workers ist kein Zielruntime mehr.** Coolify ist Orchestrator, nicht Facharchitektur; +die Cloudflare-Artefakte (`build:cf`, `cf:dry-run`, `wrangler.jsonc`) fallen erst nach belegter +Container-Parität (EYT-149). Beide Workloads werden aus dem Wurzelverzeichnis gebaut (`apps/api/Dockerfile`, -`apps/web/Dockerfile`, Topologie in `docker-compose.yml`), mit `pnpm install --frozen-lockfile` -und `corepack`. Der Outbox-Worker benutzt **dasselbe** API-Image und überschreibt nur das -Kommando mit `node dist/worker.js`. Nur `web` veröffentlicht einen Port; die API hängt am -internen Netz. Beide Images laufen als `node`, nicht als root, und tragen den Commit als -OCI-Label `org.opencontainers.image.revision`. - -**`EASYTREE_API_PROXY_TARGET` ist LAUFZEIT-Konfiguration — gemessen 22.08.2026 auf Next 16.2.11 -(EYT-126).** Der Same-Origin-Proxy liegt seit EYT-126 in Route Handlern -(`apps/web/app/api/[[...pfad]]`, `app/health`, `app/ready`), nicht mehr in -`next.config.ts`-`rewrites()`. Gemessen im Container-Smoke: **ein** Web-Image wurde einmal -gebaut, sein Digest festgehalten, und derselbe Digest zweimal gegen verschiedene APIs gestartet — -`ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a"}`, -`ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b"}`, und -`vorher= nachher=` identisch. **Das Web-Image ist damit weder an ein Ziel noch -an einen Anbieter gebunden** — dieselbe Datei und derselbe Startpfad bauen es für Coolify wie -für Railway, und es gibt kein `--build-arg` mehr, das sich unterscheiden könnte. - -Eine früher hier stehende Messung (Build mit `http://buildtime-marker.invalid:9999`, Start mit -einem anderen Wert, HTTP 500 mit `ENOTFOUND`) **war korrekt** — sie galt dem `rewrites()`-Weg, -den es nicht mehr gibt. - -Fail-closed bleibt es an zwei Stellen: `apps/web/instrumentation.ts` prüft das Ziel beim -Serverstart, und danach beantwortet Next **jede** Route mit 500 — auch `/` und `/anmelden` -(gemessen 22.08.2026, Container und `next start`). Der Prozess hält dabei den Port; er bedient -aber keinen normalen Anwendungsverkehr, und der Compose-Healthcheck auf `/` schlägt fehl, der -Container gilt als `unhealthy`. **Behaupte nicht, der Container starte nicht.** -`lib/proxy-durchreichen.ts` prüft zusätzlich bei jeder Anfrage. - -**Zwei nicht wiederholenswerte Sackgassen, beide gemessen 21.08.2026:** eine Next-16-`proxy.ts` -(Node-Middleware) funktioniert zwar zur Laufzeit, sendet aber -`x-middleware-rewrite: ` an den Browser — der Kopf lässt sich nicht entfernen, -ohne die Weiterleitung abzuschalten —, und `@opennextjs/cloudflare` bricht mit `Node.js -middleware is not currently supported` ab, was den Pflichtjob `build-web` rot machen würde. -Edge-Middleware scheidet aus, weil dort `process.env` beim Bauen eingebacken wird. - -`scripts/smoke-container.sh` ist der Container-Smoke; er läuft am Ende von `db-gates` (kein -eigener Job — der wäre kein Pflichtcheck, bis jemand das Ruleset neu anwendet) und meldet -`[container-smoke] mode=required executed=… passed=… skipped=0`. Er prüft OCI-Label gegen den -Head, Nicht-root, `/health`, `/ready` mit echter Datenbank, den Weg Web→API über den -Dienstnamen, dass der Dienstname von aussen NICHT auflösbar ist, dass weder das ausgelieferte -HTML noch ein Client-Chunk noch ein Antwortkopf noch ein `location`-Kopf die interne Adresse -nennt, geheimnisfreie Protokolle, das Verweigern des Starts im Produktionsprofil ohne -`DATABASE_SSL_ROOT_CERT`, **ein Image gegen zwei Ziele bei unverändertem Digest**, das -Fail-closed-Verhalten bei fehlendem und bei ungültigem Proxyziel, und geordnetes Herunterfahren. -Der Antwortkopf-Teil ist dabei nicht auf `location` beschränkt: **jeder** übrige Kopf, dessen -**Wert** die interne Adresse nennt, fällt in `lib/proxy-durchreichen.ts` ersatzlos weg — -`X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein `Set-Cookie` mit interner -`Domain`. Weggelassen und nicht umgeschrieben, weil ein Kopf ohne festgelegte Bedeutung keine -Struktur trägt, aus der sich eine Übersetzung ableiten ließe; fremde Adressen bleiben stehen, -mehrere `Set-Cookie` bleiben mehrere. **Gesucht wird die Adresse, nicht das Wort** — der -Kopfname wird gar nicht geprüft, eine URL im Wert wird mit `new URL()` gefunden und über ihre -Origin verglichen, die Autorität `host:port` nur an einer Zeichengrenze und nur bei -ausgewiesenem Port, der nackte Hostname nur als **ganzer** Wert. Ein Teilstringvergleich gegen -den nackten Dienstnamen wäre bei der vorgesehenen Topologie `http://api:3001` fatal: er -verschluckte `X-Api-Version: 1`, eine Doku-URL auf `api.example.org` und ein Cookie -`api_session` — kein Schutz, sondern ein stiller Ausfall. Die Stub-Container des Smokes senden -sowohl den leckenden als auch diese harmlosen Köpfe nachweislich; sonst wäre weder die -Abwesenheit der einen noch die Anwesenheit der anderen ein Nachweis. -Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=27 passed=27 skipped=0`. -Runbook: +`apps/web/Dockerfile`, `docker-compose.yml`). Der Outbox-Worker benutzt **dasselbe** API-Image mit +`node dist/worker.js`. Nur `web` veröffentlicht einen Port. Beide Images laufen als `node`, nicht +als root, und tragen den Commit als OCI-Label `org.opencontainers.image.revision`. Das Web-Image +baut mit `EASYTREE_NEXT_OUTPUT=standalone` — `lib/next-output.ts` akzeptiert nur diesen Wert oder +leer, und `outputFileTracingRoot` ist die Workspace-Wurzel, damit die Workspace-Pakete im +Standalone-Bundle landen. + +**`EASYTREE_API_PROXY_TARGET` ist LAUFZEIT-Konfiguration (EYT-126)** — der Proxy liegt in Route +Handlern, nicht in `rewrites()`. **Das Web-Image ist damit weder an ein Ziel noch an einen +Anbieter gebunden**: derselbe Digest bedient nacheinander verschiedene APIs, und es gibt kein +`--build-arg` mehr. + +Fail-closed an zwei Stellen: `instrumentation.ts` prüft das Ziel beim Serverstart, +`lib/proxy-durchreichen.ts` bei jeder Anfrage. Danach antwortet Next auf **jeder** Route mit 500; +der Prozess hält den Port, der Container gilt als `unhealthy`. **Behaupte nicht, der Container +starte nicht.** + +**Zwei nicht wiederholenswerte Sackgassen:** eine Next-16-`proxy.ts` (Node-Middleware) sendet +`x-middleware-rewrite: ` an den Browser — nicht entfernbar, ohne die +Weiterleitung abzuschalten —, und `@opennextjs/cloudflare` bricht daran ab (`Node.js middleware is +not currently supported`), was `build-web` rot machen würde. Edge-Middleware scheidet aus, weil +dort `process.env` beim Bauen eingebacken wird. + +**Gesucht wird die Adresse, nicht das Wort.** In `lib/proxy-durchreichen.ts` fällt **jeder** +Antwortkopf, dessen **Wert** die interne Adresse nennt, ersatzlos weg; ein absoluter +`location`-Kopf auf das Ziel wird relativ gemacht; externe Redirects, fremde Adressen und +mehrfache `Set-Cookie` bleiben unangetastet; ein Verbindungsfehler wird ein 502 ohne Hostnamen. +Verglichen wird über die Origin, die Autorität `host:port` nur an einer Zeichengrenze, der nackte +Hostname nur als **ganzer** Wert — ein Teilstringvergleich verschluckte bei `http://api:3001` +sonst `X-Api-Version: 1`. + +`scripts/smoke-container.sh` läuft am Ende von `db-gates` (ein eigener Job wäre kein Pflichtcheck, +solange das Ruleset nicht neu angewendet wird) und meldet `[container-smoke] mode=… skipped=0`; +Die Stub-Container senden nachweislich sowohl den leckenden als auch die harmlosen Koepfe; sonst +waere weder die Abwesenheit der einen noch die Anwesenheit der anderen ein Nachweis. Pruefumfang: [`docs/runbooks/staging-deploy.md`](docs/runbooks/staging-deploy.md). -**Ein Deploy ist trotzdem gesperrt.** `BLOCKER_ENVIRONMENT_SEPARATION`: es existiert keine -EasyTree-Datengrenze, deren `project_ref` von `inypnrvpawvhgiyagxbd` verschieden ist -(nachgemessen 21.08.2026 über `list_projects` und `list_branches`). Der **kostenlose** Weg dorthin -ist zusätzlich versperrt: ein `create_project`-Versuch für `easytree-staging` (Kosten gemessen -0 $/Monat) wurde mit einem Quota-Fehler abgelehnt — die Free-Projekt-Quota zählt **pro Nutzer** -über alle Organisationen hinweg, in denen er Owner oder Admin ist, und `DYAI2025` hat sie mit zwei -aktiven Free-Projekten ausgeschöpft; das zweite ist über diesen Zugang nicht sichtbar. Ein -pausiertes Projekt zählt laut Supabase-Doku nicht mit, das pausierte „Bazodiac" zu löschen hilft -also nicht. Siehe -[`docs/plans/2026-08-20-sprint-6-staging-blocker.md`](docs/plans/2026-08-20-sprint-6-staging-blocker.md). - -## Deployment (Railway) — measured 01.08.2026 - -- The API runs as Railway service `EasyTree` (project `EasyTree`, environment `production`), - public domain `easytree-production.up.railway.app`. `/health` and `/ready` are the - operational endpoints; the root path `/` is **not** a health check — a 404 there is fine - for a pure API service, a 502 is not. The worker is deliberately **not** a Railway service - yet, and the web app is not deployed against this API yet. -- Builder is Railpack; build and start commands come from the service variables - `RAILPACK_BUILD_CMD` / `RAILPACK_START_CMD`. `PORT` = `API_PORT` = 3001. -- The direct Supabase DB host is **IPv6-only** (AAAA record, no A record). The service needs - outbound IPv6 enabled, otherwise connections die with `ENETUNREACH`. This Mac has no IPv6 - route — measurements against that host run via the DYAI VPS. -- TLS chain verification runs against `DATABASE_SSL_ROOT_CERT` (Supabase Root 2021 CA, - cryptographically verified against the live chain before use). See the config section - above for why the factory strips the whole URL query. -- The hosted customer DB carried **twelve** migrations as of 01.08.2026 (`supabase db push ---db-url`, verified by reading `supabase_migrations.schema_migrations` — count 12) and the - role `easytree_app` (NOSUPERUSER, NOBYPASSRLS, NOINHERIT, LOGIN; password provisioned out - of band, stored nowhere but the Railway variable). On the hosted EasyTree customer - project, measured on 01.08.2026, `postgres` was not a superuser but carried - **BYPASSRLS** — the EYT-45 start gate refuses it by design, so `DATABASE_URL` must - connect as `easytree_app`. `seed.sql` is synthetic dev data and was **not** applied - to production. -- **That "twelve" is a dated measurement, not a current one, and the repository has moved past - it.** Measured 14.08.2026: `origin/master` carries **17** migrations, and - `feat/eyt-109-daily-plan-cost-snapshot` adds `0018_cost_snapshots` for **18**. How many of - them are applied to the hosted DB is **not measured here** — do not infer it from the file - count. Count the files with `ls -1 supabase/migrations/*.sql | wc -l`; the production side - needs its own read of `supabase_migrations.schema_migrations`. -- Evidence for "it runs" (01.08.2026): single Nest boot, role gate passed, `/health` and - `/ready` HTTP 200 with `database: true`, 11/11 probes over 5m20s Online, zero restarts, - zero `self-signed` and zero pg SSL warnings in the deployment log. +**Staging läuft seit dem 24.08.2026 auf dem VPS** (`srv1308064.hstgr.cloud`, Coolify-Service +`easytree`/`staging`, zieht die GHCR-Digests aus `release-images.yml` statt zu bauen): +`BLOCKER_ENVIRONMENT_SEPARATION` ist dort durch eine **VPS-lokale** Supabase-CLI-Datengrenze +ohne Cloud-`project_ref` aufgelöst — owner-freigegeben, Produktionsdaten unberührt. **Für ein +cloudseitiges Staging gilt der Blocker fort:** es gibt kein zweites Supabase-Cloud-Projekt, und +die Free-Quota zählt **pro Nutzer** über alle Organisationen (ein pausiertes Projekt zählt nicht +mit, das pausierte „Bazodiac“ zu löschen hilft also nicht) +([`docs/plans/2026-08-20-sprint-6-staging-blocker.md`](docs/plans/2026-08-20-sprint-6-staging-blocker.md)). +Drei Messungen, die den ersten Deploy gekostet haben: **Staging nur über HTTPS abnehmen** — über +plain HTTP scheitert jeder Browser-Schreibvorgang still an `crypto.randomUUID` (kein Secure +Context; Lesen und Login gehen, `pageerror` im Trace lesen, nicht den Server verdächtigen), und +die Staging-Suite verweigert eine Nicht-HTTPS-`EYT_STAGING_URL`. Veröffentlichte Planversionen +sind per Trigger auch für Superuser unveränderlich — Journey-Wochen sind Verbrauchsmaterial, +„zurücksetzen“ heißt neue Woche oder voller `db reset`. Und der Rollback ist als Coolify-Stop → +`docker start` des Vorgängers → Coolify-Start **geübt**, Coolifys eigene Redeploy-Historie noch +nicht. Protokoll und Abweichungen (API im `development`-Preset, Host-Netz für API/Worker): +`docs/plans/2026-08-24-vps-staging-deploy-protokoll.md`, Evidenz +`docs/evidence/2026-08-25-eyt-142-staging/`. Der Demo-Mandant kommt aus +`scripts/ops/bootstrap-demo-tenant.mjs` (idempotent, `--verify`); Stundensätze entstehen +ausschließlich über die Oberfläche. + +**Railway bleibt Sekundaerziel — und dort laeuft gerade nichts.** Gemessen 23.08.2026 antworten +`/`, `/health` und `/ready` unter `easytree-production.up.railway.app` alle mit Railways +Edge-404 `{"status":"error","code":404,"message":"Application not found"}`; das ist die +Plattform, nicht die Anwendung. Ob der Dienst im Railway-Projekt noch existiert, ist **nicht** +gemessen. Der folgende Absatz ist die Messung vom 01.08.2026 und beschreibt, wie es lief: +die API lief als Dienst `EasyTree` +(Umgebung `production`, Domain `easytree-production.up.railway.app`); Builder ist Railpack, Bau- +und Startbefehl kommen aus `RAILPACK_BUILD_CMD` / `RAILPACK_START_CMD`, `PORT` = `API_PORT` = 3001. +`/health` und `/ready` sind die Betriebsendpunkte — **der Wurzelpfad `/` ist keine +Gesundheitspruefung**: dort ist ein 404 fuer einen reinen API-Dienst in Ordnung, ein 502 nicht. +Der Worker ist bewusst **kein** Railway-Dienst, und die Web-App ist nicht gegen diese API +deployt. **Wieviele Migrationen auf der gehosteten Datenbank liegen, ist hier NICHT gemessen — +leite es nicht aus der Dateizahl ab.** Dateien zaehlen mit +`ls -1 supabase/migrations/*.sql | wc -l`; die Produktionsseite braucht ihren eigenen Blick in +`supabase_migrations.schema_migrations`. + +Der direkte Supabase-DB-Host ist **IPv6-only**; dieser Mac hat keine IPv6-Route, Messungen dagegen +laufen über den VPS. ## Testing notes - **Every test that asserts a _property_ needs a named counter-mutation that turns it red.** Without a concrete answer to "which deliberate mutation makes this test fail?", the test is - execution evidence, not proof. Prefer a **permanent scenario** in the suite (a deliberately - open probe table, a foreign-tenant row, a stopped process) over a throwaway edit to source — - a mutation you reverted never runs again. This rule exists because Sprint 3 shipped nine - green tests that measured nothing (a conformance test that checked its own re-implementation; - an idempotency test that asserted a constant stub; a base-path test that compared a constant - to itself; a seed check that printed "KAPUTT" and exited 0). Seven were caught by review, not - by the suite. Worked examples of the rule applied: `architecture-red-case.test.ts` and the - meta-gate probe inside `db-gates`. Related traps: a Zod schema nobody `parse`s is a comment, - and an `as` cast under the word "validated" is erased at compile time. -- Vitest per package; `test/**/*.test.ts(x)`. Files under `test/` without a `.test.ts` suffix - (e.g. `tenant-context.helper.ts`, `test/architecture/{scan,rules}.ts`) are shared helpers, - not suites. + execution evidence, not proof — and the mutation must be **executed**, not imagined. Prefer a + **permanent scenario** (an open probe table, a foreign-tenant row, a stopped process) over a + throwaway source edit: a mutation you reverted never runs again. Sprint 3 shipped nine green + tests that measured nothing. Related traps: a Zod schema nobody `parse`s is a comment, an `as` + cast under "validated" is erased at compile time, and `as unknown as SomeGateway` erases the + port-completeness check, so a stub can miss a method with `typecheck` green. One gap stays open: + a test written _after_ the code has no red phase, and nothing yet requires a substitute — this + rule is the manual stand-in. - **Architecture boundaries are enforced by a test, not by convention** (EYT-46). - `apps/api/test/architecture.test.ts` scans every `.ts(x)` under `apps/` and `packages/`, - extracts imports with `ts.preProcessFile` (regex would miss `export *`, dynamic `import()` - and type-only forms) and applies the rules in `test/architecture/rules.ts`. It runs inside - the existing `unit-tests` job — deliberately no new required check, so the ruleset needs no - admin re-apply. `domain-allowlist` is an **allowlist**: domain code may import only relative - paths inside its own `domain/` layer plus `@easytree/domain`. A blocklist cannot express - ADR-001's universal ban — `http`, `pg-pool`, `kysely/dist/esm/index.js`, `rxjs` and `typeorm` - all slip past the obvious patterns. Scope is registry-driven via `SCAFFOLDED_MODULES` - (`apps/api/src/modules/module-catalogue.ts`), and each rule asserts it saw ≥1 file, so a - rename or a bad glob turns the suite red instead of silently green. `architecture-red-case.test.ts` - proves each rule fires, against a synthetic tree in `os.tmpdir()` — never the real one. -- **Domain invariants are covered by a registry, not by a list of green tests** (EYT-61 AC1). - `apps/api/test/domain-invariants/registry.ts` names every invariant and the positive _and_ - negative test that proves it; `domain-invariant-coverage.test.ts` measures four things: every - value export of `@easytree/domain` appears in the registry, no entry names a non-existent - export, every rule has both directions (only `konstante` may skip the negative), and each - named test title occurs verbatim in the named file — so a rename or a deleted test goes red - instead of silently green. A "negative test" here means a case that would **disprove** the - invariant if it did not hold, not merely a test expecting an error. It lives under - `apps/api/test/` rather than in `packages/domain` because that package sets `"types": []` and - has no dependencies by design; giving it Node types to read files would breach the very wall - the package is. -- **Property tests use fast-check with a fixed seed** (`packages/domain/test/*.property.test.ts`, - EYT-61 AC7). Deterministic sweeps stay, but they never leave their own grid — the existing - `time-interval` sweep never left August and said nothing about DST, year boundaries or - millisecond edges. fast-check is a **devDependency only**: `domain-allowlist` scopes to - `packages/domain/src/`, not `test/`, so the shipped package stays dependency-free. The fixed - seed is the point — a red run in CI reproduces locally with no extra argument; when one fails, - fast-check reports the shrunk counterexample plus `seed` and `path`. + `apps/api/test/architecture.test.ts` runs the **eleven** rules in + `apps/api/test/architecture/rules.ts` and extracts imports with `ts.preProcessFile`, because a + regex would miss `export *`, dynamic `import()` and type-only forms. `domain-allowlist`, + `ui-dependency-allowlist` (`packages/ui/src` may import react and relative paths only — a + primitive gets its link element, formatter or data as a prop) and `feld-shell-boundary` (the + Feld shell may reach react, next, `@easytree/ui`, `@easytree/contracts` and named `lib/` + infrastructure, never Werkbank components) are **allowlists** — a blocklist cannot express a + universal ban. Each rule asserts it saw ≥1 file, and the scope assertions inventory the + directories independently, so a rename or bad glob turns the suite red instead of silently + green. It runs inside the existing `unit-tests` job — deliberately **no new required check**, so + the ruleset needs no admin re-apply. `architecture-red-case.test.ts` proves each rule fires, + against a synthetic tree in `os.tmpdir()` — **never the real one**. Generated build output + (`.open-next/`, `.wrangler/`) must stay excluded from every tree-scanning guard, + ESLint and Prettier alike. +- **Domain invariants are covered by a registry** (EYT-61 AC1): + `apps/api/test/domain-invariants/registry.ts` names every invariant with the positive _and_ + negative test that proves it, and the coverage test goes red on a missing, non-existent or + renamed entry. Every rule needs **both** directions — only `konstante` may skip the negative. The + registry lives under `apps/api/test/` and not in `packages/domain`, because that package sets + `"types": []` by design; giving it Node types to read files would breach the very wall it is. "Negative" means a case that would **disprove** the invariant, not just one + expecting an error. +- **Property tests use fast-check with a fixed seed**, so a red CI run reproduces locally and + reports the shrunk counterexample plus `seed` and `path`. It is a **devDependency only** — + `domain-allowlist` scopes to `packages/domain/src/`, so the package stays dependency-free. + Under five parallel vitest processes those runs exceed 5000 ms and `turbo run test` stops at + the first red package, leaving `api`/`web` unmeasured — locally use `--continue --concurrency=1`. - **The DST-gap test is a static guard, and honestly labelled as one** - (`apps/api/test/no-local-time-construction.test.ts`, EYT-61 AC6a). The missing hour can only - arise if something builds an instant out of wall-clock parts; nothing in the repo does — time - enters as a UTC instant and is converted to a zone only for display (`Intl` with an explicit - `timeZone`). So a test that "covers" the gap is not possible today; what is possible is - ensuring the property is not lost unnoticed. The moment an input field accepts local time, that - test fails, and whoever adapts it must decide what "02:30 on 29 March" means. -- **Module table ownership is documentation, not enforcement.** `TABLE_OWNERSHIP` records - intent; at runtime there is exactly one application role (`authenticated`) and RLS filters by - tenant, not by module, so any module's code can write any table inside its tenant. Do not - report ADR-001's "Tabellenbesitzregeln werden in CI geprüft" as satisfied. -- `apps/api` transforms with **SWC**, not esbuild/oxc — NestJS needs legacy decorators plus - `design:paramtypes` metadata (`apps/api/vitest.config.ts`). -- `apps/web` and `packages/ui` run in jsdom; web sets `oxc.jsx.runtime = "automatic"` because - Next's tsconfig uses `jsx: "preserve"`. -- Playwright (`apps/web/e2e/`) runs against the production build (`next start` on 3000) with - axe; it is not part of `pnpm test`. It starts that server itself - (`reuseExistingServer: false`), so port 3000 must be free. -- `scripts/smoke-worker.sh` distinguishes three states (EYT-70): a listening socket belongs to - the worker (red), the check ran and found none (green), or **the check could not run at all - (red, not "no listener")**. Probes in order: `/proc` (authoritative for a process we started), - `lsof` (macOS/BSD), `ss` (fallback). It no longer passes vacuously on macOS — `lsof` gives a - real answer there, and a probe that errors or returns nothing fails the smoke. Two test hooks - make the red case reproducible: `EASYTREE_SMOKE_PROBE_PID` checks one pid without starting a - worker, `EASYTREE_SMOKE_FORCE_PROBE=proc|lsof|ss|none` forces a method. Caveat: the - end-to-end smoke still needs a reachable database, because the worker refuses to boot without - one — so on a machine with no Supabase stack, only `db-gates` exercises the full path. -- Automated a11y lives in `apps/web/test/a11y.test.tsx` (jsdom + axe) and, since Sprint 2, in - `apps/web/e2e/shell-smoke.spec.ts` (real Chromium, CI job `web-smoke`). In - `docs/runbooks/a11y-checklist.md`, items 1–6 are passed and regression-guarded; only item 7 - (screenreader smoke with VoiceOver/NVDA) is open — it needs a human with assistive tech, so - no "accessibility fully verified" claim is available yet. - -## Operator-side agent tooling (not part of this repo) - -Two global Claude Code skills were re-triggered on 27.07.2026 after the Sprint 3 review found -that both already covered the failures but never fired — the gap was discovery, not content. -They live in `~/.claude/skills/` on Benjamin's machine, so a fresh clone elsewhere will not -have them; the rules they carry are duplicated above in _Testing notes_ and _Conventions_, -which is where this repo's copy of record is. - -- `testing-anti-patterns` — now triggers when **writing** tests, not only when reviewing them. -- `verification-before-completion` — now triggers before **any push, commit or PR**, not only - before a completion claim. (Its description was also truncated mid-word in the source file - and has been repaired.) - -Both were left content-unchanged. The one real gap remains open: a test written _after_ the -code has no red phase, and nothing yet requires a substitute for it — see the counter-mutation -rule under _Testing notes_, which is the manual stand-in. + (`apps/api/test/no-local-time-construction.test.ts`): nothing here builds an instant out of + wall-clock parts, so the missing hour cannot arise and cannot be "covered". The moment an input + field accepts local time it fails, and whoever adapts it must decide what "02:30 on 29 March" + means. +- **Module table ownership is documentation, not enforcement.** At runtime there is one + application role (`authenticated`) and RLS filters by tenant, not by module — do not report + ADR-001's "Tabellenbesitzregeln werden in CI geprüft" as satisfied. +- Vitest per package (`test/**/*.test.ts(x)`; files without that suffix are helpers, not suites). + `apps/api` transforms with **SWC** — NestJS needs legacy decorators and `design:paramtypes`; + `apps/web` and `packages/ui` run in jsdom with `oxc.jsx.runtime = "automatic"`. In jsdom + `import.meta.url` is not a `file://` URL, so build file paths from `process.cwd()` (= the + package), and vitest swallows `console.log` unless `--disableConsoleIntercept` is set — put + measured counts into the assertion, not into a log line. Playwright (`apps/web/e2e/`) runs + against the production build, is not part of `pnpm test`, and starts its own server — port + 3000 must be free; a fresh worktree has no Playwright browsers (100 % red = missing binary, + not a regression), and removing a `data-testid` breaks a journey that only CI runs — grep + repo-wide first. +- `scripts/smoke-worker.sh` distinguishes three states (EYT-70): a listening socket belongs to the + worker (red), the check ran and found none (green), or **the check could not run at all (red, + not "no listener")**. `EASYTREE_SMOKE_PROBE_PID` and `EASYTREE_SMOKE_FORCE_PROBE` keep the red + case reproducible. Probes run in order `/proc`, `lsof` (macOS/BSD), `ss`. The end-to-end smoke + needs a reachable database, so on a machine without a Supabase stack only `db-gates` exercises + the full path. +- Automated a11y lives in `apps/web/test/a11y.test.tsx` (jsdom + axe), the browser axe runs in + `shell-smoke.spec.ts`, `read-through.spec.ts`, the auth journey and the staging journey. + Items 1–6 of `docs/runbooks/a11y-checklist.md` are regression-guarded; item 7 (screenreader + smoke) needs a human with assistive tech — no "accessibility fully verified" claim is available + yet. Two jsdom traps: `EmptyState`/`LoadingState` carry `role="status"`, so a + `findByRole("status")` in the same view is ambiguous — anchor on named elements; and + `document.activeElement` is not `:focus` for date/time inputs. ## Conventions - TypeScript is strict plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax`, `module: NodeNext` (`tsconfig.base.json`). ESM packages - (`config`, `ui`) need `.js` extensions on relative imports. -- Commits: conventional prefix + Jira ID, e.g. `feat(ci): EYT-67 …`, `test(web): … (EYT-41)`. -- **Three delivery checks, one command each — each of these failed twice in Sprint 3.** - (1) After a scripted text replacement, grep the _result_; the script's exit code says nothing - about whether the pattern still matched (Prettier had reformatted the target, the replacement - silently did nothing, and the commit message claimed it anyway). (2) Stage named files, then - read `git diff --cached --name-only` — a directory-wide `git add` twice swept in dozens of - untracked `docs/ux/` files. (3) Read the gate output before `git push`; `vitest` alone is not - enough, a type error in a test file only surfaces under `tsc`. All three produce false claims - in commit messages and PR bodies, which are later read as evidence. -- Docs and most code comments are German; the PRD/handoff artifacts are the exception. - Match the surrounding language of the file you're editing. + `verbatimModuleSyntax`, `module: NodeNext`. ESM packages (`config`, `ui`) need `.js` on relative + imports. +- Commits: conventional prefix + Jira ID, e.g. `feat(ci): EYT-67 …`. +- **Three delivery checks, one command each.** (1) After a scripted text replacement, grep the + _result_ — the exit code says nothing about whether the pattern still matched. (2) Stage named + files, then read `git diff --cached --name-only` — a directory-wide `git add` sweeps in + untracked noise. (3) Read the gate output before `git push`; `vitest` alone misses a type error + in a test file. All three otherwise produce false claims in commit messages and PR bodies, + later read as evidence. +- Docs and most code comments are German; the PRD/handoff artifacts are the exception. Match the + surrounding language. - `packages/shared` is forbidden (ADR-001) — share only via stable, named packages. -- Untracked working dirs at the repo root: `penpot/` is a separate upstream git clone (design - tooling, not project source) and `_sprint2-transfer/` holds git bundles. Neither is - prettier-ignored, so `pnpm format` locally may report files CI never sees — scope with - `pnpm exec prettier --check apps packages docs` when that gets noisy. -- The working tree also carries **untracked root copies** of four documents that now live in - `docs/`: `ADR-001-boilerplate-architecture.md`, `VALIDATION.md`, `agent_handoff_v1.3.md` - (byte-identical to their `docs/` counterparts) and - `jira_epic_post_mvp_planungsoekonomie_maschinenverleih.md` (**diverged** from - `docs/backlog/post-mvp/jira_epic_planungsoekonomie_maschinenverleih.md`). A root-level grep - therefore returns doubled hits; the tracked `docs/` file is authoritative in every case. - Never edit or cite the root copies. +- Four `docs/` documents also exist as **tracked** root copies (measured 23.08.2026 — they are in + every clone, not just this tree), so a root-level grep returns doubled hits. Three pairs are + byte-identical; the one that **diverges** is `agent_handoff_v1.3.md` (33 lines against 171) — + and it is the stale side, still naming `DYAI2025/Arborga.git` as the canonical repository, + which ADR-002 replaced. Cite the `docs/` file, never a root copy. Two more untracked root dirs: `penpot/` is a separate upstream git clone (design + tooling, **not project source**) and `_sprint2-transfer/` holds git bundles. Both **are** + covered by `.gitignore` and therefore by Prettier — they are not the source of local format + noise (_Commands_). **The tracked `docs/` file is authoritative** — never edit or cite one. +- `docs/context/.active-feature` is a Plumbline scope marker and is **armed** (it names a + feature). The operator-side Plumbline hook reads it and blocks shell commands outside the + declared scope — a stale marker silences the shell without saying why, so check it before + suspecting the tooling. `scripts/plumbline-scope-guard.sh` is the repo-side fail-closed checker + (exit 3 = violation, 4 = cannot run safely; `--self-test` runs its own counter-mutation). + +## Compact Instructions + +These survive verbatim, or the next turn works blind: + +- **The open ticket IDs** (`EYT-…`) and which acceptance criteria are evidenced, which are not. +- **The exact head SHA and branch.** Merge approvals are SHA-bound: any commit after an approval + invalidates it. +- **Which CI run was measured, by id and at which head** — and whether commits landed since, + which would mean it no longer covers the head. +- **Which counter-mutation is still outstanding**, and for each one done: that it was executed, + went red, and was reverted (scoped `git diff` on the mutated file). +- **The evidence level of every claim carried forward** (executed CI run > local run > subagent + report > plan). A claim without its command is not a result. +- **Named blockers still open** (e.g. `BLOCKER_ENVIRONMENT_SEPARATION` for cloud staging) and + what the user has and has not approved. + +Drop instead: the narrative of what was tried, output already condensed into a number, and any +"looks correct" verdict — re-measure it. + +## Projektgedaechtnis + +`~/.claude/projects/-Users-benjaminpoersch-EasyTree/memory/MEMORY.md` indexiert die **gemessenen +Fallen und Werkzeugverhalten** dieses Projekts — was einmal Zeit gekostet hat und sonst wieder +zuschlägt (still grüne Filter, Turbo-/vitest-/pgTAP-/Supabase-/`gh`-Eigenheiten, Messfehler). +**Produktwissen gehört nicht dorthin:** Scope, Architektur, Entscheidungen und Autoritäten stehen +in `docs/` (MVP-Scope in Confluence) und werden von dort zitiert.