docs(backlog): add WP-18..22 (productie-volwassenheid phase)
Some checks failed
CI / storybook-a11y (push) Successful in 4m12s
CI / backend (push) Successful in 1m6s
CI / api-client-drift (push) Successful in 1m37s
CI / frontend (push) Successful in 1m31s
CI / codeql (csharp) (push) Failing after 1m51s
CI / codeql (javascript-typescript) (push) Failing after 1m24s

Gap analysis found the POC's designed-but-unbuilt strategic gaps: ABAC
authorization (ADR-0002/PRD-0002 phase P1), no e2e coverage, unproven
i18n second-locale seam, thin resilience seams (correlation-id,
idempotency, retry), and in-memory-only persistence. Each WP is grounded
in the current code (file paths + line numbers), not just the analysis.

Also corrects PRD-0001's stale 'Proposed' status header — the Mijn
aanvragen vertical is fully built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 20:07:05 +02:00
parent bf920696ac
commit cbb8ae548c
7 changed files with 648 additions and 21 deletions

View File

@@ -39,7 +39,7 @@ Gates land before the work they cover; each lint rule lands in the same WP as th
for its existing violations, so every WP ends green. for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
| --------------------------------------- | ------------------------------------------------------------ | ------------- | ------ | | --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [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-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-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
@@ -57,11 +57,20 @@ for its existing violations, so every WP ends green.
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 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-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-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 1015 (axe covers story churn); Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed 03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
`<app-async>` before brief adopts it); 13 defines the gap-marker format that 11/12 reference `<app-async>` 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. 1822 (phase 5, "productie-volwassenheid")
are independent of each other and of phases 14 — 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 ## WP template

View File

@@ -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.

View File

@@ -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 (`<app-async>`'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 `<app-async>`'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.

View File

@@ -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 `<trans-unit>`
(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.

View File

@@ -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<string,object>{["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.

View File

@@ -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).

View File

@@ -1,6 +1,6 @@
# PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview # 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 > 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 > actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD