# CLAUDE.md Agent guide for this repo. The _why_ lives in `docs/reference/architecture/ARCHITECTURE.md`, `docs/reference/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide `docs/reference/fp-tea-atomic-design.md` (FP + The Elm Architecture + atomic design); this file is the _rules_. When a decision below and those docs disagree, the docs win — update this file. POC of a Dutch BIG-register self-service portal (healthcare professionals log in, view their registration, apply for re-registration). Angular 22, standalone, signals. Auth is faked; **data and business rules are served by a minimal ASP.NET Core backend** (`backend/`, see its README) and consumed through an NSwag-generated typed client. The FE renders the backend's decisions. Reference data mimicking BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief persist to a SQLite file via EF Core (WP-22) — `docs/project/archive/backlog/WP-22-durable-persistence.md`. **Monorepo (WP-67):** two Angular projects share one backend + one shared library — `apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal` (Behandelaar backoffice, ADR-0002). Both import `libs/shared` (design system + kernel + generated API client) and `libs/beheer` (the admin/stamdata context, used identically by both). `backend/` is unowned by either — a genuinely shared dependency. ## Commands ```bash npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200 npm run start:behandelportal # ng serve behandelportal → http://localhost:4201 npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer) npm run lint # eslint — enforces `any`-free code + import/layer boundaries npm run build # ng build ssp && ng build behandelportal (must stay green) npm run storybook # ssp's component library by atomic layer npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below) npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y docker compose up # run both FE apps + backend together (Swagger at :5000/swagger) cd backend && dotnet test # backend rule + endpoint tests task # list every task (a thin facade over the commands above) ``` **Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own `auth` context at the same `@auth/*` alias pointing at different physical directories — a single merged tsconfig can't resolve both at once, so `.storybook-ssp/` and `.storybook-behandelportal/` are separate config dirs (`npm run storybook[:behandelportal]` / `build-storybook[:behandelportal]`), each globbing its own app's stories + both shared libraries'. **Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend format+test, api-client drift), so a red build is caught locally. Two ways to make it automatic: `npm run ci` by hand, or enable the opt-in hook with `git config core.hooksPath scripts/githooks` (runs it on `git push`; bypass once with `--no-verify`). The e2e + storybook-a11y jobs need a browser/servers — run `--full` for storybook-a11y; e2e separately (the script prints how). **Second-locale gate:** `messages.en.xlf` is a hand-maintained translation of every `$localize` id; `ng build --localize` fails (via `i18nMissingTranslation: error`) if any id lacks an English ``. Add one whenever you add a `$localize` string — `npm run ci` catches a miss before CI does. `.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22). Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only advisories are pinned via `package.json` `overrides`. Angular is pinned to the exact version 22.0.5: 22.1.x emits `var(--%NS%name)` and breaks every `--rhc-*` token, so the two moderate advisories it fixes stay open. Neither is reachable, so the audit gate runs at `--audit-level=high` (see the comment in `ci.yml`). ## Model routing for agent delegation Three custom agents in `.claude/agents/` pin the model to the step, not the whole session — so this doesn't depend on a human remembering to run `/model` at the right moment: - **`planner`** (Opus) — design/approach work: a WP's Decisions block, an ambiguous bug's root cause, sequencing a multi-file change. No Edit/Write access; hands back a plan. - **`developer`** (Sonnet) — implementation once the approach is settled: routine code against a pre-made plan, ending green (`npm run ci`). - **`task-runner`** (Haiku) — simple, read-only, mechanical checks: running a test suite, `git status`/`grep`, verifying a file exists. No Edit/Write access. Delegate to the matching agent only when the _current_ session isn't already on that model — don't add indirection for its own sake. `docs/project/archive/backlog/README.md`'s session protocol is the worked example of this in practice. ## The decisions (non-negotiable working agreements) ### 1. DDD: contexts then layers, dependencies point inward `apps//src/app///` for an app-local context; `libs//src//` for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service — contexts `auth`, `overzicht` (the portal home; composes `registratie`'s dashboard sections plus its own cross-context nav sections), `registratie`, `herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching page, not a feature; **sanctioned** to read every context in its own app — nothing imports it)) and `apps/behandelportal` (Behandelaar backoffice, ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the design system + kernel + generated API client — no business logic) and `libs/beheer` (the admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging duplicate copy back into one). `auth` is deliberately **not** shared even though today it's near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different `Principal` variants with different login flows; the two copies are expected to diverge. | Layer | Job | Angular allowed? | | ----------------- | ----------------------------------------- | -------------------------------- | | `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` | | `application/` | coordinate state/tasks (stores, commands) | yes (signals) | | `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) | | `contracts/` | wire DTOs (the FE⇄BE seam) | no | | `ui/` | how it looks (components, pages) | yes | **Dependencies only point inward**: `ui → application → domain`; every context in either app may use `libs/shared` and `libs/beheer`; never the reverse (`libs/shared` may not depend on `libs/beheer` either — it stays the base). `ui`/`layout` never import `infrastructure` directly (reach data through an application store/command) — lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's `.dependency-cruiser.base.js` + one thin `.dependency-cruiser..js` per app). An app may not import the other app's source directly. Cross-context only `overzicht → registratie → libs/shared|beheer`, `herregistratie → registratie → libs/shared|beheer`, `auth → libs/shared|beheer`, `brief → libs/shared|beheer` (ssp); `behandeling → libs/shared|beheer`, `auth → libs/shared|beheer` (behandelportal). Imports use aliases as direction statements: `@shared/* @beheer/* @auth/* @overzicht/* @registratie/* @herregistratie/* @brief/*` (ssp) — `@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own `tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no `paths` — see its comment). `domain/` imports nothing from Angular. ### 2. Atomic design: folder = layer `libs/shared/ui` atoms → molecules → organisms; `libs/shared/layout` templates (`shell`, `page-shell`); each app's own context `ui/` pages. Each level only uses levels below, and a shared component takes nav/copy as `input()`s or an injection token (e.g. `HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`, `DEBUG_PANEL` in `shell.component.ts`) rather than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page should be **composition of existing blocks** — adding building blocks is the exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API, the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`, `spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a `// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the vendored `.feedback feedback-*` classes.) **The step-component contract.** A wizard step follows the same rule as `address-fields.component.ts`: values in, events out, no internal state. Three clauses: 1. **Inputs down.** A step reads its data only from `input()`s the container passes it. 2. **One narrow output up.** A step emits one specific event, not the container's whole `dispatch`. 3. **`dispatch` is never passed down.** The container owns the Model and decides what a step's event means; a step never calls `dispatch` itself. Corollary: a step gets **no** story of its own. The wizard's own story already mounts every step, because it seeds the machine. ### 3. State: make illegal states unrepresentable Default reflex — **if you're about to add a second/third boolean to track state, model a discriminated union instead.** Three tools, all in `libs/shared/src/application`: - **`RemoteData`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`. Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success). Render it via the `` molecule (`libs/shared/src/ui/molecules/async`) — one of four templates, mutually exclusive by construction. Default loading spinner/skeleton is delay-gated (~250ms) so fast connections don't flash. - **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates send messages, never mutate. **`createStore` is the one wiring idiom** — a page never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level machine's State/Msg types are context-prefixed (`ChangeRequestState`, `ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports `initial` + `reduce`. A **composable sub-machine** embedded inside a parent model keeps prefixed _value_ exports instead (`initialUpload`/`reduceUpload`, see `upload.machine.ts`) — prefixing there avoids alias noise at the composition site. - **`Result` + value objects** ("parse, don't validate") — raw input becomes a branded type only via a parser returning `Result` (ssp's `registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it. **Derive, don't store** what you can compute — e.g. the wizard's visible steps are `visibleSteps(answers)`, not a stored field (`intake.machine.ts`). **Side effects stay out of the reducer.** A _command_ (`application/submit-*.ts`) does the HTTP, then dispatches a message describing the outcome. Reducer = "what the new state is"; command = "go do it, then say what happened." **Shared cross-page state = one root singleton.** Stores are `providedIn: 'root'` (`BigProfileStore`, `SessionStore`). That single instance is the shared state — no NgRx, no extra lib. Optimistic update pattern: `begin*` (flip pending) → `confirm*` (clear + `resource.reload()`) / `rollback*` (undo). ### 4. BFF-lite + decision DTOs (ADR-0001) `infrastructure/` is the **only** layer that touches the network — the anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a decision-enriched DTO; **the FE renders decisions, it does not recompute business rules.** Per rule, pick: _decision flag_ (server computes the boolean — e.g. herregistratie eligibility) or _config value_ (server sends threshold, FE applies for instant feedback, server re-validates as authority — e.g. scholing threshold). FE keeps only **format** validation, never as authority. The generated client (`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is** the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written `contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types it too loosely (the four survivors are all the latter — the generator emits every property as optional and flattens unions); such a file must still import nothing. Either way a hand-written `parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain — **a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned rules live **only** on the server, with no FE mirror to drift from it — the FE may mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but never reimplements the _algorithm_. **Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the business controls (profession↔diploma map, thresholds, policy-question text) live as typed C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in code, an unknown key fails closed, the value is operational rather than a shared business rule, and writes are admin-capability-gated **and** audited. Two surfaces pass it today — `OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004. ### 5. Testing Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters). Test the pure function directly — no Angular TestBed for domain. UI is exercised via Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests — each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-behandelportal/`, WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout` component is titled `Design System//`; a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled `Domein//` — full stop, regardless of which atomic layer it is (a context organism doesn't get its own `Organisms/` bucket). ## Conventions - Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over constructor DI (constructor only for `effect()`/template-ref injection). - Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives), with a `parse*` boundary; `withViewTransitions()` for page transitions (header/footer have stable `view-transition-name`, excluded from the fade). - **Naming:** shared/reusable UI is **English** (language-agnostic: `button`, `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, `*.machine.ts`). Pick the language by which side of the seam the code is on. - **English prose uses Simplified Technical English (STE).** This covers documentation, code comments, commit messages, ADRs, and the backlog notes. One idea per sentence; 20 words or fewer in a procedure, 25 in a description. Active voice, present tense. One word for one meaning — pick a term and repeat it, do not vary it for style. Keep articles ("the test fails"). Three nouns together at most. No idioms and no humour. Six sentences per paragraph at most. Write a procedure as numbered steps, one action per step. **STE governs form, not content.** Split a long sentence; never drop a caveat, a measurement, or a precise term to make it shorter. **STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or existing documents you are not already editing. - **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's first-party `$localize` (no third-party i18n lib), with a stable custom id (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a translation file, not a code change (the seam). Shared/English components must **not** hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller supplies the text (see `libs/shared/src/ui/molecules/async`). Format-validation messages in `domain/value-objects/` stay co-located but are still `$localize`-wrapped. - **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts` (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the same shape as the wizards, whether it's one step or many. Don't hand-roll mutable fields + ad-hoc error signals. - **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe (a domain function, a `$localize` string) uses the one hand-written `formatDatumNl` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled `toLocaleDateString` call. - Routes: lazy `loadComponent`, persistent `ShellComponent` parent (`libs/shared`), `canActivate: [authGuard]` on protected routes (each app's own `app.routes.ts`). - Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under `public/cibg-huisstijl/` and loaded via a `` in each app's `index.html`; `libs/shared/styles.scss` (one copy, both apps' `angular.json` point at it — WP-67) holds a **token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003. - Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error` on data pages (`scenario.interceptor.ts`) to see every async state — sticky per tab (change it via a full navigation, not an in-app link). Hand-written `fetch`/XHR calls (uploads, `/brief/preview`, `/admin/org-template/*/preview`, `/brief/reveal-bignummer`) bypass the interceptor. - Dev role stand-in (**dev-only**): `?role=drafter|approver|admin` (or the `⚙ state` dev panel). Roles, how to switch, and what each unlocks: `docs/reference/roles-and-access.md`. `admin` unlocks the capability-gated pages: `/brief/huisstijl` (org-template editor), `/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`. - Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`, `noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`. - **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to `{apps,libs}/**`) fails the build on `any`; the same config's `max-lines` rule caps every `{apps,libs}/**/*.{page,component,section,step}.ts` file at 250 lines (`skipBlankLines: true`, `skipComments: true`). 250 is reachable, not a style-guide default — the dashboard page lands at 42 lines. `npm run dep:check` (`.dependency-cruiser.base.js` + one `.dependency-cruiser..js` per app, WP-67) fails on illegal imports — `domain/` importing Angular, a context importing "upward" (the `herregistratie → registratie → shared`, `auth → shared` direction), an app importing the other app's source, or `libs/shared` depending on `libs/beheer`. CI (`.github/workflows/ci.yml`) runs lint + `dep:check` + `check:tokens` + test (both apps + both libraries) + build (both apps), backend `dotnet test`, and an API-client drift check (one generated client, `libs/shared/src/infrastructure/api-client.ts`). ## Adding a feature (recipe) Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter: `httpResource` or command returning `Result`) → application (store if shared state; union + pure reduce) → UI last (compose `libs/shared/ui` atoms, wrap async in ``, dispatch messages). Worked example: the SSP's intake wizard (`herregistratie/`). The recipes are also invocable skills in `.claude/skills/`: `new-feature`, `new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`, `ui-component`, `new-ssp` (bootstrap a new portal from this template), `document-feature` (ship/update docs in the same diff as the code). ## Out of scope (POC, don't build unprompted) Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark), runtime DTO validation on every endpoint, multi-tab session sync.