diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c878a72..9396d03 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -38,30 +38,39 @@ only needs re-running if a WP unexpectedly touches `backend/`. Gates land before the work they cover; each lint rule lands in the same WP as the fixes for its existing violations, so every WP ends green. -| WP | Title | Phase | Status | -| --------------------------------------- | ------------------------------------------------------------ | ------------- | ------ | -| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | -| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | -| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | -| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | -| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo | -| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo | -| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | -| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | -| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | -| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | -| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | -| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | -| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo | -| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo | -| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo | -| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | -| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo | +| WP | Title | Phase | Status | +| --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ | +| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | +| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | +| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | +| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | +| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo | +| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo | +| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | +| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | +| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | +| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | +| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | +| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | +| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo | +| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo | +| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo | +| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | +| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo | +| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | todo | +| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo | +| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo | +| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | todo | +| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | todo | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed `` before brief adopts it); 13 defines the gap-marker format that 11/12 reference -— if 11/12 run first, they define it and 13 adopts it. +— if 11/12 run first, they define it and 13 adopts it. 18–22 (phase 5, "productie-volwassenheid") +are independent of each other and of phases 1–4 — pick any order; **18 is the recommended +first pick** (it's the headline gap: no authorization spine exists yet, and it closes the +FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the +current in-memory persistence is a documented, defensible POC choice, not a bug. ## WP template diff --git a/docs/backlog/WP-18-abac-capability-spine.md b/docs/backlog/WP-18-abac-capability-spine.md new file mode 100644 index 0000000..fb52f5f --- /dev/null +++ b/docs/backlog/WP-18-abac-capability-spine.md @@ -0,0 +1,153 @@ +# WP-18 — ABAC capability spine (Principal + capabilities, phase P1) + +Status: todo +Phase: 5 — productie-volwassenheid + +## Why + +The single biggest gap between this POC and a production SSP: identity carries no +roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified +`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable` +computes its authorization gate **in the frontend** from that header — the exact +anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them). +The backend is fully open: no `[Authorize]`, no principal, ownership is a constant +`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; nothing is built. This +WP implements PRD-0002's **P1 — Capability spine** only (§9), the smallest slice +that closes the anti-pattern and gives every later phase (data-scoping, PII +redaction, step-up/audit) a real foundation to extend. + +## Read first + +- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal` + union, identity-vs-authorization split) +- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1 (this WP + implements exactly P1 — don't reach into P2/P3) +- `src/app/auth/domain/session.ts` (flat `Session` to replace) +- `src/app/auth/application/session.store.ts`, `src/app/auth/auth.guard.ts` (seams + that already localise the `Session → Principal` swap, per ADR-0002) +- `src/app/shared/domain/role.ts`, `src/app/shared/infrastructure/role.ts` + + `role.interceptor.ts` (the dev stub being retired as an authority, kept as a dev + toggle) +- `src/app/brief/application/brief.store.ts:28,41-46` (`readonly role = currentRole()` + and the `editable` computed — the FE-computed gate to remove) +- `backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27` (`HerregistratieDecisionsDto` + — the decision-flag pattern this WP extends to a `BriefDecisionsDto`) +- `backend/src/BigRegister.Api/Program.cs:318-335` (`IsAdmin`, the `X-Role` reader + around brief endpoints — becomes `Authz.Can`) +- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review`, the + `actingId == e.DrafterId → Forbidden` SoD check — keep this check, move it behind + the verified principal) + +## Decisions (pre-made, don't relitigate) + +- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or + audit log — those are PRD-0002 §9 P2/P3, separate future WPs. This WP: `Principal`, + `AccessStore`/`can()`, `capabilityGuard`, `GET /me`, capability flags on the brief + screen DTO, and server-side enforcement via one shared `Authz.Can` helper. +- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The + `Principal` is still built server-side from the existing dev stand-ins + (`X-Role`/`X-Admin` headers), but it becomes the backend's own construct — the FE + never re-derives capabilities from the header, it only reads what the backend sends. +- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — start with + exactly `brief:approve`, `brief:reject`, `brief:send` (the brief flow is the only + role-gated flow that exists today). Do not invent capabilities for flows that don't + exist yet (e.g. `aanvraag:beoordelen` — that's the backoffice, ADR-0002, out of scope). +- **Emit and enforce are the same code path.** `Authz.Can(principal, action, resource)` + is called both to compute the DTO flag and to gate the endpoint — never two separate + checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out). +- **Dev role toggle survives**, but moves behind the `Principal` seam: `?role=` still + picks an identity for demo purposes, but it flows into building the `Principal` + server-side (still asserted by the client — this is _not_ real auth, just moving + the authority from FE-computed to BE-computed within the POC's honesty envelope). + Keep it explicitly commented `// dev stub — NOT a security boundary` per PRD-0002 §3. + +## Files + +- `src/app/auth/domain/session.ts` — replace `Session` with the `Principal` + discriminated union from ADR-0002 (`{ kind: 'zorgverlener'; bsn; naam }` — no + `medewerker` variant yet, that's ADR-0002/backoffice scope; keep the union shape so + it's additive later). Update `isAuthenticated`. +- `src/app/auth/application/session.store.ts` — carries the `Principal`; unchanged + persistence rules (never persist the BSN, per existing comment). +- New `src/app/shared/domain/capability.ts` — branded/union `Capability` type + (`'brief:approve' | 'brief:reject' | 'brief:send'`), framework-free. +- New `src/app/shared/application/access.store.ts` — `providedIn: 'root'`, holds + resolved capabilities as `RemoteData` from `GET /me`; `can(capability): boolean`, + deny-by-default on absence. +- New `src/app/shared/infrastructure/me.adapter.ts` (+ spec) — calls `GET /me`, + `parseMe(): Result` boundary. +- New `capabilityGuard` in `src/app/auth/auth.guard.ts` (or co-located + `capability.guard.ts`) — factory `CanActivateFn` extending `authGuard`'s shape. +- `src/app/brief/application/brief.store.ts` — delete `readonly role = currentRole()` + and the FE-computed `editable`; read `canApprove`/`canReject`/`canSend` off the + loaded `BriefViewDto`'s new `BriefDecisionsDto` instead. +- `src/app/shared/infrastructure/role.interceptor.ts` — keep (still asserts the dev + identity), retitle its comment to "feeds Principal construction, not an authority + the FE reads back." +- Backend: `backend/src/BigRegister.Api/Contracts/Dtos.cs` — add + `BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanSend)`; add it to + `BriefViewDto`. +- New `backend/src/BigRegister.Api/Domain/Authorization/Principal.cs` + + `Authz.cs` — `Principal` (mirrors the FE union), `Authz.Can(principal, action)` + covering the three brief capabilities + the existing SoD rule. +- `backend/src/BigRegister.Api/Program.cs` — add `GET /api/v1/me` returning the + resolved `Principal`'s capabilities; replace the ad-hoc `X-Role` reads around + brief endpoints with `Authz.Can`; compute `BriefDecisionsDto` via the same helper. +- New backend test `backend/tests/BigRegister.Tests/AuthzTests.cs` — `Authz.Can` + unit tests (approve/reject/send × drafter/approver × SoD). + +## Steps + +1. Backend: `Principal`, `Authz.Can`, `GET /me`, `BriefDecisionsDto` wired into the + existing brief endpoints (replace `IsDrafter`/`X-Role` reads one at a time, + keeping `BriefEndpointTests.cs` green after each). +2. FE: `Capability` type, `access.store.ts`, `me.adapter.ts`, `capabilityGuard`. +3. FE: `Session → Principal` in `auth/domain`; thread through `SessionStore`, + `auth.guard.ts` (both keep working — `isAuthenticated` still means "has a + Principal"). +4. FE: `brief.store.ts` reads `canApprove`/`canReject`/`canSend` from the DTO; + delete `currentRole()` import and the FE-computed `editable`. Update the brief + UI components consuming `.editable`/`.role` to consume the new flags. +5. Update PRD-0002's own status: this WP completes phase P1 — note it in the PRD or + leave for a follow-up doc pass (don't rewrite the PRD's phasing table mid-WP). + +## Acceptance criteria + +- [ ] `brief.store.ts` contains no `currentRole()` call and no FE-computed + permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO. +- [ ] Forging `?role=approver` in the browser with a stale/absent server capability + still gets a 403 from the backend (verified by a test hitting the endpoint + directly, bypassing the FE). +- [ ] `Authz.Can` is the only place brief authorization logic lives; the emit path + (DTO flags) and the enforce path (endpoint gating) both call it. +- [ ] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an + unknown capability. +- [ ] The existing SoD rule (approver ≠ drafter) still holds, now expressed as an + `Authz.Can` precondition rather than inline in `BriefStore.Review`. +- [ ] `capabilityGuard` compiles and is demonstrated on at least one route (or + documented as available-but-unwired if no route needs it yet — brief has no + route today, it's a page section). + +## Verification + +GREEN (`docs/backlog/README.md`) + `cd backend && dotnet test`. Manual smoke: +`npm start` with `?role=drafter` — draft-only actions enabled; `?role=approver` — +approve/reject enabled, editing disabled. Confirm via browser devtools that removing +the `X-Role` header (or backend patched to ignore it) makes every capability `false` +— i.e. deny-by-default actually denies. + +## Out of scope + +PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit +log) — separate future WPs. The Behandeling/backoffice app and `medewerker` +`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real +AD/OIDC integration (identity provider stays simulated). + +## Risks + +Threading `Principal` through `SessionStore`/`auth.guard.ts` touches the one +authenticated-session seam every route depends on — keep the observable shape +(`isAuthenticated(): boolean`) identical so no route wiring needs to change, only +what's inside `Session`/`Principal`. Backend `Authz.Can` replacing inline `X-Role` +reads must preserve the existing 403 `Outcome.Forbidden` mapping +(`Program.cs:330-335`) exactly, or `BriefEndpointTests.cs` breaks. diff --git a/docs/backlog/WP-19-e2e-smoke.md b/docs/backlog/WP-19-e2e-smoke.md new file mode 100644 index 0000000..72413ca --- /dev/null +++ b/docs/backlog/WP-19-e2e-smoke.md @@ -0,0 +1,99 @@ +# WP-19 — Playwright e2e smoke + +Status: todo +Phase: 5 — productie-volwassenheid + +## Why + +There is no end-to-end test anywhere in the repo — no Playwright/Cypress config, no +`e2e/` directory. `axe-playwright` is already a dependency (used by +`test-storybook:ci` to run axe against Storybook, `.storybook/test-runner.ts`), but +nothing drives the actual running app through a real browser. The GREEN gate proves +every unit and component-in-isolation, never a real user flow through the FE+backend +wired together — the thing a demo/reference app should be able to prove first. + +## Read first + +- `README.md` "Run it" + "See every data state (scenario toggle)" — the flows to + cover +- `docker-compose.yml` (the two-service dev topology e2e can run against) +- `.storybook/test-runner.ts` (existing Playwright-adjacent config in the repo, for + browser-launch precedent, though it drives Storybook not the app) +- `src/app/shared/infrastructure/scenario.interceptor.ts` (the `?scenario=` toggle — + reuse it for the error-path test instead of mocking the network) +- `.github/workflows/ci.yml` (the `storybook-a11y` job's `playwright install +--with-deps chromium` step — same install pattern for a new e2e job) + +## Decisions (pre-made, don't relitigate) + +- **Playwright, not Cypress.** `axe-playwright` is already a dependency and the repo + already has one Playwright-based CI job (`storybook-a11y`); adding Cypress would + be a second, redundant browser-automation toolchain. +- **Smoke-level coverage only**: one happy-path flow end to end, one degraded-path + flow via `?scenario=`. This is not a full e2e suite — it proves the seam works, + it doesn't replace component/unit tests. +- **Run against the real backend**, not a mock server — the point is proving FE+BE + integration, which is exactly what unit tests (mocked adapters) don't cover. +- Faked auth (`digid.adapter.ts`) is used as-is: e2e logs in with any 9-digit BSN, + no special e2e auth bypass. + +## Files + +- New `playwright.config.ts` at repo root — `baseURL` from an env var (default + `http://localhost:4200`), `webServer` config that can optionally boot `ng serve` + (skip if `CI` already starts the app in a prior step — see Steps). +- New `e2e/smoke.spec.ts` — the happy path. +- New `e2e/error-state.spec.ts` — the `?scenario=error` path. +- `package.json` — add `"e2e": "playwright test"` script; `@playwright/test` devDependency. +- `.github/workflows/ci.yml` — new job `e2e`, steps: checkout, setup-node, setup-dotnet, + `npm ci`, `npx playwright install --with-deps chromium`, start backend + (`dotnet run --project backend/src/BigRegister.Api &`), `npm start &` (or `ng +serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15` + per the hardened workflow convention already in `ci.yml`. + +## Steps + +1. Install `@playwright/test`; scaffold `playwright.config.ts` with a single + `chromium` project (match `test-storybook:ci`'s browser choice). +2. `e2e/smoke.spec.ts`: navigate to `/login`, submit a BSN, land on `/dashboard`, + assert real dashboard content renders (not a loading/error state), navigate into + one wizard (herregistratie or registratie change-request), fill the minimum + required fields, submit, assert a success state. +3. `e2e/error-state.spec.ts`: navigate to `/dashboard?scenario=error`, assert the + error alert + "Opnieuw proberen" button render (``'s error slot), + click retry, assert it re-fetches (scenario is per-request so a retry without the + query param would succeed — confirm the interceptor's actual behavior first and + assert accordingly). +4. Wire the CI job; verify it's independent of (doesn't block or get blocked by) the + existing jobs — add to `concurrency`/`timeout-minutes` conventions already in `ci.yml`. +5. Document `npm run e2e` in `README.md`'s command list. + +## Acceptance criteria + +- [ ] `npm run e2e` passes locally against `docker compose up` or `npm start` + + `dotnet run` run manually. +- [ ] CI job `e2e` is green and runs on every PR alongside the existing jobs. +- [ ] The happy-path spec exercises a real wizard submit against the real backend + (not mocked) and asserts on the resulting UI state. +- [ ] The error-path spec exercises ``'s error slot + retry via the real + `?scenario=error` toggle, not a mocked HTTP response. + +## Verification + +`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears +and passes. Cross-check that a deliberately broken flow (e.g. temporarily rename a +required form field) fails the e2e spec, proving it isn't a no-op. + +## Out of scope + +Full e2e coverage of every wizard/flow; visual regression testing; cross-browser +matrix (chromium only, matching the existing a11y job); load/performance testing. + +## Risks + +The `?scenario=` interceptor is dev-only (`isDevMode()` gated, per +`app.config.ts`) — confirm the e2e target build runs in dev mode (it does via `ng +serve`/`npm start`; a production `ng build` would need the toggle unavailable, +which is correct and should be asserted, not worked around). Backend +in-memory stores mean e2e runs against a fresh seed each restart — don't assert on +data that a previous test run could have mutated; restart the backend per CI run. diff --git a/docs/backlog/WP-20-second-locale.md b/docs/backlog/WP-20-second-locale.md new file mode 100644 index 0000000..a1ed551 --- /dev/null +++ b/docs/backlog/WP-20-second-locale.md @@ -0,0 +1,101 @@ +# WP-20 — Second locale proof + +Status: todo +Phase: 5 — productie-volwassenheid + +## Why + +CLAUDE.md's conventions claim "a second locale is a translation file, not a code +change (the seam)" — every user-facing string is already wrapped in `$localize` +with a stable `@@context.key` id. But `angular.json` has no `i18n` block, no +`locales` config, and there is no extracted `.xlf` file anywhere in the repo. The +seam is built into every component but never proven to actually work end to end. + +## Read first + +- `CLAUDE.md` "User-facing copy = `$localize`" convention +- `angular.json` (current build config — no `i18n` section) +- A handful of `$localize` call sites to confirm id conventions are consistent + enough to extract cleanly: `src/app/shared/application/submit.ts` + (`@@submit.failed`), `src/app/registratie/domain/value-objects/postcode.ts` + (`@@validation.postcode`) +- Angular's `@angular/localize` extraction tooling (`ng extract-i18n`) — no new + dependency needed, it ships with the Angular CLI already in use + +## Decisions (pre-made, don't relitigate) + +- **English (`en`) is the second locale** — arbitrary but concrete; proves the + mechanism without requiring a real translator. Machine-translate or hand-write a + handful of strings, mark the rest with an obvious placeholder prefix if time-boxed + (e.g. `[EN] ` prefix) rather than leaving them silently untranslated — silent + fallback-to-source would look like the feature works when it's actually untested. +- **Source locale stays `nl`**, unchanged (CLAUDE.md is explicit about this). +- **Build-time locale switching** (Angular's standard `i18n` merge, separate output + per locale), not a runtime-swappable locale — that matches how `$localize` + + Angular CLI actually work and avoids inventing a custom i18n runtime. +- This WP proves the seam; it does not translate the whole app to production + quality. A partial/placeholder `en` file is acceptable if every string has _some_ + translation (even if imperfect) — the acceptance bar is "the build seam works and + every id resolves," not "the English copy is publication-ready." + +## Files + +- `angular.json` — add `i18n.sourceLocale: "nl"` and `i18n.locales.en` pointing at + the new translation file; add an `en` configuration under `build`/`serve` that + merges it (standard Angular CLI i18n scaffolding, `ng add @angular/localize` if + the schematic isn't already fully wired). +- New `src/locale/messages.en.xlf` (or `.json`, whichever `ng extract-i18n` + defaults to) — the translation file, generated then filled in. +- `package.json` — add `"extract-i18n": "ng extract-i18n --output-path src/locale"` + script. +- `.github/workflows/ci.yml` — extend the `frontend` job (or add a step) to build + both locales: `ng build --localize` (builds all configured locales in one pass) + or two explicit `ng build --configuration=production,en` invocations — pick + whichever the Angular 22 CLI supports cleanly and document the choice inline. +- `README.md` — note the second-locale build under "Tech notes," replacing the + implicit claim with a demonstrated one (link to how to build/run the `en` locale). + +## Steps + +1. Run `ng extract-i18n` once to generate the master translation file from every + `$localize`/`i18n="@@id"` call site; commit it as the `nl` reference (or the tool's + default source-language artifact, per Angular's convention). +2. Copy it to `messages.en.xlf`, fill in English text for every `` + (or your chosen placeholder strategy per the Decisions above). +3. Wire `angular.json`'s `i18n` block + an `en` build configuration. +4. `ng build --localize` (or the two-configuration equivalent) — confirm two output + bundles (`dist/.../nl/`, `dist/.../en/`) each serve correctly with `ng serve +--configuration=en` or a static server against the `en` output. +5. Wire CI to build both locales as part of the existing `build` step (or a + parallel step) so a broken translation file fails CI, not just a local build. +6. Spot-check the `en` build in a browser: login page, dashboard, one wizard step — + confirm English strings render, layout doesn't break on longer/shorter text. + +## Acceptance criteria + +- [ ] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s). +- [ ] `messages.en.xlf` exists with a translation for every extracted unit. +- [ ] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output + bundle in CI, and CI fails if the `en` file is missing a unit the source gains. +- [ ] Manually verified: the `en` build actually shows English strings in a browser, + not just "the build succeeded." + +## Verification + +`npm run extract-i18n` locally, diff against the committed file to confirm no drift; +`ng build --localize` locally, serve the `en` output, click through login → +dashboard → one wizard. GREEN gate stays green (the `nl` build is unaffected). + +## Out of scope + +Professional/accurate English translation (placeholder-quality is acceptable per +Decisions); a locale switcher in the running app UI (build-time locale selection +only, per Decisions); RTL locales or pluralization edge cases beyond what +`$localize` already handles by default. + +## Risks + +`ng extract-i18n` may surface `$localize` call sites with inconsistent or missing +`@@id`s that currently work fine at runtime (ids are optional for `$localize` to +function, but required for clean extraction) — budget time to add ids where +missing rather than treating every gap as a bug to fix elsewhere. diff --git a/docs/backlog/WP-21-resilience-seams.md b/docs/backlog/WP-21-resilience-seams.md new file mode 100644 index 0000000..afb3af1 --- /dev/null +++ b/docs/backlog/WP-21-resilience-seams.md @@ -0,0 +1,132 @@ +# WP-21 — Resilience seams (correlation-id, idempotency, retry) + +Status: todo +Phase: 5 — productie-volwassenheid + +## Why + +`api-client.provider.ts`'s own header comment lists four cross-cutting seams and +marks three "done" — but two of the three are only half-done, and the fourth +(retry/backoff) is an explicit unfilled seam: + +- **Correlation id**: the FE generates a fresh `X-Correlation-Id` per request + (`api-client.provider.ts:29`), but the backend only _reads_ it opportunistically + inside the `Submit` helper (`Program.cs:344`) for log lines — there's no + middleware, so most endpoints never see or echo it, and it's never returned to + the caller for support/debugging correlation. +- **Idempotency key**: generated per-attempt (`api-client.provider.ts:31`), which + the same comment admits defeats its own purpose — "a real retry would thread a + STABLE key per logical submit so re-sends dedupe; here it's per-attempt." A retry + today would double-submit, not dedupe. +- **Retry/backoff**: not implemented at all — the comment names it as the one + remaining line to add, never added. + +## Read first + +- `src/app/shared/infrastructure/api-client.provider.ts` (the whole seam-comment + block at the top, lines 10-22, plus `httpClientFetch`'s header-building code) +- `backend/src/BigRegister.Api/Program.cs:344-368` (`Submit` helper — where + `X-Correlation-Id` is read today, and the only place) +- `backend/src/BigRegister.Api/Data/DocumentStore.cs:16` (`AuditEntry` — same + correlation id shape reused for audit `Actor` today, see `Program.cs:361` passing + `cid` as the audit actor for post-delivery) + +## Decisions (pre-made, don't relitigate) + +- **Correlation id becomes ASP.NET Core middleware**, not a per-endpoint read: every + request gets a correlation id (client-supplied `X-Correlation-Id` if present, + else server-generated), it's pushed into the logging scope for every log line in + that request (not just `Submit`'s), and echoed back as a response header so the + FE/caller can log it too. +- **Idempotency key becomes stable per logical operation**, generated once when a + submit/mutation _starts_ (e.g. once per wizard's submit action) and reused across + retries of that same logical attempt — not regenerated on every HTTP call. This + is a FE-side change (where the key is generated) plus a backend-side change + (actually deduping on it — see Files). +- **Retry/backoff applies only to idempotent GETs**, using rxjs `retry({ count, +delay })` in `httpClientFetch`'s pipe, per the existing header comment's own + suggestion. Writes are never auto-retried (the point of item above is making + retries _safe_, not making everything retry automatically — a POST retry policy + is a separate, larger decision about at-least-once semantics best left for when a + real backend needs it). +- Server-side idempotency _deduplication_ (actually short-circuiting a repeated key + to return the first result) is scoped to the submit endpoints only + (`Program.cs`'s `Submit` helper callers) — not every mutation — since that's + where the existing seam already concentrates correlation/idempotency handling. + +## Files + +- `backend/src/BigRegister.Api/Program.cs` — add correlation-id middleware + (`app.Use(async (ctx, next) => { … })` near the top of the pipeline, before route + registration): read-or-generate `X-Correlation-Id`, stash in + `ctx.Items`/`HttpContext`, push into `ILogger` scope + (`BeginScope(new Dictionary{["CorrelationId"]=cid})`), set it on + `ctx.Response.Headers` before the response is written. +- `backend/src/BigRegister.Api/Program.cs` — simplify the `Submit` helper's own + `cid` read (now redundant with the middleware-populated value; read from + `HttpContext.Items` or inject via a lightweight accessor) so every log line in + `Submit` picks up the same id without re-parsing the header. +- New backend idempotency check: a small in-memory `IdempotencyStore` (same pattern + as `ApplicationStore`/`DocumentStore` — static dict + lock, ponytail-labeled with + the upgrade path to a real cache/store) keyed on `Idempotency-Key`, consulted by + the submit endpoints before calling `SubmissionRules.NewReference()`; returns the + cached response on a replayed key instead of minting a new reference. +- `src/app/shared/infrastructure/api-client.provider.ts` — generate the + `Idempotency-Key` once per logical submit rather than per HTTP attempt (thread it + in from the caller — likely means the submit commands in `application/submit-*.ts` + generate and pass the key, not the low-level fetch adapter); add + `retry({ count: 2, delay: 500 })` (or similar) to the GET-only path in the rxjs + pipe, gated on `method === 'GET'`. +- New backend test `backend/tests/BigRegister.Tests/IdempotencyTests.cs` — replay a + submit with the same `Idempotency-Key`, assert the same reference comes back and + `SubmissionRules.NewReference()` was not called twice (or assert the observable + effect: identical response body). + +## Steps + +1. Backend middleware for correlation id first (smallest, most mechanical change); + confirm every existing log line still works and now the id is consistent + end-to-end, not just inside `Submit`. +2. Backend `IdempotencyStore` + wiring into the submit endpoints; test the replay + behavior. +3. FE: move idempotency-key generation up to the command layer + (`submit-change-request.ts` and equivalents) so one logical submit = one key + even if `runSubmit`/the HTTP layer retries underneath. +4. FE: add GET retry/backoff in `httpClientFetch`; verify it doesn't retry writes + (assert via a spec on the adapter, or a targeted e2e/manual check with the + `?scenario=slow` toggle). + +## Acceptance criteria + +- [ ] Every backend log line for a given request shares one correlation id (not + just lines inside `Submit`); the id is echoed in the response headers. +- [ ] Replaying a submit with the same `Idempotency-Key` returns the same result + without minting a second reference (backend test proves this). +- [ ] A logical wizard submit generates exactly one `Idempotency-Key`, reused across + any FE-side retry of that submit (not regenerated per HTTP attempt). +- [ ] GET requests retry on transient failure (e.g. simulated via `?scenario=slow` + or a forced 5xx); POST/PUT/DELETE never auto-retry. + +## Verification + +GREEN + `cd backend && dotnet test`. Manual: `?scenario=error` on a GET-backed page, +confirm a retry attempt happens (network tab shows 2 requests) before the error +state renders; submit a wizard twice with a manually replayed idempotency key (e.g. +via curl against the backend directly) and confirm the second call doesn't create a +duplicate application. + +## Out of scope + +Retrying writes automatically (explicitly deferred, see Decisions); a durable +idempotency store surviving restart (in-memory is consistent with the rest of the +backend's persistence posture — see WP-22 if that changes); circuit breakers or +more advanced resilience patterns (Polly, etc.) — out of scope for a POC-scale +seam. + +## Risks + +Correlation-id middleware ordering matters — it must run before any endpoint that +logs, including error-handling middleware, or some log lines will still lack the +id. The idempotency store trades a small amount of memory for correctness under +replay; fine at demo scale, but the ponytail comment should name the real upgrade +(a TTL'd cache) so it isn't mistaken for a production-ready dedup mechanism. diff --git a/docs/backlog/WP-22-durable-persistence.md b/docs/backlog/WP-22-durable-persistence.md new file mode 100644 index 0000000..312f2a4 --- /dev/null +++ b/docs/backlog/WP-22-durable-persistence.md @@ -0,0 +1,133 @@ +# WP-22 — Durable persistence (optional tier) + +Status: todo +Phase: 5 — productie-volwassenheid + +## Why + +Every backend store (`ApplicationStore`, `DocumentStore`, `BriefStore`) is a +`static Dictionary` guarded by a single `lock` object, explicitly documented as +in-memory ("no DB", per `backend/README.md` and CLAUDE.md's own framing). Data — +including the audit log — is lost on every restart. This is a deliberate POC +simplification (CLAUDE.md lists "runtime DTO validation on every endpoint" and +similar as out-of-scope, and a database was never promised), but it's the one gap +that would visibly break the moment someone tries to run this as a real demo across +multiple sessions or deploys it anywhere that restarts (e.g. most PaaS platforms +recycle instances). + +This WP is marked **optional tier** — lower priority than WP-18/19/20/21 — because +unlike auth/e2e/i18n/resilience, the current in-memory design is explicitly +documented and defensible for a POC. Do this when the POC needs to survive restarts +(demoing over multiple days, deploying somewhere with instance recycling), not +speculatively. + +## Read first + +- `backend/README.md` (the "in-memory seeded, no DB" framing to preserve or + supersede) +- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`, + `backend/src/BigRegister.Api/Data/DocumentStore.cs`, + `backend/src/BigRegister.Api/Data/BriefStore.cs` — the three stores, each + `static Dictionary` + `lock` +- `backend/src/BigRegister.Api/Data/SeedData.cs` (current in-memory seed — becomes + a first-run DB seed) +- `docs/architecture/0001-bff-lite-decision-dtos.md` (confirm this WP doesn't touch + the decision-DTO contracts — persistence is purely behind the existing store + interfaces) + +## Decisions (pre-made, don't relitigate) + +- **SQLite + EF Core**, not a heavier database — matches the POC's zero-external- + infrastructure posture (no docker service to add, no connection string to manage + beyond a file path) while proving real persistence. +- **Persistence lives entirely behind the existing static-class store APIs** — the + public methods on `ApplicationStore`/`DocumentStore`/`BriefStore` keep their + signatures; only the implementation swaps from `Dictionary` to `DbContext`. No + endpoint or domain-rule code changes (`Program.cs`, `Domain/*`). +- **Seed on empty DB**, not on every startup — `SeedData` runs once (checked via + "is the DB empty") so restarts don't reset demo data, which is the entire point + of this WP. +- **Document bytes stay a deliberate exception** if storage size becomes a concern: + either store them as a BLOB column (simplest, consistent with "one DB, no extra + infra") or explicitly punt file bytes to disk with only metadata in SQLite — + decide based on actual seeded file sizes, don't over-engineer a blob-storage + abstraction for a POC. +- **Audit log becomes a real table**, not just "no longer volatile" — this closes + the "audit log is in-memory" gap named in the original gap analysis alongside + persistence, since it's the same static-dict problem in `DocumentStore.cs`. + +## Files + +- `backend/src/BigRegister.Api/BigRegister.Api.csproj` — add + `Microsoft.EntityFrameworkCore.Sqlite` + `Microsoft.EntityFrameworkCore.Design`. +- New `backend/src/BigRegister.Api/Data/AppDbContext.cs` — `DbSet`s mirroring the + three stores' current in-memory shapes (`StoredDocument`, `AuditEntry`, whatever + `ApplicationStore`/`BriefStore` hold internally — read those files first to avoid + redesigning the shape, just relocate it). +- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`, + `DocumentStore.cs`, `BriefStore.cs` — convert static dictionary methods to + `DbContext`-backed queries; keep every public method signature identical (this is + the acceptance bar — a signature change means a caller in `Program.cs` or + `Domain/*` needs to change, which should be zero). +- `backend/src/BigRegister.Api/Data/SeedData.cs` — becomes "seed if empty" run once + at startup against the real DB. +- `backend/src/BigRegister.Api/Program.cs` — register `AppDbContext` (DI), run + migrations/`EnsureCreated` + conditional seed at startup. +- New EF Core migration (generated via `dotnet ef migrations add Initial`). +- `.gitignore` — exclude the runtime `.db` file (ship the migration, not the + database). +- `backend/README.md` — update "in-memory seeded, no DB" framing to describe the + SQLite file and its lifecycle (created/seeded on first run, persists thereafter, + delete the file to reset demo data). +- `docker-compose.yml` — mount a volume for the SQLite file so `docker compose up` + restarts don't lose data either (currently the `api-bin`/`api-obj` volumes exist + for build caching only, not data). + +## Steps + +1. Add the EF Core packages; define `AppDbContext` matching the current in-memory + record shapes exactly (no schema redesign in this WP). +2. Convert one store at a time (`DocumentStore` first — it's the smallest and has + the audit log, which is the most valuable win), keeping + `backend/tests/BigRegister.Tests/*` green after each conversion. +3. Wire `AppDbContext` + startup migration/seed in `Program.cs`. +4. Convert `ApplicationStore`, then `BriefStore`. +5. Update `docker-compose.yml` with a persistent volume; update `backend/README.md`. +6. Full backend test suite + a manual restart test: run the backend, create an + application, restart the process, confirm the application still exists. + +## Acceptance criteria + +- [ ] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in + `Data/*.cs` for application/document/brief state. +- [ ] Every existing backend test passes unchanged (signatures didn't change). +- [ ] Restarting the backend process preserves previously created applications, + documents, and brief drafts (manually verified). +- [ ] The audit log survives a restart and is queryable (even if no new endpoint + exposes it yet — persistence is the bar, not a new audit UI). +- [ ] `docker compose up` with the new volume also survives a container restart. + +## Verification + +`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`, +create an application via the FE or a curl request, kill and restart the process, +confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up` + +- `docker compose restart api`. + +## Out of scope + +A production-grade database (Postgres/SQL Server) — SQLite is the deliberate, +right-sized choice for a POC that still wants to prove real persistence. Migrating +existing in-memory demo data on upgrade (a fresh SQLite file starts from +`SeedData`, same as today's in-memory start). Blob storage for document bytes +beyond a BLOB column (only revisit if seeded files are large enough to matter). + +## Risks + +EF Core's async patterns don't drop in as a 1:1 replacement for synchronous +dictionary lookups — endpoint handlers in `Program.cs` currently call store methods +synchronously; converting to `async`/`await` may ripple further than "just the +Data/ layer" if minimal-API handlers aren't already `async`. Check this before +starting and budget for handler signature changes (still not a _behavior_ change, +but a wider diff than the Files section implies if handlers need `async` added). diff --git a/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md b/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md index 5a28c85..f5f1de5 100644 --- a/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md +++ b/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md @@ -1,6 +1,6 @@ # PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview -Status: Proposed · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002) +Status: Implemented · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002) > Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as > actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD