docs: archive the finished backlogs (RD-30)

Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-08 23:00:38 +02:00
co-authored by Claude Opus 5
parent 097e8468e0
commit 12f17d9d73
161 changed files with 154 additions and 24 deletions
+197
View File
@@ -0,0 +1,197 @@
# Backlog — showcase hardening
Ordered work packages that take this POC from "good" to **reference showcase**: CIBG
design-system fidelity, DDD/FP consistency, Storybook as curriculum, and WCAG compliance
with automated gates. Source: the architecture/CIBG/a11y audit of 2026-07-02 (plan:
"Showcase hardening").
This backlog **supersedes `docs/project/SHOWCASE-ROADMAP.md`**.
## Session protocol
- **One WP per session.** Read `CLAUDE.md`, this README, the WP file, and the WP's
"Read first" list — then execute. Do not start the next WP in the same session.
- **Match the model to the step, not the whole session** (see CLAUDE.md's "Model routing
for agent delegation"). Read the WP's Decisions block with the `planner` agent (Opus) if
the current session isn't already Opus — the approach and edge cases deserve the
stronger model. Implement directly if already on Sonnet, or hand off to the `developer`
agent otherwise, once the plan is approved. Delegate simple, read-only CLI checks
(running a test suite, `grep`/`git status`, verifying a file exists) to the `task-runner`
agent (Haiku) — proportion cost to the step's difficulty, not the WP's.
- The **Decisions** block in each WP is pre-made — don't relitigate it.
- A WP ends **GREEN** (below) with its acceptance criteria checked off and its Status
updated to `done` (+ commit hash).
- No WP leaves a lint rule/check disabled without an inline justification comment **and**
a cross-reference to the WP that will remove it.
## GREEN (global definition of done)
```bash
npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook
```
From WP-01 onward, additionally:
```bash
npm run test-storybook:ci
```
Phases 05 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
WPs `cd backend && dotnet test` is part of GREEN, and any wire change ends with
`npm run gen:api` leaving no drift.
From WP-19 onward, `npm run e2e` is part of CI (its own job) but NOT part of the local
GREEN one-liner above — it needs the real backend + `npm start` already running (see
WP-19's own file), so it's a separate manual/CI step, not chained into the others.
## Order
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 | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [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 | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done |
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done |
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done |
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | done |
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | done |
| [WP-70](WP-70-test-data-builders.md) | Test-data builders: illegal fixtures unrepresentable (ADR-0006) | 12 · DDD hardening | done |
| [WP-71](WP-71-test-framework-coherence.md) | Test framework coherence: BDD/DDD alignment, close the escape hatches | 12 · DDD hardening | done |
| [WP-72](WP-72-delete-legacy-submit-endpoints.md) | Delete the dead legacy submit endpoints | 12 · DDD hardening | done |
| [WP-73](WP-73-domain-unions.md) | `RegistrationStatus` and `Aanvraag` as closed unions | 12 · DDD hardening | done |
| [WP-74](WP-74-e2e-isolation.md) | E2E isolation without a new backend endpoint | 12 · DDD hardening | done |
| [WP-75](WP-75-fe-be-seam-closure.md) | Close the remaining FE/BE seams | 12 · DDD hardening | done |
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
`<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. 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.
Phase 6 (Brief v2, the "Brief opstellen v2" PRD) is strictly ordered
23 → 24 → 25 → 26 → 27 → 28: 24 needs 23's `orgTemplate` on the wire, 25 needs 24's
`letter.css` contract, 26 needs 23's endpoints + 24's canvas, 27/28 polish on top.
Phase 7 (refinements, WP-31..36): only 31 → 32 is ordered (32 reuses the `createHistory`
helper 31 extracts); 3336 are independent — pick any order.
Phase 8 (platform/DX/showcase, WP-37..46): goal is BOTH a teaching showcase AND a production
starter template. Priority: WP-37 (P0 quick fix) → WP-46 + WP-38 + WP-39 (P1, parallel) →
WP-40 (P2) → WP-43 (P3) → WP-41 → WP-42 → WP-44 → WP-45 (P4). Ordering constraints:
41 needs 40's PII kernel; 42 needs 40+41; 44 (`gen:context`) needs 38 (declarative boundaries
make the generator simple) + 43; 45 (`create-frontend`) needs 43+44. 37/38/39/40/43/46 are otherwise
independent. Two open tool forks, decided as step 1 of their WP: 38 dependency-cruiser vs Sheriff;
43 plop vs Angular schematics.
Phase 9 (OpenZaak/ZGW integration, WP-49..52) is strictly ordered 49 → 50 → 51 → 52: 49 lands
the source seam + ZGW client the rest reuse; 50 (create) needs a zaak to exist; 51 (documents)
links to 50's zaak; 52 (notificaties) reacts to changes on the zaken 4951 manage. All slices
stay behind `Zgw:Enabled` (default off) so the POC keeps running offline.
Two cross-cutting WPs sit alongside that read→write arc: **53** (a real per-request identity
seam + citizen-scoping — replaces the stubbed owner/BSN; production-blocking for a real
deployment of 4952) and **54** (a docker OpenZaak harness + opt-in integration test — makes
50/51/52 developable against a live instance instead of only fixtures). 53 is independent of the
CRUD arc and can land any time; 54 depends on 49 (something to read) and unlocks realistic
testing for the rest. Both are self-contained (each WP file carries its own current-state
handoff) and sized for a fresh Sonnet session.
Phase 10 (OpenZaak production hardening, WP-55..60 — now **done**) and Phase 11
(Behandelportal, WP-61..66) are two independent tracks that can be worked concurrently —
neither blocks the other. Within phase 10: 55/59/60 were fully independent; 57 and 58 both
built on 56's provisioning mechanism, otherwise independent of each other. Within phase 11: 61
(bootstrap), 62 (backend medewerker identity), and 63 (backend status DTO) are
independent of each other and can land in any order; 64 needs all three (61 for the app
to exist, 62 for identity, 63 for the status it reads); 65 needs 64; 66 needs 65 and — now
that WP-60 has landed (bounded retry + flagged divergence in `ZgwHttpClient`/`Program.cs`) —
inherits that retry for free, but must call `RecordZgwDivergence` on its own besluit write path
to get the flagging half too.
## WP template
```markdown
# WP-NN — Title
Status: todo | in-progress | done (<commit>)
Phase: N — name
## Why
## Read first
## Decisions (pre-made, don't relitigate)
## Files
## Steps
## Acceptance criteria
## Verification
## Out of scope
## Risks
```
@@ -0,0 +1,80 @@
# WP-01 — Axe-on-every-story CI gate
Status: done (97f7de4)
Phase: 0 — enforcement & gates
## Why
The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts`
for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations _interactively_.
Nothing gates CI. This WP turns "a panel you can look at" into "a check that fails the
build", so every story added or changed by later WPs is automatically covered.
## Read first
- `.storybook/main.ts`, `.storybook/preview.ts` (existing a11y config — reuse its tags)
- `.github/workflows/ci.yml` (job layout to extend)
- `package.json` (scripts; note `.npmrc` has `legacy-peer-deps=true`)
## Decisions (pre-made, don't relitigate)
- Mechanism: **`@storybook/test-runner` + `axe-playwright`** against the static build.
The repo uses `@storybook/angular` on the **webpack** builder, which rules out
`@storybook/addon-vitest` (Vite-only). Fallback if test-runner doesn't support
Storybook 10 yet: a hand-rolled Playwright + `axe-core` loop over `storybook-static`
(same gate semantics — document the swap in this file if taken).
- Escape hatch: `parameters: { a11y: { disable: true } }` per story, allowed **only** with
an inline justification comment and a cross-reference to the WP that will fix it
(WP-16/WP-17).
## Files
- `package.json` (devDependencies + scripts)
- `.storybook/test-runner.ts` (new)
- `.github/workflows/ci.yml` (new job)
- Possibly a handful of story/component files for triaged violations
## Steps
1. Check version fit first: `npm view @storybook/test-runner peerDependencies` and pick
the release matching the installed Storybook 10 (`legacy-peer-deps` absorbs minor lag).
2. `npm i -D @storybook/test-runner axe-playwright http-server wait-on concurrently`
(+ `npx playwright install chromium` locally).
3. `.storybook/test-runner.ts`:
- `preVisit`: `injectAxe(page)`.
- `postVisit`: `getStoryContext(page, context)`; skip when
`parameters.a11y?.disable === true`; else `checkA11y(page, '#storybook-root', …)`,
honoring `parameters.a11y.config`/`options.runOnly` from `preview.ts` (don't
duplicate the tag list — read it from the story context).
4. Scripts:
- `"test-storybook": "test-storybook"`
- `"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\""`
5. CI job `storybook-a11y` in `ci.yml`: checkout → setup-node (same version as the
`frontend` job) + npm cache → `npm ci``npx playwright install --with-deps chromium`
`npm run build-storybook``npm run test-storybook:ci`.
6. Run locally against a fresh `build-storybook`; triage violations: fix trivial ones
(labels, roles, contrast via `--rhc-*` tokens); anything structural gets the escape
hatch + comment + WP cross-ref.
7. Update `docs/project/backlog/README.md`: GREEN now includes `npm run test-storybook:ci`.
## Acceptance criteria
- [x] `npm run test-storybook:ci` passes locally against a fresh `storybook-static/`.
- [x] CI has a `storybook-a11y` job; an axe violation makes it fail (verified once with a
deliberately broken story, then reverted).
- [x] Every `a11y.disable` skip carries a justification comment + WP cross-ref.
- [x] Backlog README GREEN updated.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Fixing structural a11y issues in components (WP-16/WP-17); Playwright e2e page sweeps
(explicitly not chosen).
## Risks
- test-runner ↔ Storybook 10 peer mismatch → use the documented fallback.
- Unknown violation count on first run → the escape hatch keeps the WP bounded.
@@ -0,0 +1,67 @@
# WP-02 — Harden `check:tokens` + fix what it then catches
Status: done (88442b0)
Phase: 0 — enforcement & gates
## Why
The token guard (`check:tokens` in `package.json`) only greps for hex colors and only
scans `registratie/ui`, `shared/ui`, `shared/layout`. It misses `rgb()`/`hsl()` values
and skips `brief/`, `auth/`, `herregistratie/`, `showcase/` entirely. The guard must
cover the whole app before the CIBG work (WP-10…13) leans on it.
## Read first
- `package.json` (`check:tokens` script)
- `src/styles.scss` (the token bridge — the vocabulary fixes must come from)
- `CLAUDE.md` theming section
## Decisions (pre-made, don't relitigate)
- Raw `px` values are **not** grepped (too many false positives); border-width fixes are
done manually in this WP and the script documents why px is out of scope.
- A `/* token-ok */` inline marker suppresses a justified false positive (e.g. `rgb` in a
comment or data-URI) — each use needs a reason in the comment.
## Files
- `package.json` → move logic to `scripts/check-tokens.sh` (new), keep the npm script name
- `src/app/shared/layout/site-footer/site-footer.component.ts``rgb(255 255 255 / 0.25)`
- `src/app/shared/ui/debug-state/debug-state.component.ts` — raw rgba/hex; tokenize and
drop its exclusion (it's dev-only chrome but the rule should have no holes)
- `src/app/brief/ui/letter-block/letter-block.component.ts` — raw `3px` border
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` — raw `1px` border
- `src/app/brief/ui/passage-picker/passage-picker.component.ts` — raw `1px` border
## Steps
1. Create `scripts/check-tokens.sh`; `package.json` `check:tokens` calls it.
2. Extend the regex to `#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(` (skip lines containing
`token-ok`).
3. Extend the scanned set to **all** `src/app/**/*.component.ts`.
4. Run it; fix every hit using tokens from the `src/styles.scss` bridge (add a bridge
token only if no existing one fits — keep the vocabulary small).
5. Fix the raw border widths in the three brief components (`--rhc-border-width-*`),
even though px isn't grepped.
6. Plant a violation, confirm the script fails, remove it.
## Acceptance criteria
- [x] Script scans all contexts and matches hex + rgb()/hsl().
- [x] Zero exclusions; any `token-ok` marker has a reason. (No `token-ok` markers were needed.)
- [x] site-footer, debug-state, and the three brief components are tokenized.
- [x] A planted violation provably fails `npm run check:tokens`.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Grepping px/rem values; scss files (`styles.scss` is the one place palette values are
allowed — it IS the bridge).
## Risks
Regex false positives in strings/URLs — that's what the `token-ok` marker is for; keep
its bar high.
@@ -0,0 +1,79 @@
# WP-03 — Boundaries I: contracts purity + ApiClient confinement
Status: done (f9b76e7)
Phase: 0 — enforcement & gates
## Why
`contracts/` is the FE⇄BE wire seam and must import **nothing** (CLAUDE.md §1,
ADR-0001) — but `registratie/contracts/dashboard-view.dto.ts` imports domain types, and
`change-request-form.component.ts` injects `ApiClient` straight into UI. Neither rule is
lint-enforced. Rule + fixes land together so this WP ends green.
## Read first
- `CLAUDE.md` §1 + §4 (layers, BFF-lite)
- `eslint.config.mjs` (existing zone style to extend)
- `src/app/registratie/contracts/dashboard-view.dto.ts`
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts`
- `src/app/registratie/ui/change-request-form/change-request-form.component.ts` (~line 53)
- `src/app/registratie/application/submit-change-request.ts`
- `src/app/shared/infrastructure/role.ts` and `src/app/brief/ui/letter-composer/letter-composer.component.ts` (line 2)
## Decisions (pre-made, don't relitigate)
- Wire DTOs express enums as plain string-literal unions **inlined in the DTO file**
they describe the wire, not the domain. The adapter's `parse*` maps them to domain types.
- The `Role` **type** moves out of infrastructure (to `shared/domain` or
`shared/application` — pick where its current consumers point most naturally); the
role-reading mechanism (`role.interceptor.ts` etc.) stays infrastructure.
## Files
- `eslint.config.mjs` — two new restrictions
- `src/app/registratie/contracts/dashboard-view.dto.ts` — remove domain imports
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts` — absorb the mapping
- `src/app/registratie/ui/change-request-form/change-request-form.component.ts` — stop
injecting `ApiClient`; the component calls the application command, which owns the
client dependency (move the `inject` into `submit-change-request.ts` or a thin
registratie adapter, matching how other commands get the client)
- `src/app/shared/infrastructure/role.ts` + new home for the `Role` type + the
`letter-composer.component.ts` / `brief.store.ts` import sites
## Steps
1. Rewrite `dashboard-view.dto.ts` as pure wire shapes (no imports at all); update the
adapter's `parse*`/`toDomain` to map wire → domain; run the adapter spec.
2. Move the `Role` type; update import sites (mechanical; lint will find them).
3. Refactor change-request-form: UI keeps dispatching to the machine; the submit command
receives/owns the `ApiClient` (pattern: other `submit-*.ts` commands).
4. Add eslint zones:
- `src/app/**/contracts/**` may import nothing from `@angular/*`, any `@*` context
alias, or relative app code.
- `@shared/infrastructure/api-client` importable only from `**/infrastructure/**`
(+ the app config/provider and the generated file itself).
5. Lint the repo; there must be zero new disables.
## Acceptance criteria
- [x] `dashboard-view.dto.ts` has no import statements.
- [x] No value import of `@shared/infrastructure/api-client` outside `infrastructure/`
(the only remaining non-infra reference is draft-sync's type-only DTO import,
allowed by `allowTypeImports`). ApiClient confinement is lint-enforced.
- [x] Both eslint rules active; planted violations fail lint (contracts→domain,
UI→ApiClient value); type-only DTO import still passes.
- [x] Change-request flow works — verified end-to-end in the running app (submit →
command → adapter → backend → reference `BIG-2026-…`, success alert); specs green.
## Verification
GREEN + `npm run test-storybook:ci`. Manual smoke: `npm start` → Gegevens wijzigen →
submit a change request.
## Out of scope
The `ui ↛ infrastructure` rule for adapter injections in wizards — that's WP-04.
## Risks
Role-type move ripples through brief imports — mechanical; run tests before/after.
@@ -0,0 +1,77 @@
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
Status: done (035e785)
Phase: 0 — enforcement & gates
## Why
The documented rule `ui → application → domain` (CLAUDE.md §1) is not lint-enforced, and
three components inject infrastructure adapters directly. This is the biggest refactor of
Phase 0 — a pure move of wiring, no behavior change.
## Read first
- `CLAUDE.md` §1, `docs/reference/architecture/ARCHITECTURE.md`
- `eslint.config.mjs`
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` (lines
~18-19: `BrpAdapter`/`parseBrpAddress`, `DuoAdapter`/`parseDuoLookup`)
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` (~line 28:
`IntakePolicyAdapter`)
- `src/app/registratie/application/big-profile.store.ts` (the facade pattern to copy)
- `src/app/showcase/ui/concepts.page.ts`
## Decisions (pre-made, don't relitigate)
- **Showcase is sanctioned, not fixed**: `showcase/` is a teaching page whose point is
showing multiple contexts side by side. Add an explicit eslint override allowing
`@registratie/*` / `@herregistratie/*` from `src/app/showcase/**`, with a message
comment — same precedent as the existing `debug-state` exemption. Document in
ARCHITECTURE.md: "showcase may read every context; nothing imports showcase."
- Move wiring only — do not redesign adapter signatures. Existing machine specs are the
safety net.
## Files
- `eslint.config.mjs` — forbid `**/ui/**` and `**/layout/**``**/infrastructure/**`;
add the showcase sanction
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` — BRP/DUO
lookups move behind a registratie `application/` facade (command or store methods)
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` — policy resource
moves behind **new** `src/app/herregistratie/application/` (folder doesn't exist yet)
- `docs/reference/architecture/ARCHITECTURE.md` — fix "three contexts, four layers" → six contexts
(shared, auth, registratie, herregistratie, brief, showcase), five layers (+ contracts);
add the showcase sanction
- `CLAUDE.md` §1 — add `brief` to the context list; note the showcase sanction
## Steps
1. Create the application-layer facades (pattern: `big-profile.store.ts` /
`submit-*.ts` commands); components inject those instead of adapters.
2. Add the eslint restriction; add the showcase override with rationale message.
3. Fix any further hits lint finds (`letter-composer`'s infra import is handled in
WP-03; if it slipped, fix here).
4. Update both docs' context/layer inventory.
## Acceptance criteria
- [x] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions. (Probed: a ui→infra import errors.)
- [x] No `**/ui/**` file imports from `**/infrastructure/**` (production; stories/specs
exempted — test scaffolding wires the real client).
- [x] Both wizards behave unchanged (178 specs pass; storybook a11y suite mounts both
wizards green — behaviour is a pure wiring move behind root facades).
- [x] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
## Verification
GREEN + `npm run test-storybook:ci`. Manual smoke: `npm start` → run the registratie
wizard (BRP prefill + DUO lookup steps) and the intake wizard end-to-end.
## Out of scope
Redesigning the adapters or resource loading; parse boundaries (WP-05).
## Risks
Resource wiring moving layers can subtly change injection timing — keep facades
`providedIn: 'root'` like existing stores and hand the resource through unchanged.
@@ -0,0 +1,66 @@
# WP-05 — Parse-don't-validate closure + MDX
Status: done
Phase: 1 — FP/DDD core
## Why
Three adapters trust the wire, contradicting the repo's flagship "parse, don't validate"
principle (every response through a hand-written `parse*` returning `Result`).
## Read first
- `CLAUDE.md` §3 + §4; `docs/reference/architecture/0001-bff-lite-decision-dtos.md`
- `src/app/registratie/infrastructure/applications.adapter.ts` (+ its spec — the pattern
to copy)
- The three offenders below
## Decisions (pre-made, don't relitigate)
- Unknown enum-ish values from the wire map to an explicit `Failure` (never a silent
fallback); specs pin that behavior.
- The MDX curriculum page ships in this WP (the WP that establishes an invariant teaches
it).
## Files
- `src/app/herregistratie/infrastructure/intake-policy.adapter.ts` — returns the raw DTO
resource with no parse; add `parseIntakePolicy(raw: unknown): Result<string, …>` and a
domain-side type
- `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) —
`n.type as AantekeningType` → validated parse
- `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as
PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips)
- New co-located specs: `intake-policy.adapter.spec.ts`, extend
`big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing)
- New `src/docs/parse-dont-validate.mdx` — title `Foundations/Parse, don't validate`
## Steps
1. For each hole: write the small parser (pattern: `parseAanvraagStatus` in
`applications.adapter.ts`), wire it in, delete the cast.
2. Spec each parser: valid shape, missing field, unknown tag/enum value → `err`.
3. MDX page: the principle, value objects (`registratie/domain/value-objects/`), boundary
parsers, linking the three real call sites just fixed (before/after snippet).
## Acceptance criteria
- [x] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
- [x] Each new parser has a spec including a rejection case.
- [x] MDX renders under Foundations in Storybook.
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
(`npm start` → /intake).
## Out of scope
Runtime validation on _every_ endpoint (explicitly out of scope for the POC per
CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
## Risks
Seeded backend data containing values the new parsers reject → surfaces as `Failure` in
the UI; check the backend seed while writing the parsers and align the accepted set.
@@ -0,0 +1,86 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: done
Phase: 1 — FP/DDD core
## Why
18 `$any()` casts in templates defeat strict template checking. Root cause for ~9 of
them: `AsyncLoadedDirective` types its template context as `{ $implicit: unknown }`
(`src/app/shared/ui/async/async.component.ts`), so every `<ng-template appAsyncLoaded
let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Read first
- `src/app/shared/ui/async/async.component.ts` (component + directives)
- Consumers with `$any`: `src/app/registratie/ui/dashboard.page.ts`,
`registration-detail.page.ts`, `registration-summary/registration-summary.component.ts`
(×5, union peeking), `registratie-wizard.component.ts` (×4, step data),
`src/app/showcase/ui/concepts.page.ts` (×2)
## Decisions (pre-made, don't relitigate)
- Fix the root cause with generics + `static ngTemplateContextGuard`, not per-consumer
casts.
- Fallback (only if Angular's inference fights the `RemoteData<E,T> | Resource<T>` union
input): split into two typed inputs (`data` / `resource`) — record the swap here.
## Files
- `src/app/shared/ui/async/async.component.ts``AsyncComponent<T>`;
`AsyncLoadedDirective<T>` with
`static ngTemplateContextGuard<T>(dir, ctx): ctx is { $implicit: T }` (same for the
failure directive's error type if applicable)
- Every `$any()` call site (grep `-rn '\$any(' src/app`)
## Steps
1. Make the async component/directives generic; keep the public API otherwise identical.
2. Remove the now-unneeded `$any()`s in async consumers.
3. Remaining union narrowing: replace with `@switch` on the status tag
(registration-summary) or small typed `computed()` getters (wizard step data,
showcase fake resource).
4. `npm run build` (strict templates) is the real check here.
## Acceptance criteria
- [x] `grep -rn '\$any(' src/app` → zero hits.
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [x] Build green with strict template checking.
## Verification
GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
staat er niet bij" handmatig branch) — every fixed template renders its real data with
no console errors.
## Deviation from the original plan
`AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
only infers a structural directive's type parameter from an **input bound on that same
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
directive that has no input of its own cannot inherit a type from a sibling input on the
parent `<app-async>` element, even though the two are nested in the same template. This
is a hard limitation of Angular's template type-checker, not a gap in this
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
the compiler continuing to type `let-p` as `unknown` after the generic was added).
The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
its existing `duoData` computed instead of adding a new one. The registration-summary
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
deviation is contained to consumer templates, as the WP intended.
@@ -0,0 +1,115 @@
# WP-07 — Brief on the shared idioms + RemoteData MDX
Status: done
Phase: 1 — FP/DDD core
Depends on: WP-06 (typed `<app-async>`)
## Why
The brief context drifted from the repo's own reflexes: `brief.store.ts` (~lines 22-25)
holds `busy: signal<boolean>` + `lastError: signal<string|null>` + a separate `saveState`
union side by side — representable illegal combos, the exact "second boolean" smell
CLAUDE.md §3 bans. And `brief.page.ts` hand-renders its load lifecycle with `@switch` +
spinner/alert instead of `RemoteData` + `<app-async>` — the only async flow in the app
bypassing the shared molecule.
## Read first
- `CLAUDE.md` §3; `src/app/shared/application/remote-data.ts`
- `src/app/brief/application/brief.store.ts`, `src/app/brief/ui/brief.page.ts`,
`src/app/brief/domain/brief.machine.ts` (+ spec)
- `src/app/registratie/application/applications.store.ts` (a store doing it right)
## Decisions (pre-made, don't relitigate)
- Transient submit/save state becomes **one** tagged union
(`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its
union shape (align tag style).
- Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's
_domain_ lifecycle (loading tags move out of the machine only if they purely mirror
the fetch — keep the seam: RemoteData = fetch, machine = letter).
- Keep the debounced-save sequencing identical; only re-type the state.
## Files
- `src/app/brief/application/brief.store.ts`
- `src/app/brief/ui/brief.page.ts`
- `src/app/brief/domain/brief.machine.ts` + `brief.machine.spec.ts`
- New `src/docs/remote-data.mdx` — title `Foundations/RemoteData & Async`
## Steps
1. Replace the signal trio with one union signal; update consumers (letter-composer bar,
autosave status line).
2. Route the page's load through `RemoteData` + `<app-async>` (typed via WP-06); wire the
existing loading/empty/failure templates.
3. Update machine/store specs for the union transitions.
4. MDX page: the four states, `map2`/`andThen`, the delay-gated spinner, and the
`?scenario=` dev toggle — linking `brief.page.ts` and `dashboard.page.ts` as live
examples.
## Acceptance criteria
- [x] No boolean-plus-error signal pairs in `brief/`.
- [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
Deviation for why `empty` isn't meaningful here).
- [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
(new).
- [x] MDX renders under Foundations.
## Verification
GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
WP-06's baseline by the new store spec). Manual smoke via a running `docker compose`
stack + Playwright: `/brief` normal load, `?scenario=slow` (spinner), `?scenario=error`
(failure alert + working retry), and `/brief?role=approver` — all with no console errors.
## Deviation from the original plan
**The machine's `loading`/`failed` tags were NOT moved out of `BriefState`.** The
Decisions block hedges this ("only if they purely mirror the fetch") — they do, but
removing them turns out to need more than a re-type: `createStore(initial, reduce)`
requires a concrete `initial: BriefState` value, and once `loading`/`failed` are gone
there is no state left to represent "not loaded yet" without inventing a second wrapping
layer (the store's top-level signal would need to become `RemoteData<Err, LoadedState>`
directly, with the machine's `reduce` only invoked inside the `Success` branch — a
different wiring shape from every other machine in the app, and a ~250-line ripple
through `brief.machine.spec.ts`). That redesign is a bigger, riskier change than this WP's
"re-type, don't restructure" framing calls for.
Instead, `BriefStore.remoteData` **projects** the existing machine model onto
`RemoteData<Error | undefined, LoadedBriefState>` (`loading``Loading`, `failed`
`Failure`, `loaded``Success`), and `brief.page.ts` renders that projection through
`<app-async>`. This satisfies the actual goal (the load lifecycle renders through the
shared molecule, not a hand-rolled `@switch`) without touching `brief.machine.ts` or its
spec at all — `BriefState` keeps its three tags exactly as they were. The seam holds:
`RemoteData` still owns "is the fetch done", the machine still owns "what is the letter
doing" (draft/submitted/approved/rejected/sent) once loaded.
**`?scenario=empty` doesn't apply to `/brief`.** It rewrites the HTTP body to `[]`, which
fails `parseBriefView`'s `!dto.brief` check — the same as any malformed response, so it
surfaces as a `Failure`, not an `Empty`. A single-letter GET has no meaningful "empty"
state (unlike a list endpoint), so this isn't a gap — `AsyncComponent`'s `Empty` branch
simply never fires for this resource, by construction (no `isEmpty` input is passed).
**Reused the WP-06 fallback for the loaded slot.** `<ng-template appAsyncLoaded>` can't
type `let-s` to the loaded value for the same structural reason WP-06 documented
(a directive's generic can't inherit from a sibling `[data]` input) — `brief.page.ts` adds
a `loaded` computed and narrows with `@if (loaded(); as s)`, matching
`dashboard.page.ts`/`registration-detail.page.ts`.
## Out of scope
Brief component stories (WP-15); machine renaming conventions (WP-08).
## Risks
Autosave (debounced) interplay with the new transition union — flush ordering must stay
as-is; `brief.store.spec.ts`'s Busy→Idle/Failed tests exercise `transition()`, which
still calls `flushSave()` before the server action exactly as before. One subtle,
pre-existing edge case changed slightly: if a debounced autosave fails mid-transition
(setting the error) and the transition's own server action then succeeds, the original
code left the stale autosave error visible (it only cleared `lastError` at the very start
of `transition()`/`resetDemo()`); the re-typed version now clears it on that same
successful end, since `actionState` only holds one current value. Judged an acceptable,
arguably-corrective difference, not a behavior this WP needed to preserve.
@@ -0,0 +1,88 @@
# WP-08 — One store idiom + machine naming + TEA MDX
Status: done
Phase: 1 — FP/DDD core
## Why
Docs (`docs/reference/fp-tea-atomic-design.md`, ARCHITECTURE §2c) teach `createStore` as THE
wiring, but the wizard pages hand-wire `signal(model)` + local `dispatch()` — juniors see
two idioms and copy the wrong one. Machine naming also drifts:
`change-request.machine.ts` exports bare `State`/`Msg`; `upload.machine.ts` exports
`reduceUpload`/`initialUpload`.
## Read first
- `src/app/shared/application/store.ts` (`createStore`) + its spec
- `docs/reference/fp-tea-atomic-design.md` Part on TEA-in-Angular
- All `*.machine.ts` files (6) and their UI wiring sites (wizard components/pages)
## Decisions (pre-made, don't relitigate)
- **`createStore` is THE idiom** — migrate the hand-wired pages to it. One shape
everywhere is the teachability win; the migration is ~5 lines per page.
- Naming convention (goes into CLAUDE.md §3):
- State/Msg types are context-prefixed: `ChangeRequestState`, `ChangeRequestMsg` (rename
the bare ones; other machines already comply).
- A top-level machine exports `initial` + `reduce`.
- A **composable sub-machine** (embedded in a parent model) keeps prefixed value
exports: `initialUpload`/`reduceUpload` **stay** — prefixing avoids alias noise at
the composition site.
## Files
- `src/app/registratie/domain/change-request.machine.ts` (+ spec + consumers) — rename
- Wizard UI wiring: `registratie-wizard.component.ts`, `intake-wizard.component.ts`,
`herregistratie-wizard.component.ts` (whichever hand-wire `signal`+dispatch) →
`createStore`
- `CLAUDE.md` §3 — the naming + wiring convention
- New `src/docs/machines.mdx` — title `Foundations/State Machines (TEA)`
## Steps
1. Rename `State`/`Msg` in change-request (mechanical; lint/build finds consumers).
2. Swap hand-wired model signals for `createStore(initial, reduce)` in the wizard
components; templates keep dispatching messages — no template changes expected.
3. Write the convention into CLAUDE.md §3 (including the sub-machine rule).
4. MDX page: Model/Msg/reduce, commands-outside-reducers, `createStore` wiring, the
naming rules — linking `herregistratie.machine.ts` as the worked example.
## Acceptance criteria
- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
hand-rolled dispatch remains.
- [x] Convention documented in CLAUDE.md; MDX renders.
- [x] All machine specs pass unchanged (reducers untouched).
## Verification
GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
this WP touched no reducer logic). Manual smoke via a running `docker compose` stack +
Playwright: the change-request form (the renamed machine) submitted end-to-end with a
referentie shown; the intake wizard stepped forward and back; the herregistratie wizard
loaded its first step — no console errors across all three.
## Deviation from the original plan
**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto
`createStore`) turned out to already be done.** `registratie-wizard.component.ts`,
`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and
`change-request-form.component.ts` all already wire
`createStore<XState, XMsg>(initial, reduce)` — confirmed both by reading each file and by
`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present
since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app`
(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two
unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an
earlier snapshot of the codebase but stale by the time this WP ran — only the
`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation
(Steps 34) had real work left.
## Out of scope
Changing reducer logic or messages; store specs for root stores (they're covered where
logic exists).
## Risks
Rename ripple — purely mechanical; reducer specs are the net.
@@ -0,0 +1,85 @@
# WP-09 — Pure-logic closure: dates + missing command specs
Status: done
Phase: 1 — FP/DDD core
## Why
Three hand-rolled copies of the same `nl-NL {day: numeric, month: long, year: numeric}`
formatter exist with divergent signatures, while other components use the Angular `date`
pipe — two mechanisms plus drift risk. And two application commands with real logic have
no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
## Read first
- `src/app/registratie/domain/tasks.ts` (~line 15, `formatNL(d: Date)`)
- `src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts` (~line 96,
`formatNL(iso?: string)`)
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (~line 96, inline copy)
- `src/app/registratie/application/draft-sync.ts` (debounce logic)
- `src/app/registratie/application/submit-change-request.ts`
- `src/app/app.config.ts` (LOCALE_ID `nl` is already set)
## Decisions (pre-made, don't relitigate)
- **Templates use `DatePipe`; pure TS uses one `formatDatumNl`** in
`src/app/shared/kernel/datum.ts` (Intl.DateTimeFormat, accepts ISO string | Date,
empty-safe). Rule goes into CLAUDE.md conventions.
- Kernel naming is Dutch (`datum.ts`) — it formats for the Dutch UI; if that clashes with
the "shared = English" rule during implementation, `format-date-nl.ts` is the
alternative — pick one and note it here.
## Files
- New `src/app/shared/kernel/datum.ts` + `datum.spec.ts`
- The three formatter copies (delete, redirect to pipe or kernel fn)
- New `src/app/registratie/application/draft-sync.spec.ts`
- New `src/app/registratie/application/submit-change-request.spec.ts`
- `CLAUDE.md` conventions (one line: DatePipe in templates, `formatDatumNl` in pure TS)
## Steps
1. Write `formatDatumNl` + spec (pin exact expected strings, e.g. `2 juli 2026`;
undefined/empty → `''`).
2. Replace the three copies; templates that can use the pipe use the pipe.
3. `draft-sync.spec.ts` with vitest fake timers: coalescing (n rapid changes → 1 sync),
trailing call, flush-on-submit if applicable.
4. `submit-change-request.spec.ts`: ok path, error path (stubbed client), Result shape.
5. Also sweep the tiny dead exports flagged in the audit: remove unused `map3` from
`shared/kernel/fp.ts` (verify unused first) and the never-set `variant` input on
`confirmation.component.ts`.
## Acceptance criteria
- [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
`Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
`grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
literal criterion, same intent — no file anywhere hand-rolls date formatting).
- [x] Both command specs exist; debounce coalescing + error path covered
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
mentioned it: CLAUDE.md, `docs/reference/architecture/ARCHITECTURE.md`, `remote-data.mdx`). The
`variant` input on `confirmation.component.ts` no longer exists — already
cleaned up before this WP ran; nothing to do.
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
pure TS).
## Verification
GREEN + `npm run test-storybook:ci` (208 unit tests, up from WP-08's 201 by the 7 new
specs; 137 Storybook/a11y unchanged). Manual smoke via a running `docker compose` stack +
Playwright: dashboard's herregistratie-deadline task text ("Verleng uw registratie vóór 1
maart 2027"), the Concept aanvraag-block's complete-before text ("Rond de aanvraag af
vóór 2 augustus 2026"), and `formatDatumNl` unit specs for the letter-preview's `today`
all render the expected long-form Dutch date, no console errors.
## Out of scope
`Result` combinators (map/andThen/fold) — nice-to-have from the old roadmap, not in this
backlog.
## Risks
Node ICU vs browser locale output differences — the spec pins strings; if CI's Node lacks
full ICU (it shouldn't on Node 24), pin via `Intl.DateTimeFormat('nl-NL', …)` explicitly.
@@ -0,0 +1,73 @@
# WP-10 — CIBG button fidelity
Status: done (69880ef)
Phase: 2 — CIBG fidelity
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
.btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
> button (atom, file-input, RTE toolbar) currently has a next/previous affordance to
> attach one to — skipped as not applicable, not recorded as a gap (nothing hand-rolled
> to mark).
## Why
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
.btn-link / .btn-link--underline / .btn-inline` — and **no** `.btn-outline-primary`,
`.btn-outline-secondary`, or `.btn-sm` (verified absent from
`public/cibg-huisstijl/css/huisstijl.min.css`). Three components emit those dead classes
and render as unstyled Bootstrap defaults instead of CIBG buttons.
## Read first
- https://designsystem.cibg.nl/componenten/knoppen/ (or grep the vendored CSS)
- `src/app/shared/ui/button/button.component.ts` (~line 14)
- `src/app/shared/ui/upload/file-input/file-input.component.ts` (~line 38)
- `src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts` (toolbar, ~lines 55-60)
## Decisions (pre-made, don't relitigate)
- `variant='secondary'` → `.btn-secondary` (CIBG's documented secondary).
- RTE toolbar buttons → `.btn-ghost`; drop `.btn-sm` — if the toolbar needs a smaller
size, that's token-bridge CSS carrying the CIBG-gap marker (format per WP-13; define it
inline if WP-13 hasn't run).
- Add `'ghost'` to the atom's variant union (CIBG documents it; the RTE needs it).
- Icon affordances (chevron/pijl): implement **only if** icon classes exist in the
vendored CSS — inspect first; otherwise record as a gap in the WP-13 register and skip.
## Files
- `src/app/shared/ui/button/button.component.ts` + `button.stories.ts`
- `src/app/shared/ui/upload/file-input/file-input.component.ts`
- `src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts`
- Any story/snapshot referencing the old classes
## Steps
1. Fix the variant map in the button atom; add `ghost`.
2. file-input label-button → `btn btn-secondary`.
3. RTE toolbar → `btn btn-ghost` (+ marked size CSS if needed).
4. Grep-check `btn-outline`/`btn-sm` gone repo-wide.
5. Storybook visual review of Button (all variants), FileInput, RichTextEditor against
designsystem.cibg.nl/componenten/knoppen.
## Acceptance criteria
- [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [x] Axe still green (contrast can change with real button styles).
## Verification
GREEN + `npm run test-storybook:ci`. Manual Storybook review of the three components.
## Out of scope
Other invented classes (WP-11); datablock (WP-12).
## Risks
Visual regressions where outline-secondary "worked by accident" via Bootstrap base rules
— the story review step is the catch.
@@ -0,0 +1,83 @@
# WP-11 — CIBG markup fidelity: application-link + absent-class triage
Status: done (98fd7e4)
Phase: 2 — CIBG fidelity
> Done as part of the "CIBG UI fidelity pass" (user-requested, out of order).
> application-link now uses the real `.dashboard-block.applications li a` chain via a
> `li[app-application-link]` attribute selector (native `<li>` child, axe-clean); the
> invented `.application`/`.application-title` classes are gone (grep gate clean). The
> dashboard "Mijn aanvragen" renders as the CIBG Aanvragen component. Remaining
> absent-class triage for non-aanvragen components stays with WP-13's gap register.
## Why
`application-link.component.ts` invents `.application` / `.application-title` — absent
from the vendored CSS. The real "aanvragen" pattern styles
`.dashboard-block.applications li a` with `h3`/`.h3`, and `.subtitle/.status/.cta`
scoped **inside** `.applications`. The invented classes mean the rows only look right by
accident of parent scoping, and `.application-title` is dead. A few other components use
classes that don't exist in the vendored build.
## Read first
- https://designsystem.cibg.nl/componenten/aanvragen/
- `public/cibg-huisstijl/css/huisstijl.min.css` (grep `.applications`, `.breadcrumb`,
error-summary candidates)
- `src/app/shared/ui/application-link/application-link.component.ts` +
`application-list/application-list.component.ts`
- `src/app/shared/layout/page-shell/page-shell.component.ts` (`.back`),
`wizard-shell/wizard-shell.component.ts` (`.es-list`/`.es-title`),
`src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts`
(`.radio-option`), `shared/layout/breadcrumb/breadcrumb.component.ts`
## Decisions (pre-made, don't relitigate)
- application-list/link: rework markup to the documented `.dashboard-block.applications`
scoping chain; keep the component API (`heading/subtitle/cta/to`) unchanged.
- For each remaining absent class: **adopt a vendored pattern if one exists, else mark as
CIBG-gap extension** (marker format per WP-13; define inline if WP-13 hasn't run).
This fallback is explicit so the WP can't stall on a missing upstream pattern.
- Wizard error summary: check whether CIBG's Foutmelding/Veldvalidatie pattern classes
are vendored; adopt if so, else mark as gap (the current behavior/a11y stays —
`role="alert"`, focus management).
- Breadcrumb: `.breadcrumb` IS vendored — verify usage against CIBG Kruimelpad and
annotate only; no rework expected.
## Files
- `application-link.component.ts`, `application-list.component.ts` (+ stories)
- `page-shell.component.ts`, `wizard-shell.component.ts`,
`delivery-channel-toggle.component.ts`, `breadcrumb.component.ts` (annotations/markers
or adopted patterns)
- `src/app/registratie/ui/dashboard.page.ts` (visual consumer — no code change expected)
## Steps
1. Rework application-list/link markup; delete `.application`/`.application-title`.
2. Grep the vendored CSS for Foutmelding/Veldvalidatie-ish classes; adopt or mark.
3. Triage `.back` and `.radio-option`: adopt or mark.
4. Verify breadcrumb; add the "wraps vendored `.breadcrumb` (CIBG Kruimelpad)" comment.
5. Eyeball the dashboard — a visual diff on "Wat wilt u doen?" is intended.
## Acceptance criteria
- [ ] `grep -rn '"application"\|application-title' src/app` → empty (component selector
aside).
- [ ] Every invented class either replaced by a vendored pattern or carries the gap
marker.
- [ ] Dashboard + wizard stories reviewed; axe green.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → dashboard ("Wat wilt u
doen?" list) + a wizard validation error (error summary).
## Out of scope
Datablock adoption (WP-12); the full gap register (WP-13).
## Risks
The intended dashboard visual diff needs human eyes — flag it in the session summary for
the user.
@@ -0,0 +1,85 @@
# WP-12 — CIBG Datablock for application data
Status: done (82fc3c4)
Phase: 2 — CIBG fidelity
> Done as part of the "CIBG UI fidelity pass" (user-requested, out of order). New
> `app-data-block` molecule wraps `.data-block`/`.block-wrapper`; `data-row` moved to a
> `div[app-data-row]` attribute selector so the `<dl>`'s child is a native `<div>`
> (axe-clean — fixed a live definition-list defect). review-section folded on;
> registration-summary + dashboard BRP block dropped `app-card` for the datablock.
## Why
CIBG documents **Datablock** (designsystem.cibg.nl/componenten/datablock/) as THE way to
display user/application data: a grey `dl/dt/dd` container, `.data-block` +
`.block-wrapper`, Bootstrap grid for label/value widths, a `--stacked` variant, and
optional expandable help text (`.dt-item`, `.dt-help-btn-open/-close`, `.dt-help-panel`).
All classes are **verified present** in `public/cibg-huisstijl/css/huisstijl.min.css`.
The app currently renders application data with bare `data-row`s inside a custom card —
adopting Datablock makes the data views design-system-native.
## Read first
- https://designsystem.cibg.nl/componenten/datablock/
- `public/cibg-huisstijl/css/huisstijl.min.css` (grep `.data-block`)
- `src/app/shared/ui/data-row/data-row.component.ts`
- `src/app/shared/ui/review-section/review-section.component.ts` (already uses
`.data-block`/`.block-wrapper` ad hoc)
- Consumers: `src/app/registratie/ui/dashboard.page.ts` ("Persoonsgegevens (BRP)" card),
`registration-summary/registration-summary.component.ts`,
`registration-detail.page.ts`
## Decisions (pre-made, don't relitigate)
- New **`data-block` molecule** in `shared/ui` wrapping the vendored classes: inputs for
optional heading and `stacked` variant; content = projected `data-row`s (or a typed
rows input — match how `review-section` projects today; prefer projection for
composability).
- `data-row` stays as the row primitive **inside** the datablock (document that role in
its header comment) — don't fork a second row component.
- `review-section` aligns onto the new molecule instead of hand-carrying the classes.
- Expandable help text (`.dt-item` + help button/panel) only if a real field needs it —
if built, follow CIBG's focus management (focus into panel on open, back to trigger on
close) and the documented `aria-*` state on the trigger.
## Files
- New `src/app/shared/ui/data-block/data-block.component.ts` + `data-block.stories.ts`
- `src/app/shared/ui/review-section/review-section.component.ts`
- `src/app/registratie/ui/dashboard.page.ts` (Persoonsgegevens card → datablock)
- `registration-summary.component.ts`, `registration-detail.page.ts`
- `src/app/shared/ui/data-row/data-row.component.ts` (header comment; possibly minor
class alignment)
## Steps
1. Build the molecule (standard + stacked variants), stories for both (axe-gated).
2. Migrate the three data views; keep copy/i18n ids untouched.
3. Fold review-section onto the molecule; delete its duplicated class carrying.
4. Optional `aria-label` on the `dl` where the block has no visible heading.
5. Visual review dashboard + registration pages.
## Acceptance criteria
- [ ] Application data (BRP card, registration summary/detail, wizard review) renders in
CIBG datablocks.
- [ ] `data-row` documented as the datablock row primitive; no duplicate dl styling
anywhere.
- [ ] Stories for standard + stacked; axe green.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → dashboard + /registratie
detail; wizard review step still renders.
## Out of scope
The help-panel variant unless a real field needs it; table views (registration-table
stays a CIBG Tabel).
## Risks
The grey datablock surface may double up visually inside the custom `.app-card` — decide
per view whether the card wrapper drops (datablock replaces it) and note the choice in
the component comment.
@@ -0,0 +1,103 @@
# WP-13 — CIBG-gap register + hygiene + MDX
Status: done (9d58f59)
Phase: 2 — CIBG fidelity
> **Deviation:** WP-11/12 ran first but left no markers (deferred to this WP, as their own
> files note), so this WP defines the marker format fresh per its own Decisions block —
> not adopted from 11/12. The Decisions block's `task-list → Actieblok` mapping is stale:
> no `.actieblok`/`actie` class exists in the vendored CSS, and `task-list`'s own header
> comment already (accurately) documents it as composing `choice-list`'s Keuzelijst
> pattern rather than a distinct Actieblok one — left as-is rather than forced to claim a
> nonexistent mapping. `application-link`'s `.static-row` (flagged as a marked-gap
> candidate in this file's own correction note) got the marker too. The optional
> `check:cibg-gaps` script (step 4) is skipped: the register is nine rows, reviewed at PR
> time same as any other doc — a CI script to diff it against code markers is complexity
> the size of the problem doesn't warrant (noted, not built).
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
> `ul.file-list`, `.file-container`, `.file-name`/`.file-meta`, `.btn-upload`,
> `.upload-validation`), so the suite was **reworked to wrap those classes** instead —
> it is no longer a gap to mark. WP-13's remaining register still covers skeleton/
> spinner, rich-text-editor, wizard-shell, confirmation, card (`.app-card`),
> status-badge, placeholder-chip, etc. (Note: `application-link`'s non-navigating
> `.static-row` mirrors the aanvragen card surface from tokens — a small marked-gap
> candidate.)
## Why
User decision: hand-rolled token-bridge components are allowed **only if explicitly
marked** as CIBG-gap extensions in code + Storybook, so every deviation from the design
system is auditable. Plus two hygiene items from the audit: a dead wrapper to delete and
the list-family rationale to document.
## Read first
- `docs/reference/architecture/0003-*.md` (ADR-0003 — the token-bridge rationale this extends)
- The audit's gap list (below)
## Decisions (pre-made, don't relitigate)
- Marker format (header comment in the component):
`// CIBG-GAP EXTENSION: <closest CIBG concept> — <why hand-rolled>` plus
`parameters: { cibgGap: true }` and a "CIBG-gap extension" line in the story
description.
- **Keep all three list families** — each maps a distinct CIBG pattern: `task-list`
Actieblok, `application-list` → Aanvragen, `choice-list` → Keuzelijst. Header comment
in each names its pattern. Consolidation would blur exactly the design-system mapping
this showcase demonstrates.
- **Delete `upload-status-banner`** (`shared/ui/upload/upload-status-banner/`): a
23-line near-identity wrapper over `alert` with one consumer — inline `<app-alert>`
there.
## Files
Components to mark (closest CIBG concept in parens):
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
- `upload/` suite (Bestand-upload)
- `rich-text-editor` (Tekstgebied)
- `wizard-shell` (Wizard; error summary → Foutmelding, if WP-11 marked it)
- `confirmation` (Bevestiging — verify: it wraps vendored classes; if so it needs no
marker, only the pattern comment)
- `debug-state` (devtool, no CIBG concept)
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
`placeholder-chip`
Plus:
- Delete `upload-status-banner` + its story; inline alert at its consumer
- Header comments on `task-list`/`application-list`/`choice-list`
- New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register`
- ADR-0003 gets a cross-link to the register
## Steps
1. Add the marker to each gap component + `cibgGap` story parameter.
2. Delete the banner; migrate its one consumer; run its former story's states through
the consumer's story if coverage would drop.
3. Write the MDX register: a table (component → closest CIBG concept → why) + the rule
"grep the vendored CSS before hand-rolling anything".
4. Optional but cheap: a `check:cibg-gaps` script that greps markers and diffs against
the MDX table, so the register can't silently rot — add to `check:tokens`' CI step if
trivial, else note as skipped.
## Acceptance criteria
- [x] Every component with hand-rolled surface CSS either wraps vendored classes or
carries the marker (spot-check with a grep for `styles: [` vs markers).
- [x] Register MDX complete, linked from ADR-0003.
- [x] `upload-status-banner` gone; consumer green; no story coverage lost.
- [x] List trio documented.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Rebuilding any gap component against the design system (they're sanctioned by decision).
## Risks
Low — mostly annotation; the deletion is the only behavior-touching change.
@@ -0,0 +1,88 @@
# WP-14 — Storybook taxonomy reorg + Layers MDX
Status: done (8b19fad)
Phase: 3 — Storybook as curriculum
> **Deviation:** the "Layout/" bucket (breadcrumb, site-footer, site-header) wasn't in the
> Decisions block's explicit scheme, so each got folded into Atoms/Molecules/Organisms by
> its own doc-comment classification (breadcrumb → Molecules, site-footer/site-header →
> Organisms, both already documented as such in their component header comments) rather
> than kept as a separate bucket. Fixing `atomic-design.mdx`'s "status banner" reference
> (stale since WP-13 deleted `upload-status-banner`) was caught as a side effect of
> reviewing every MDX page for broken references — not itself a retitle issue, but the
> same "unbroken MDX" acceptance criterion covers it.
## Why
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
outliers (`Registratie/…` wizards, one stray `Shared UI/Status Badge`). The sidebar
should tell the repo's actual story: a reusable design system on one side, domain
contexts on the other — the DDD seam made visible. The sidebar becomes the curriculum's
table of contents.
## Read first
- `.storybook/preview.ts`, `.storybook/main.ts`
- `src/docs/*.mdx` (three existing Foundations pages)
- `CLAUDE.md` naming rule (shared = English, domain = Dutch)
## Decisions (pre-made, don't relitigate)
Sidebar scheme (respects the English/Dutch seam):
```
Foundations/ ← MDX curriculum (all concept pages)
Design System/
Atoms/… Molecules/… Organisms/… Templates/… ← everything in shared/ui + shared/layout
Domein/
Registratie/… Herregistratie/… Brief/… Auth/… ← everything in a context's ui/
```
- Rule: **a component in a context folder gets a `Domein/<Context>/` title, full stop**
(context organisms like `aanvraag-block` move from `Organisms/` to
`Domein/Registratie/`). Component display names stay as authored (the folder tells the
context).
- `Devtools/` (debug-state) files under `Design System/Devtools/`.
- Order pinned via `parameters.options.storySort`:
`['Foundations', 'Design System', ['Atoms','Molecules','Organisms','Templates','Devtools'], 'Domein']`.
## Files
- All `*.stories.ts` `title:` fields (~49, mechanical)
- `.storybook/preview.ts` (`storySort`)
- Existing `src/docs/*.mdx` (fix any story references broken by retitling)
- New `src/docs/layers.mdx` — title `Foundations/Layers & Contexts`
- `CLAUDE.md` — the story-title convention (one paragraph in §2 or Conventions)
## Steps
1. Retitle all stories per the scheme (fix `Shared UI/Status Badge`).
2. Add `storySort`; build Storybook and check the sidebar.
3. `layers.mdx`: 6 contexts, 5 layers, the inward dependency rule, "this is enforced" (link
the eslint rules from WP-03/04), the English/Dutch seam — i.e. the sidebar's own
rationale, with links into real stories on both sides.
4. Check every MDX `<Canvas of={…}>`/story import still resolves (build-storybook
catches broken refs — do an explicit pass anyway).
5. Convention into CLAUDE.md.
## Acceptance criteria
- [x] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
pinned.
- [x] Zero story titles outside the scheme (grep `title:` and eyeball).
- [x] `layers.mdx` renders; existing MDX pages unbroken.
- [x] Convention in CLAUDE.md.
## Verification
GREEN + `npm run test-storybook:ci` (the axe run also re-visits every retitled story).
## Out of scope
New component stories (WP-15); the remaining curriculum pages (they ship with their
invariant-WPs: 05, 07, 08, 13, 17).
## Risks
MDX story references breaking on retitle — `build-storybook` is the catch; fix-forward
in the same session.
@@ -0,0 +1,74 @@
# WP-15 — Missing stories: shell + brief components
Status: done (0cfb01f)
Phase: 3 — Storybook as curriculum
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
> **Note:** fixture duplication across the four brief stories that need `Brief`/
> `LetterSection`/`LetterBlock` shapes (letter-block, letter-preview, letter-section, plus
> the pre-existing letter-composer) didn't bite enough to justify the shared-fixtures
> escape hatch — each story only builds the minimal slice it actually renders (letter-block
> needs one block, not a whole `Brief`), so the co-located fixtures stayed small and
> non-duplicative in practice. A pre-existing, unrelated axe finding on
> `text-input--invalid` (informational only — `test-storybook:ci` doesn't fail on it) shows
> up in the run; it predates this WP and isn't caused by anything here.
## Why
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
`shared/layout/shell` and six of seven brief components. Unstoried components are also
invisible to the axe gate.
## Read first
- `src/app/brief/ui/letter-composer/letter-composer.stories.ts` (the one brief story —
fixture + decorator pattern to reuse)
- The seven components below
## Decisions (pre-made, don't relitigate)
- Titles per WP-14 scheme: `Design System/Templates/Shell`, `Domein/Brief/<Name>`.
- If fixture duplication across brief stories bites, extract a shared
`src/app/brief/ui/brief.fixtures.ts` — otherwise keep fixtures co-located.
## Files (new)
- `src/app/shared/layout/shell/shell.stories.ts`
- `src/app/brief/ui/rejection-comments/rejection-comments.stories.ts`
- `src/app/brief/ui/diagnostics-panel/diagnostics-panel.stories.ts`
- `src/app/brief/ui/letter-block/letter-block.stories.ts`
- `src/app/brief/ui/letter-preview/letter-preview.stories.ts`
- `src/app/brief/ui/letter-section/letter-section.stories.ts`
- `src/app/brief/ui/passage-picker/passage-picker.stories.ts`
## Steps
1. One story file per component: default state + at least one meaningful variant
(e.g. letter-block locked/editable, diagnostics with findings/empty, rejection-comments
filled, preview with sample data toggle).
2. Provide router/HTTP/store context via `applicationConfig`/`moduleMetadata` decorators
like the wizard stories do.
3. Run the axe gate; fix trivial violations in the components (label/role level); anything
structural → escape hatch + cross-ref WP-16.
## Acceptance criteria
- [x] Every component in `src/app` has ≥1 story (verify: list components without a
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
files (9 of them) have never had stories; every `*.component.ts` now does.
- [x] All new stories pass the axe gate (or carry a justified skip).
- [x] Titles follow WP-14.
## Verification
GREEN + `npm run test-storybook:ci`.
## Out of scope
Play-test assertions (WP-16 adds those where they enforce a contract).
## Risks
Brief components may lean on `BriefStore` state — stub via the store's public API in a
decorator rather than reaching into internals.
@@ -0,0 +1,90 @@
# WP-16 — Component a11y: description wiring + alert role
Status: done (pending commit)
Phase: 4 — a11y
## Why
Audit findings axe can't (fully) catch:
- `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control
ever references** — `text-input` sets `aria-describedby` only to `…-error` and only
when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on
the login form).
- The field↔control id pairing is manual (`fieldId` must equal the input's `name`/`id`)
with nothing enforcing it.
- The `alert` atom is always `role="status"` — error alerts are announced politely while
other errors in the app use `role="alert"`; urgency is inconsistent.
## Read first
- `src/app/shared/ui/form-field/form-field.component.ts` (~line 15)
- `src/app/shared/ui/text-input/text-input.component.ts` (~lines 17-18)
- `src/app/shared/ui/radio-group/radio-group.component.ts`, `checkbox/checkbox.component.ts`
- `src/app/shared/ui/alert/alert.component.ts`
- `src/app/auth/ui/login-form/login-form.component.ts` (a live desc that's never wired)
## Decisions (pre-made, don't relitigate)
- `aria-describedby` = space-joined ids: `-desc` **always when a description exists** +
`-error` **when invalid**; order pinned (desc first, error second).
- Pairing contract: form-field exposes its `fieldId`; the composition contract
(`fieldId === control id/name`) is documented in both components and **enforced by a
story play test** on the canonical form-field+text-input composition
(`expect(input).toHaveAttribute('aria-describedby', 'x-desc')`, flip validity, assert
`'x-desc x-error'`). Play tests run in the WP-01 test-runner for free.
- DI-based auto-wiring (form-field providing the id via injection) is **out of scope**
more clever than this POC needs; the play test catches drift. Revisit only if the
manual contract actually breaks in practice.
- Alert: `type === 'error'``role="alert"`; others keep `role="status"`. Rationale
comment in the atom.
## Files
- `form-field.component.ts`, `text-input.component.ts`, `radio-group.component.ts`,
`checkbox.component.ts` (describedby joins; only where the component takes a hint)
- `alert.component.ts` (+ story asserting the role per variant)
- `form-field.stories.ts` (or a composition story) with the play test
- Call sites that pass descriptions (verify login-form BSN hint is now announced)
## Steps
1. Implement the describedby join in the input atoms; form-field renders `-desc` only
when a description input is set (it already does — verify).
2. Write the play tests (form-field composition + alert roles).
3. Alert role switch + rationale comment.
4. Manual screen-reader spot check (optional but recommended — note result here).
## Acceptance criteria
- [x] Description text is programmatically associated in the canonical composition;
login-form BSN hint announced.
- [x] `-error` id appended exactly when invalid; order stable.
- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
CI gate.
## Deviation from the original plan
`radio-group`/`checkbox` were left unchanged — grepping every call site found zero
consumers pairing either with a `form-field` `description` (only the login-form BSN
field, which uses `text-input`). The WP's own Files note ("describedby joins; only
where the component takes a hint") already carved out this exact case — adding
`hasDescription` to atoms with no live description consumer would be unused surface,
not a fix. `radio-group` already had correct `-error`-only wiring; untouched.
`describedBy()` was added directly on `text-input` rather than factored into a shared
`shared/kernel` helper — one consumer, ~5 lines, not worth the indirection yet.
Manual screen-reader spot check (optional per the WP) skipped; the play test is the
enforced check going forward.
## Verification
GREEN + `npm run test-storybook:ci` (includes the new play tests).
## Out of scope
Route-change focus and template lint (WP-17); rewriting form-field's layout.
## Risks
`aria-describedby` churn on validity flips re-announcing content — pinned order + play
test coverage keeps it deterministic.
@@ -0,0 +1,102 @@
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
Status: done (pending commit)
Phase: 4 — a11y
## Why
Three app-level gaps close the WCAG story:
- **No route-change focus/scroll management** — `app.config.ts` has only
`provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever
it was and scroll position is unmanaged. (The wizard manages focus _within_ steps; the
skip link is the only cross-page mechanism.)
- **No template a11y linting** — `@angular-eslint` is entirely absent.
- User decision: a **manual WCAG checklist** documents what automation can't test.
## Read first
- `src/app/app.config.ts`
- `eslint.config.mjs`, `package.json`
- `src/app/shared/layout/shell/shell.component.ts` (`#main` target),
`page-shell/page-shell.component.ts` (candidate focus target)
## Decisions (pre-made, don't relitigate)
- Scroll: `withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })`.
- Focus: on `NavigationEnd`, move focus to the new page's `h1` (or `#main` fallback,
`tabindex="-1"`), **deferred via `afterNextRender`** so it coexists with view
transitions; skip the initial load. Implement as a small root service/effect wired in
`app.config.ts` — not per page.
- Lint: `angular-eslint` at the version matching Angular 22 (~v21+ — check
`npm view angular-eslint peerDependencies`), with `processInlineTemplates` (this repo
has **no** `.html` templates). Rules: `alt-text`, `label-has-associated-control`,
`interactive-supports-focus`, `click-events-have-key-events`, `valid-aria`,
`elements-content`. **Verify the processor fires** by planting a known-bad pattern
before trusting green.
- The checklist is a living doc with per-page status columns — not prose.
## Files
- `src/app/app.config.ts` + a new small `shared/` focus-on-navigation service
- `eslint.config.mjs`, `package.json` (angular-eslint)
- Any template the new lint rules flag
- New `docs/reference/wcag-checklist.md`
- New `src/docs/a11y.mdx` — title `Foundations/Accessibility`
## Steps
1. Router scroll + focus service; manual keyboard smoke (tab through, navigate, confirm
focus lands on the new page's heading).
2. Add angular-eslint; enable the template rules on inline templates; plant a bad
pattern, see it fail, remove it; fix real hits.
3. `docs/reference/wcag-checklist.md`: manual checks per page (dashboard, wizards, brief, login) —
keyboard walk & focus order, no traps, 200% zoom/reflow, NVDA or VoiceOver pass,
visible focus, error announcement; status columns (page × check).
4. `src/docs/a11y.mdx`: the layered approach — axe gate (WP-01) + template lint + play
tests (WP-16) + manual checklist; the alert-role rationale; where the
`a11y.disable` skip register lives and its rules.
## Acceptance criteria
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
view transitions still play.
- [x] Template a11y rules active and _proven_ to fire; lint green.
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
## Deviation from the original plan
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
setup. Less code to hand-maintain, same coverage plus more.
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
`.feedback` flex row doesn't wrap. Documented in `docs/reference/wcag-checklist.md` with the
root cause, **not fixed** — fixing live component CSS found via the checklist is the
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
WP's own deliverable. Flagged here so it isn't lost.
- "Screen reader" column left unfilled for every page — the pass available in this
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
coverage than was done.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: keyboard-only walk of
login → dashboard → wizard → brief.
## Out of scope
Full manual audit of every page (the checklist enables it; filling it beyond the
dashboard is ongoing work, not this WP).
## Risks
- angular-eslint's inline-template processor silently not running → the planted-violation
step is mandatory.
- Focus-move vs `withViewTransitions` timing → `afterNextRender` + test in a real browser
(`npm start`), not just unit-level.
@@ -0,0 +1,191 @@
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid
> **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine:
>
> - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked
> (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a
> step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks
> it. Realized on the BIG-nummer, not the BSN, because **no BSN travels the wire** (see
> PRD-0002 §5c note).
> - **P3-lite audit + guard (§8, §6):** a no-PII `AuditAuthz` log line records reveal
> attempts (allow/deny) and org-admin denials; the already-built `capabilityGuard` is
> now wired onto the `brief/huisstijl` admin route.
>
> Still unbuilt: data-scoping (§5b), real step-up/MFA, break-glass.
## 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`
computed 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 was fully open: no `[Authorize]`, no principal, ownership is a constant
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; 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/reference/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
union, identity-vs-authorization split — see the deviation noted below)
- `docs/project/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
authorization helper)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
SoD guard to `Authz.CanActOn`)
- `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
## 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.
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
`Principal` is built server-side from the existing dev stand-in (`X-Role` header),
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 — exactly
`brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
(draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
stays exactly the three above.
- **Emit and enforce are the same code path for approve/reject.**
`Authz.CanActOn(action, principal, drafterId)` is the SAME check
`BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
BOLA bug it calls out). `Send` is deliberately **not** role-gated (see Risks) —
that parity is preserved exactly, decisions only mirror it.
- **Dev role toggle survives** as the POC's identity stub: `?role=` still picks an
identity for demo purposes, resolved into a `Principal` server-side via
`Authz.ResolvePrincipal`. Commented `dev stub — NOT a security boundary` per
PRD-0002 §3.
- **Deviation from the original plan — `auth/domain/session.ts` is untouched.** An
earlier draft of this WP planned a `Session → Principal` rename in the SSP's login
domain. That's **out of scope**: ADR-0002 explicitly lists that refactor as
"deferred until a second actor is actually introduced" (§"Out of scope here"), and
no second actor exists yet — renaming a type to a one-variant union ahead of that
need is exactly the premature abstraction the ADR warns against. It also turned
out unnecessary: the brief workflow's drafter/approver "acting identity" is a
**separate axis** from the SSP login session (a Zorgverlener logs in via BSN;
drafter/approver is an independent `?role=` toggle, not tied to that login). This
WP's `Principal` therefore lives entirely in the backend's
`BigRegister.Domain.Authorization` namespace and never touches `auth/`.
## Files (as built)
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
`PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
CanActOn/Decisions`.
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
(same Forbidden-before-Conflict ordering as before).
- `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
(including `send`, which had no `HttpContext` before) now returns a fresh
`BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
stale after a mutation.
- `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
- `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
`BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
tests for live decisions and `/me`.
- `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
- `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
`parseMe` boundary (unknown capability strings are dropped, not rejected).
- `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
deny-by-default.
- `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
**Built but deliberately unwired**: no route in this app needs a capability gate
today (both drafter and approver land on the same `/brief` page; the gating is
per-action, not per-page). It's the available building block for a future
approver-only page.
- `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
- `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
`BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
`decisions`; the pure `transition()` helper replaces them with each fresh
server value.
- `src/app/brief/infrastructure/brief.adapter.ts` (+ spec) — `save/submit/approve/
reject/send` now return `Result<string, BriefView>` (was `Brief`) via
`parseBriefView`, which also parses `decisions`.
- `src/app/brief/application/brief.store.ts` — deleted `currentRole()`/`editable`;
added `canEdit`/`canApprove`/`canReject`/`canSend` computed straight from
`BriefState.loaded.decisions`.
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (+ stories) —
`editable`/`role` inputs replaced by the four `can*` inputs; the approve/reject
block gates on `canApprove() || canReject()`, the send button on `canSend()`.
- `src/app/brief/ui/brief.page.ts` — passes the four `can*` signals through.
- `src/app/shared/infrastructure/role.ts` — comment updated (no longer claims the
FE derives `editable` from the role reader).
- Regenerated `backend/swagger.json` + `src/app/shared/infrastructure/api-client.ts`
via `npm run gen:api` (new `/me` endpoint + DTO shapes).
## Steps (as executed)
1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
(`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
(79/79 including 10 new tests).
2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
`me.adapter.ts` (+spec) as the general capability-spine infrastructure.
6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
7. Full GREEN gate + a live curl smoke test against the running backend (submit as
drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
flipping correctly at each step).
## Acceptance criteria
- [x] `brief.store.ts` contains no `currentRole()` call and no FE-computed
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
- [x] The SoD rule is enforced server-side regardless of FE state — verified by
curl directly against the backend (drafter calling `/brief/approve` → 403)
and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
- [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
both call it.
- [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
- [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
`Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
- [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
needs it yet — see Files).
## Verification
GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
`dotnet format --verify-no-changes`. Manual smoke via curl against a running
backend: default (drafter) `GET /brief` → `canEdit: true`; submit → decisions
recompute; drafter `POST /brief/approve` → 403; approver `POST /brief/approve` →
200, `canSend: true` afterward. `GET /me` → `[]` for drafter,
`["brief:approve","brief:reject","brief:send"]` for approver.
## 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 a `medewerker`
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
AD/OIDC integration (identity provider stays simulated). The `auth/domain/session.ts`
`Session → Principal` rename (see the Decisions deviation above — ADR-0002 defers
it explicitly).
## Risks
`Send` was already unauthenticated/unauthorized before this WP (no role check on
`POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
(`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
that would have broken the existing `Send_only_from_approved` test (which calls
`send` as the default drafter identity and expects success). If a future WP decides
`send` should be approver-only, that's a deliberate behavior change, not a bug fix.
Every brief mutation endpoint now returns `BriefViewDto` instead of bare `BriefDto`
— a wire-shape change; the generated `api-client.ts` was regenerated and every FE
call site updated, but any other caller of these endpoints outside this repo would
need the same update.
@@ -0,0 +1,140 @@
# WP-19 — Playwright e2e smoke
Status: done (pending commit)
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`, `npm run e2e`.
`timeout-minutes: 15` per the hardened workflow convention already in `ci.yml`. Playwright's
own `webServer` config (`playwright.config.ts`) starts + waits-for-ready + tears down both
the backend and `ng serve` inside that one step — see the "backgrounding servers" deviation
below for why this replaced the originally-planned `&`/`wait-on` approach.
## 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
- [x] `npm run e2e` passes locally against `docker compose up` or `npm start` +
`dotnet run` run manually.
- [x] CI job `e2e` is green and runs on every PR alongside the existing jobs.
- [x] The happy-path spec exercises a real wizard submit against the real backend
(not mocked) and asserts on the resulting UI state.
- [x] The error-path spec exercises `<app-async>`'s error slot + retry via the real
`?scenario=error` toggle, not a mocked HTTP response.
## Deviation from the original plan
- **Backgrounding servers as separate CI steps doesn't work.** The Files section originally
planned starting the backend and `ng serve` with `&` in one step, then `wait-on`-ing both
ports in a later step. A process backgrounded with `&` in one GitHub/Gitea Actions `run:`
step is killed when that step's shell exits — so the later `wait-on` step hung forever on
servers that were already gone (**the 2-hour e2e hang**). Fixed by moving server lifecycle
into Playwright itself: `playwright.config.ts`'s `webServer` array starts both processes,
waits for each to be ready, runs the suite, and tears them down — all inside the single
`npm run e2e` process/step, so it's self-contained locally and in CI alike.
- **Found and fixed a real bug while writing the error-path spec**: `AsyncComponent`'s
built-in `retry()` only calls `.reload()` on a `[resource]` input — every real page
(`dashboard`, `registration-detail`, `aanvraag-detail`, `brief`) feeds `<app-async>`
via `[data]` (a store's combined `RemoteData`), so clicking "Opnieuw proberen" was a
silent no-op everywhere except the showcase teaching page. Added a `retryClicked`
output that fires regardless of feed mode, and wired the two dashboard instances
(`BigProfileStore.reloadProfile()`/`reloadAantekeningen()`) since that's what this
WP's spec exercises. **Not fixed**: `registration-detail`, `aanvraag-detail`, and
`brief` pages still have the same latent no-op retry — same "found via testing,
fixing the whole surface is beyond this WP" call as WP-17's dashboard CSS finding.
Flagging here so it isn't lost.
- Confirmed `currentScenario()` reads `window.location.search` fresh on every call —
the error-path spec's retry assertion had to change from "counts a browser network
request" (the scenario interceptor never reaches the real transport; it substitutes
`throwError` in the rxjs pipe before `next(req)`) to "observes a real Loading→Failure
reload cycle via `aria-busy`". The Steps section's literal suggestion ("assert it
re-fetches... via a network tab") didn't hold; adapted per the WP's own Risks note
to verify actual interceptor behavior first.
- Verified the suite isn't a no-op per the Verification section: temporarily broke
`diplomaOptions`' `value: d.id` (appended `-x`), watched `smoke.spec.ts` fail on the
now-missing `#diploma-d1` selector, reverted.
- The registratie wizard's minimum path needed an actual file upload (`identiteit` is
always required for `registratie` regardless of diploma choice, per
`DocumentRules.CategoriesFor` — only `diploma`/`taalvaardigheid` are answer-gated).
Picked the first DUO diploma (non-English, `Engelstalig: false`) specifically because
it carries zero policy questions, keeping the happy path to one upload.
- CIBG-styled radios hide the native `<input>` behind its `<label>` — Playwright's
`.check()` on the input times out fighting the label for pointer events; the specs
click the `label[for=...]` instead (also more representative of a real click).
## 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.
@@ -0,0 +1,103 @@
# WP-20 — Second locale proof
Status: done (e276629)
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
- [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
- [x] `messages.en.xlf` exists with a translation for every extracted unit.
- [x] `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.
- [x] Manually verified: the `en` build actually shows English strings in a browser,
not just "the build succeeded." (`login.submit`: `nl` bundle ships "Inloggen
met DigiD", `en` bundle ships "Log in with DigiD" — checked in the built JS,
not just that 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.
@@ -0,0 +1,151 @@
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
Status: done (40dbcb2)
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
- [x] 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.
Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
`BeginScope`, and `EndpointTests.Correlation_id_supplied_by_the_caller_is_echoed_back`
/ `..._is_generated_when_the_caller_omits_it` cover the response header.
- [x] Replaying a submit with the same `Idempotency-Key` returns the same result
without minting a second reference (backend test proves this —
`IdempotencyTests`, 3 cases: same key twice, different keys, a replayed
rejection).
- [x] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
any FE-side retry of that submit (not regenerated per HTTP attempt).
`runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
`api-client.provider.spec.ts`.
- [x] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
or a forced 5xx); POST/PUT/DELETE never auto-retry. Covered by
`api-client.provider.spec.ts` (3 retries on GET, 1 attempt on POST).
## Verification
GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
and server-generated) and that replaying `/api/v1/change-requests` with the same
`Idempotency-Key` returns the identical `referentie` while a different key mints a
new one; tailed the console log to confirm the correlation id shows up on a brief
endpoint's log line that never explicitly threads it.
**Deviation**: the `?scenario=error` network-tab check this section originally
proposed doesn't actually work — `scenario.interceptor.ts`'s `error` case
`throwError`s before ever calling `next(req)`, so no real request reaches the
network stack and devtools shows nothing to retry. Automated tests
(`api-client.provider.spec.ts`, using a fake `HttpClient`-shaped `.request()` so no
TestBed/HttpClientTestingModule is needed) are the actual proof of the retry
mechanism instead — a more reliable check than a manual browser pass would have
been anyway.
## 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.
@@ -0,0 +1,185 @@
# WP-22 — Durable persistence (optional tier)
Status: done (556f2f4)
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/reference/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
- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
`Data/*.cs` for application/document/brief state.
- [x] Every existing backend test passes unchanged (signatures didn't change).
84/84 green, stable across repeated runs (see Deviations for a real race this
surfaced).
- [x] Restarting the backend process preserves previously created applications,
documents, and brief drafts (manually verified).
- [x] 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). `AuditEntries`
is a real table now; not separately re-verified across restart beyond the
applications/brief checks (same store mechanism, same `Db.Create()` seam).
- [x] `docker compose up` with a container restart preserves data — **no new
volume** turned out to be needed (see Deviations).
## Verification
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
src/BigRegister.Api`, created an application via curl, killed and restarted the
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
brief). Repeated the same check against the **real** `docker compose up` stack
(this environment has an actual podman-backed compose, not a mock) — created an
application via `curl localhost:5000`, ran `docker compose restart api`, confirmed
it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db`
is the file being written (gitignored, not tracked).
## 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).
**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports
synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every
store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero
changes. The stores stayed **static classes** with no DI — each method opens its
own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under
the same `lock (_gate)` each store already had, which now doubles as a single-writer
guard for the SQLite file (SQLite tolerates only one writer at a time anyway).
## Deviations from the plan
- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed
`SeedData` populates the three stores and needs a "seed if empty" migration. It
doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints
(registration, person, diplomas, notes), which stay in-memory and are untouched by
this WP. Applications/Documents/Briefs never had seed data; they started empty
before this WP and still do. One less step than planned.
- **No new docker-compose volume.** The existing `./backend:/src` bind mount already
covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside
the bind-mounted tree — confirmed empirically, not just by reading the compose
file), so a container restart already persists it for free. Added a comment
instead of a redundant `volumes:` entry.
- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became
JSON text columns**, not new relational tables — matches the WP's own "relocate
the shape, don't redesign it" instruction and the existing "the backend treats
brief content as opaque" posture.
- **Found and fixed a real test race, not a hypothetical one.** The stores read a
single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary
shape — no DI). xUnit's default parallel-across-classes execution ran multiple
`WebApplicationFactory` hosts concurrently in the one test process, each
overwriting that same static field with its own temp-file path — caught as a
`SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls
interleaving on whichever file won the race. Fixed with
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
(`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for
a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was
actually gone, not just less likely.
- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite`
10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known
high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a
patched one and built/tested cleanly as a drop-in.
- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new
`.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest
there (`swashbuckle.aspnetcore.cli`); matched that convention.
@@ -0,0 +1,114 @@
# WP-23 — Org-template backend + admin role
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
PRD "Brief opstellen v2" splits a rendered letter over two orthogonal template axes:
the **case-type template** (section structure + placeholders — exists, unchanged) and
a new **organization template** (appearance/identity per sub-organization: letterhead,
footer, signature, margins). This WP builds the second axis server-side plus the
`admin` role that will edit it (WP-26). Everything downstream (canvas WP-24, preview
WP-25, editor WP-26) reads what this WP serves.
## Read first
- `docs/project/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`)
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source)
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22)
- `docs/project/backlog/WP-18-abac-capability-spine.md` (how the capability spine works)
## Decisions (pre-made, don't relitigate)
- **No case model, no sub-org auth scoping.** `GET /brief` stays; the brief just
gains a `SubOrgId`. Two seeded sub-orgs (`cibg-registers`, `cibg-vakbekwaamheid`)
exist purely so the admin editor can demo isolation.
- **One row per sub-org**, versions as a JSON history column
(`OrgTemplateEntity { SubOrgId PK, Draft json, PublishedVersion, History json }`) —
the WP-22 JSON-column precedent; no extra tables. Publish = append draft snapshot
to history + version++. Rollback = copy `History[v]` into `Draft` (history stays
append-only; admin republishes).
- **Sent letters are immutable**: `Send` pins `SentOrgTemplateVersion`; a sent
brief's `BriefViewDto.orgTemplate` resolves from history, never from the current
published version. Unsent briefs always follow the current published version —
that is the point of the admin editor.
- **Admin = third `X-Role` value** (`PrincipalRole.Admin`), capability
`orgtemplate:edit` via `GET /me`. Approve/reject gain an explicit
`Role == Approver` condition so the new role cannot slip through the SoD-only
check. The existing `X-Admin` document-deletion seam stays untouched.
- **Trimmed model** (PRD §3 minus): no signature image asset, no structured address
objects, no per-template fonts. Return address / footer contact are multiline
strings. Margins are 4 bounded ints (mm, 1050) — server-validated.
- **Logo = existing upload machinery**: seed an `org-logo` category (png/jpeg, 1 MB)
under a `org-template` wizardId; the template stores only `logoDocumentId`.
- Seed values come from the sample artifact `voorbeeldbrief-inschrijving.pdf`
(A. de Vries / Hoofd Registratie / Postbus 00000 / info@voorbeeld.example — all fictitious).
## Files
- `backend/src/BigRegister.Api/Contracts/Dtos.cs``MarginsDto`, `OrgTemplateDto`,
`OrgTemplateVersionDto`, `OrgTemplateAdminViewDto`, `SaveOrgTemplateRequest`,
`PublishOrgTemplateResponse`, `SubOrgSummaryDto`; `BriefViewDto` + `OrgTemplate`.
- `backend/src/BigRegister.Api/Data/OrgTemplateStore.cs` (new) — entity + store + seed.
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` — OrgTemplates DbSet + JSON converters.
- `backend/src/BigRegister.Api/Data/Migrations/*` — new migration.
- `backend/src/BigRegister.Api/Data/BriefStore.cs``SubOrgId`, `SentOrgTemplateVersion`, pin at `Send`.
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs``Admin` role, capability, gate.
- `backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs``org-logo` category.
- `backend/src/BigRegister.Api/Program.cs` — 5 admin endpoints, `ToView` orgTemplate resolution.
- `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` (new).
- Regenerated: `backend/swagger.json`, `src/app/shared/infrastructure/api-client.ts`.
- FE seam only: `src/app/shared/domain/role.ts`, `shared/domain/capability.ts`,
`shared/infrastructure/role.ts`, `shared/infrastructure/role.interceptor.ts`.
## Steps
1. DTOs (above).
2. `OrgTemplateEntity` + `OrgTemplateStore` (list/get/saveDraft/publish/rollback/
published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs,
draft == published v1) + AppDbContext mapping + migration.
3. `PrincipalRole.Admin`; `ResolvePrincipal` reads `admin`; `RoleCapabilities(Admin)`
`orgtemplate:edit`; approve/reject checks require `Approver` explicitly.
4. Endpoints: `GET /admin/org-templates`, `GET|PUT /admin/org-template/{subOrgId}`,
`POST …/publish` (returns impact count = unsent briefs of that sub-org),
`POST …/rollback/{version}`. All 403 for non-admin via `Authz`.
5. `BriefEntity.SubOrgId` (seed `cibg-registers`) + `SentOrgTemplateVersion`; `Send`
pins; `ToView` resolves published-vs-pinned into `BriefViewDto.orgTemplate`.
6. Seed `org-logo` upload category.
7. `npm run gen:api`.
8. FE: widen `Role`/`Capability` unions, `currentRole()`, interceptor URL filter
(nothing consumes them yet — WP-24/26 do).
## Acceptance criteria
- [x] Publish increments `publishedVersion` and appends to history; rollback copies an
old version into the draft without rewriting history.
- [x] Every admin endpoint returns 403 for drafter/approver, 200 for `X-Role: admin`.
- [x] A sent brief keeps its pinned org-template version after a republish; an unsent
brief follows the new published version (both asserted in one test).
- [x] Publish impact count = number of unsent briefs of that sub-org.
- [x] `PUT` with out-of-bounds margins → 400.
- [x] `GET /brief` carries `orgTemplate`; existing brief tests stay green.
- [x] `GET /me` with `X-Role: admin``["orgtemplate:edit"]`.
- [x] Full GREEN (FE untouched functionally, but lint/test/build/storybook all pass).
## Verification
`cd backend && dotnet test`; GREEN one-liner; curl smoke: admin list/save/publish
(200) vs drafter (403); sent-brief pin walk-through per acceptance.
## Out of scope
The canvas (WP-24), HTML preview endpoints + archive-at-send (WP-25), the admin UI
(WP-26). Template approval chains (draft→publish is enough for the POC; flagged as
an open question in the PRD). Sub-org-scoped brief authorization.
## Risks
`BriefViewDto` gains a field — additive, but the FE `parseBriefView` boundary and
generated client must be regenerated in the same WP to keep the drift check green.
Adding `PrincipalRole.Admin` touches the approve/reject SoD path: the explicit
`Role == Approver` condition must preserve today's Forbidden-before-Conflict order
(existing tests prove it).
@@ -0,0 +1,99 @@
# WP-24 — Letter canvas (edit on the letter)
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
The drafter should compose **on the letter** — letterhead above, footer/signature
below, content blocks edited in place — instead of in an abstract form next to a
separate preview. PRD Brief v2 §4. This is a **presentation rebuild only**: the
domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
## Read first
- PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf`
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot)
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in)
- `docs/project/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`)
## Decisions (pre-made, don't relitigate)
- **One stylesheet is the FE⇄BE rendering contract**: `public/letter.css` — class
vocabulary `.letter`, `.letter__letterhead`, `.letter__body`, `.letter__signature`,
`.letter__footer`, `.letter__page-break`; margins as `--letter-margin-*` custom
props; `@page`/print rules. Loaded via `<link>` in `index.html` (app + Storybook).
WP-25's backend renderer inlines the same file; its parity test is the fence.
- **`LetterCanvasComponent`** (new organism, `Domein/Brief/Letter Canvas`) with
`editableRegions: 'content' | 'template' | 'none'` — one component serves drafter
composing, approver review (and in WP-26, the admin editor). Letter typography on
the canvas is the letter's, not the portal UI's — but still token-bridged.
- `'content'` mode hosts the **existing** `letter-section`/`letter-block` components
unchanged; letterhead/footer/signature render read-only with a subtle tint and a
first-use caption ("komt uit de huisstijl van de organisatie").
- `'none'` mode absorbs `letter-preview`'s rendering (paragraph grouping, placeholder
chips, sample-values toggle); **`ui/letter-preview/` is then deleted** — the PRD
explicitly supersedes it; no third rendering is maintained.
- **Page-break indicator is approximate by design**: a dashed line per A4-content
interval with the caption "±pagina-einde — afdrukvoorbeeld is leidend" (PRD §2b
honesty requirement).
- `brief.machine.ts` and `BriefMsg` are untouched — `git diff` must prove it.
## Files
- `public/letter.css` (new), `src/index.html` (link)
- `src/app/brief/domain/org-template.ts` (new, pure) — `OrgTemplate` type
- `src/app/brief/infrastructure/brief.adapter.ts` (+spec) — parse `orgTemplate`
- `src/app/brief/ui/letter-canvas/*` (new: component + stories)
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (pivot swap) + stories
- `src/app/brief/ui/brief.page.ts` (pass orgTemplate through)
- delete `src/app/brief/ui/letter-preview/*`
## Steps
1. `letter.css` from the sample PDF's geometry (A4 proportions, letterhead, address
window + reference block, footer rule, signature).
2. `OrgTemplate` domain type + `parseOrgTemplate` boundary in the adapter (+spec).
3. Canvas organism: three modes, zoom input, page-break indicator.
4. Migrate `letter-preview` rendering into `'none'` mode; delete the component,
fold its stories into the canvas stories.
5. Swap the composer's `@if (canEdit())` pivot for
`<app-letter-canvas [editableRegions]="canEdit() ? 'content' : 'none'">`.
6. Stories: three modes + zoom + page-break, all axe-gated.
## Acceptance criteria
- [x] Drafter edits blocks in place on the letter surface; passage picker and
diagnostics click-to-locate still work on the canvas.
- [x] Approver sees the identical surface read-only with the action bar.
- [x] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
- [x] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
- [x] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
- [x] Full GREEN + e2e smoke.
Field notes (landed alongside): the CIBG huisstijl styles bare `<header>`/`<footer>`
elements (robijn background), so the canvas regions are `<div>`s — the letter surface
must stay letter.css-only. The passage picker's checkboxes now get unique
`checkboxId`s (all previously resolved to `id="undefined"`, so labels toggled only
the first box — multi-select was broken) and an opaque background; `.letter__body`
stacks above the page-break marks so the dashed line never draws through content,
while the "±pagina-einde" caption floats above everything for legibility.
## Verification
GREEN one-liner; `npm run e2e` (brief flow); manual: `?role=drafter` compose on
canvas, `?role=approver` review; `?scenario=slow|error` still degrade gracefully
through `<app-async>`.
## Out of scope
Server-rendered preview + `[NOG IN TE VULLEN]` resolution (WP-25); admin `'template'`
mode wiring beyond the input existing (WP-26 gives it a consumer); zoom controls
polish + standaardbrief + diff badges (WP-27).
## Risks
The canvas duplicates the letter markup that WP-25's backend renderer will emit —
acceptable _only_ because `letter.css` is shared and WP-25 adds the class-parity
test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible.
Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete.
@@ -0,0 +1,90 @@
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
"What you compose is what is sent" needs a server-side rendering of the letter —
placeholders resolved, org template applied — from the same CSS contract the canvas
uses (PRD §2b: one rendering, used twice). The preview is the artifact: at send, the
same composition is archived with the brief, making sent letters immutable.
## Read first
- PRD Brief v2 §2b, §8; `docs/project/backlog/WP-24-letter-canvas.md` (the `letter.css` contract)
- `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house
pattern: `.ExcludeFromDescription()` + hand-written FE fetch)
- `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent)
## Decisions (pre-made, don't relitigate)
- **HTML, not PDF** (user decision at plan review): no Microsoft.Playwright/Chromium
dependency in the POC. `GET /api/v1/brief/preview` returns `text/html` — the fully
composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual
affordance). The endpoint is the seam where a headless-Chromium PDF render slots
in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if
the POC ever needs real PDF bytes`.
- **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from
seed/case data; unresolved manual keys render as `[NOG IN TE VULLEN: label]`
(PRD §8) — preview is allowed with errors, only send blocks on them.
- **Parity is tested, not hoped for**: a golden-file test snapshots the composed
HTML; a second test asserts every `letter`-prefixed class in the golden HTML
exists in `letter.css`. `dotnet test` never launches a browser.
- **Archive at send**: `Send` stores the composed HTML in `BriefEntity.ArchivedHtml`
(SQLite text column) alongside the WP-23 version pin; the preview endpoint serves
the archive when status is `sent`, so a republish never changes a sent letter.
- **Two endpoints, both excluded from OpenAPI** (JSON-only generated client stays
clean): `GET /brief/preview` and `GET /admin/org-template/{subOrgId}/preview`
(proefbrief: draft template + a fixture brief). FE consumes them via a small
hand-written fetch (needs the `X-Role` header) → blob → object URL in a new tab.
- Watermark: previews of unsent letters carry a `VOORBEELD` watermark (CSS), the
archived/sent rendering never does — the PRD's open question resolved the simple way.
## Files
- `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` (new)
- `backend/src/BigRegister.Api/Data/BriefStore.cs``ArchivedHtml` + archive at send (+migration)
- `backend/src/BigRegister.Api/Program.cs` — 2 preview endpoints
- `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` (new) + `LetterHtml.golden.html`
- `src/app/brief/infrastructure/letter-preview.adapter.ts` (new, fetch → `Result<string, Blob>`)
- `src/app/brief/application/brief.store.ts``previewLetter()` command
- `src/app/brief/ui/letter-composer/*` — "Voorbeeld" button
## Steps
1. `LetterHtml.Render` + placeholder resolution + data-URI logo + watermark flag.
2. Golden-file + class-parity tests.
3. Endpoints (serve archive when sent; proefbrief renders the draft template).
4. Archive-at-send in `BriefStore.Send` (+ migration for `ArchivedHtml`).
5. FE adapter + store command + button (explicit action — no live re-render; PRD §8).
## Acceptance criteria
- [x] Preview opens the composed print-ready letter in a new tab; browser print
shows correct margins via `@page`.
- [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
despite lint errors (only send blocks).
- [x] A sent brief serves its archived HTML unchanged after an org-template republish.
- [x] Golden + parity tests green without any browser installed.
- [x] `swagger.json` unchanged by the two endpoints (drift check green).
## Verification
`cd backend && dotnet test`; GREEN one-liner; manual: compose → preview → print
dialog; send → republish template → preview still the archived rendering.
## Out of scope
Real PDF bytes / headless Chromium (the deliberate deferral — the endpoint is the
seam). Pixel-parity testing (the shared CSS + class-parity test is the fence).
Pagination fidelity beyond the browser's own print engine.
## Risks
`LetterHtml` reads `public/letter.css` from disk — path must resolve for `dotnet run`,
tests, and docker (bind mount/copy); fail loudly with a clear error if missing.
The golden file will churn whenever the letter structure changes — that is its job;
update it deliberately, never blindly.
@@ -0,0 +1,118 @@
# WP-26 — Admin org-template editor
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
The org template (WP-23) needs its editor: an admin edits, per sub-organization, the
letter's appearance **in place on the same canvas** the drafter composes on — the
mirror image (`editableRegions='template'`: letterhead/footer/signature editable,
content a read-only sample). PRD Brief v2 §5, §7h.
## Read first
- PRD Brief v2 §5, §7h; `docs/project/backlog/WP-23/24/25` (endpoints, canvas, proefbrief)
- `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`)
- `.claude/skills/form-machine` — the house form idiom this editor follows
- `src/app/shared/ui/upload/single-upload` (logo upload reuse)
## Decisions (pre-made, don't relitigate)
- **Lives in the `brief` context** (route `/brief/huisstijl`, lazy) — same bounded
capability, no new context. Gated by `AccessStore.can('orgtemplate:edit')` with a
denial alert (deny-by-default); no new route guard.
- **House form-machine idiom**: `org-template.machine.ts`
(`OrgTemplateState`/`OrgTemplateMsg`, pure `reduce` + spec) — draft fields are form
state, not brief state. Store (`org-template.store.ts`, root singleton) does
debounced draft save (mirror `BriefStore.scheduleSave`), publish, rollback.
- **Publish shows impact first**: confirmation displays the WP-23 impact count
("Dit raakt N nog niet verzonden brieven") before the POST.
- **Version history is a list, rollback copies into draft** (WP-23 semantics) —
no side-by-side rendered diff (deferred; field-level history list is enough here).
- **Logo upload reuses `single-upload`** against the `org-logo` category; the canvas
shows `<img src="/api/v1/uploads/{id}/content">`.
- **Proefbrief** = the WP-25 admin preview endpoint; just a button.
- Margins are bounded number inputs (server re-validates, WP-23).
## Files
- `src/app/brief/domain/org-template.machine.ts` (+spec)
- `src/app/brief/application/org-template.store.ts`
- `src/app/brief/infrastructure/org-template.adapter.ts` (+spec, `parseOrgTemplateAdminView`)
- `src/app/brief/ui/org-template-editor/*` (organism + stories)
- `src/app/brief/ui/org-template.page.ts`
- `src/app/app.routes.ts` (route `brief/huisstijl`)
## Steps
1. Machine (fields, `FieldEdited`/`MarginEdited`/`LogoSet`/`DraftLoaded`/save-publish
outcome Msgs) + spec.
2. Adapter (generated client CRUD + parse boundary) + spec.
3. Store: load (sub-org list + selected), debounced save, publish (impact confirm),
rollback.
4. Editor UI: sub-org switcher, canvas in `'template'` mode with inline-editable
regions, margins inputs, logo upload, version history + rollback, proefbrief
button, publish bar showing live version + published-at.
5. Route + capability gate + stories (axe).
## Acceptance criteria
- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the
canvas, and see the canvas update live.
- [x] Draft saves are debounced; publish asks for confirmation showing the impact
count; after publish the drafter's canvas (WP-24) reflects it on reload.
- [x] Version history lists published versions (who is faked, when is real);
rollback copies an old version into the draft.
- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
- [x] Logo upload validates type/size client-side (existing `rejectReason`) and
renders on the canvas after upload.
- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
- [x] Full GREEN.
## Deviations / notes (as built)
- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*,
#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here
to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
`passage-picker` `var(--rhc-color-wit, #fff)` hit.
- **Canvas edit-in-place**: `editableRegions='template'` now renders the seven
org-identity text fields as inline `<input>`/`<textarea>` controls (aria-labelled)
and shows the logo `<img>`; the letter body stays a read-only sample
(`SAMPLE_LETTER_BRIEF`). `logoUrl` input added to the canvas and wired through the
composer too, so a published logo shows to the drafter (AC2).
- **Load-effect loop (caught in the live walk)**: the page's initial-load effect must
NOT read the store model — `load()` dispatches `Loading` (a fresh object), which
would retrigger a model-reading effect into a runaway loop that saturated the page.
Gated on `canEdit()` + a plain `loadRequested` flag instead.
- **`AccessStore.ready`** added (tiny): a page-level capability gate needs to tell
"still loading `/me`" from "denied" so an admin doesn't flash the denial alert.
- **`uploadContentUrl`** pure helper extracted from `UploadAdapter.contentUrl` so
`BriefStore` can build a logo `src` without pulling `ApiClient` into its DI graph
(kept its spec green).
- **Role caching caveat**: `/me` loads once. Open the app with `?role=admin` from the
first navigation that touches the editor (the dev role stub, like `?scenario=`);
switching role mid-session won't refetch capabilities (out of scope, POC).
- **Dirty race**: `DraftSaved` carries the saved draft and clears `dirty` only if it's
reference-equal to the current draft, so an edit landing during a save round-trip
keeps its pending save.
## Verification
GREEN one-liner; manual walk: admin edits footer + margin → canvas live-updates →
proefbrief shows draft → publish (impact count) → `?role=drafter` reload shows new
appearance; second sub-org unaffected.
## Out of scope
Template approval chain (four-eyes on templates — PRD open question, out for POC).
Rendered side-by-side version diff. Soft locks. New shared overlay/modal component —
the publish confirmation uses the existing inline confirmation pattern, not a dialog.
## Risks
The editor is the first consumer of `editableRegions='template'` — WP-24 built the
input but nothing exercised it; budget for canvas fixes here. Debounced draft save +
publish can race — flush the draft save before publishing (same
`clearTimeout`+`flushSave` discipline as `BriefStore.transition`).
@@ -0,0 +1,97 @@
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
PRD Brief v2 §7: the working-day features that make the composer pleasant daily.
Several are nearly free **because** state is one immutable value — that's the
teaching payload: undo/redo is a shell-side snapshot list, the rejection diff is a
pure function over two values. Say so in code comments and stories.
## Read first
- PRD Brief v2 §7 (and the plan-review trim recorded below)
- `src/app/brief/application/brief.store.ts` (autosave + `SaveState` already exist)
- `src/app/brief/domain/brief.machine.ts` — the `Seed` Msg (undo/redo's restore path)
## Decisions (pre-made, don't relitigate)
- **Trim agreed at plan review.** IN: undo/redo, autosave retry affordance,
standaardbrief, passage search, canvas zoom controls, Ctrl+Z/Ctrl+Shift+Z,
block-level rejection-diff badges. OUT (deferred, one line each in Out of scope):
soft lock/takeover, case-context panel, 401 autosave grace, per-user usage counts,
shortcut-overlay dialog, inline character-level text diff.
- **Undo/redo is shell state, not machine state**: a `past`/`future: Brief[]` stack in
`BriefStore` (cap 50; push on `edit()`; clear `future` on a new edit); restore
dispatches the **existing `Seed` Msg** — zero machine changes — then `scheduleSave()`.
(Superseded by WP-31: the hand-rolled stack was later extracted into the shared
`createHistory<T>(cap)` helper — `BriefStore` now holds `this.history =
createHistory<Brief>(50)` and calls `.record`/`.undo`/`.redo`/`.clear` on it; same
cap-50, push-on-edit, clear-on-new-edit semantics, just generalized for reuse.)
- **Standaardbrief**: backend seeds `IsDefault` on 23 kern passages
(`LibraryPassageDto` gains the flag); one button, visible only while the kern
section is empty, dispatches the existing `PassagesInserted` with the default set —
one Msg, one undo step.
- **Passage search is a client-side filter** in the picker (label + content match) —
the library is small; no server search, no usage tracking.
- **Rejection diff**: pure `diffBlocks(before, after): BlockDiff[]` in
`domain/brief-diff.ts` (added/removed/changed by `blockId`); the "before" snapshot
is captured shell-side when the `Rejected` dispatch happens (POC limit: lost on
reload — comment it). Rendered as "gewijzigd sinds afwijzing" badges on the canvas;
the approver gets a "Toon wijzigingen" toggle on resubmission.
- **Autosave retry**: `SaveState.Error` already exists; add the "Opnieuw proberen"
button that calls the existing flush path. No new state.
## Files
- `src/app/brief/application/brief.store.ts` (+spec: history bounds, clear-on-edit,
redo, rejection snapshot)
- `src/app/brief/domain/brief-diff.ts` (new, +spec)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`IsDefault` seed) +
`Contracts/Dtos.cs` (`LibraryPassageDto`) + gen:api + adapter parse
- `src/app/brief/ui/passage-picker/*` (search input)
- `src/app/brief/ui/letter-canvas/*` (diff badges, standaardbrief button, zoom controls)
- `src/app/brief/ui/brief.page.ts` (undo/redo buttons + keydown listener, retry button)
## Steps
1. `diffBlocks` + spec (added/removed/changed/unchanged; changed = same blockId,
different content).
2. Store: history + undo/redo + rejection snapshot (+spec).
3. Backend `IsDefault` + gen:api + parse.
4. UI: standaardbrief button, search, zoom, badges, keyboard, retry.
5. Stories for the new states (axe).
## Acceptance criteria
- [x] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
history capped at 50; a new edit clears redo; restore re-triggers autosave.
- [x] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
step; button gone once kern is non-empty.
- [x] Search filters passages by label and content.
- [x] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
removed blocks are badged (block granularity).
- [x] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
content never lost locally.
- [x] Full GREEN.
## Verification
GREEN one-liner; store + diff specs; manual reject→edit→diff walk with two roles.
## Out of scope (deferred, per plan review)
Soft lock/heartbeat/takeover (real session infra, no FP teaching value here).
Case-context panel (no case data exists). 401 autosave grace (auth is faked).
Per-user passage usage counts (bookkeeping, demos nothing). Shortcut overlay dialog
(no modal component exists; not worth building one). Inline character-level diff
(block granularity carries the teaching point).
## Risks
Undo history holds `Brief` snapshots — deep-frozen immutable values, so sharing is
safe, but never push non-content dispatches (status transitions, `Seed` itself) into
history or undo will replay workflow state. The rejection snapshot lives in memory
only — document it where it's captured.
@@ -0,0 +1,107 @@
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
Status: done (pending commit; `npm run e2e` unverified in this dev sandbox — see Deviations)
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
Phase 6 ships across five WPs; this one makes it demonstrable and closes the loop:
a demo script that maps every kept PRD §12 scenario to a URL + click path, an e2e
spec covering the new flows end-to-end, story gap-fill, and the docs/README updates
that keep CLAUDE.md and the backlog truthful.
## Read first
- PRD Brief v2 §6 (demo choreography), §12 (scenario list); WP-23..27 as built
- `e2e/` (WP-19 conventions); `src/app/shared/infrastructure/scenario.ts`
## Decisions (pre-made, don't relitigate)
- **No preset registry.** The PRD's 18 scenarios collapse onto the existing toggles:
`?role=drafter|approver|admin`, `?scenario=slow|loading|error` (the interceptor
already covers all `/api/` calls, the new endpoints included), and
`POST /brief/reset`. The demo script documents the mapping; no new interceptor
cases, no scenario code.
- Demo script lives at `docs/project/prd/0003-brief-v2-demo-script.md` and follows the §6
choreography (compose → preview → switch sub-org seed → "two axes, one render").
- One e2e spec, not a suite: drafter composes on canvas → submit → approve → send
pins the org-template version; admin publishes → drafter canvas reflects it.
Preview assertion is content-type-level (text/html), not pixel.
- CLAUDE.md gets the new role value + route only — keep it rules, not narrative.
## Files
- `docs/project/prd/0003-brief-v2-demo-script.md` (new)
- `e2e/brief-v2.spec.ts` (new)
- story gap-fill where WP-24..27 left holes
- `docs/project/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up)
## Steps
1. Demo script: table scenario → URL + clicks, covering every kept §12 entry.
2. e2e spec (backend + FE running, WP-19 pattern).
3. Story sweep for the new components/states.
4. Docs updates.
## Acceptance criteria
- [x] Every kept PRD §12 scenario has a working URL + click path in the script
(walked manually once).
- [ ] `npm run e2e` green, including the new spec — **not verified in this dev
sandbox**; see Deviations.
- [x] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
`?role=admin` and `/brief/huisstijl`.
## Deviations / notes (as built)
- **No Brief v2 PRD was ever committed.** WP-23..27 cite "PRD Brief v2 §N" throughout,
but `docs/project/prd/` never held such a file — it only ever existed as chat
context. The demo script (`docs/project/prd/0003-brief-v2-demo-script.md`) is
written directly against the shipped code instead of translating an external §12
scenario list, and says so up top.
- **`?scenario=` does not reach every endpoint.** `/brief/preview`,
`/admin/org-template/{id}/preview` and `/brief/reveal-bignummer` are hand-written
`fetch` calls (same seam as uploads, deliberately `.ExcludeFromDescription()`'d) and
bypass `scenarioInterceptor`. The demo script and CLAUDE.md now say so explicitly.
- **Canvas authoring moved.** The original "drafter composes on the canvas" framing
predates commit `ba32e3d` ("brief v3 — besluit-driven guided drafting"):
`LetterCanvasComponent.editableRegions` no longer has a `'content'` mode. The
drafter now works through `BehandelSchermComponent` (case header + stepper +
`app-besluit-panel` + `app-letter-editor`), with the canvas as a read-only preview
in a modal. The demo script and e2e spec follow that path.
- **`passage-picker` is dead code** — superseded by `besluit-panel`'s guided
drafting, no consumer left besides its own story. Flagged with a comment on the
component rather than deleted in this WP (out of scope for a demo-polish pass).
- **Story gaps were state gaps, not component gaps** — every component already had a
co-located story. Added: `letter-composer` `RejectionDiff` + `AlleenLezen` (WP-27's
diff view and the pure-viewer notice had no story), `letter-canvas` +
`org-template-editor` `MetLogo` (WP-26's logo letterhead had no story), and
`org-template-editor` `LogoUploadFout` (the upload-rejection branch had no story).
- **Org templates have no reset endpoint** — the e2e spec's admin section restores
the org-template draft it edits (rollback + republish) instead of relying on a
reset, so repeated runs don't drift the seeded "BIG-register" template.
- **`npm run e2e` could not be verified green in this dev sandbox** — both the new
`brief-v2.spec.ts` and the pre-existing, untouched `smoke.spec.ts` fail here at
the same kind of step (clicking a CIBG-styled radio's `<label for>`; e.g.
`label[for="correspondentie-post"]` in `smoke.spec.ts`), with Playwright
reporting the element "detached from the DOM, retrying" or a native input whose
rendered box collapses to 1×1px. Reproduced with `--workers=1` and running
`smoke.spec.ts` alone, so it isn't cross-test contention. Since `smoke.spec.ts`
predates this WP and is unrelated to any file it touches, this reads as a
sandbox-specific rendering/CSS-loading issue (fonts 404 here; a stylesheet may
not be fully served), not a regression from this WP's changes. **Needs
confirming green on a normal dev machine / CI** before this box can be ticked.
## Verification
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
`npm run e2e`.
## Out of scope
New scenario interceptor cases; a scenario-switcher UI; screenshots/video.
## Risks
The demo script rots when flows change — it lists URLs + clicks only (no prose
walkthroughs), so churn stays cheap.
@@ -0,0 +1,80 @@
# WP-29 — Stamdata beheer editor (low-code, PR-emitting)
Status: done (0e77faf)
Phase: follow-on — ADR-0004 realization (not part of the 2026-07-02 showcase audit)
## Why
ADR-0004 (stamdata-as-code) named a **future low-code editor that commits a PR** as its
mitigation for "a non-developer may need dev assistance to edit C#", and floated a
**data-file format** as the alternative to typed-C# constants when hand-editing ergonomics
outweigh maximal compile-time safety. This WP realizes both: an admin-only maintenance editor
that reads the stamdata catalog, edits rows in a grid, and produces the edited JSON data-file
the admin drops into the repo — the existing CI build + `StamdataValidationTests` stay the
authority. No production database, no runtime write path (ADR-0004 unchanged).
## Read first
- ADR-0004 (`docs/reference/architecture/0004-stamdata-as-code.md`) — the model this obeys.
- ADR-0001 (BFF-lite + decision DTOs) — the endpoints are screen-shaped, admin-gated reads.
- `src/app/brief/**` (WP-23/26) — the org-template admin editor is the closest prior art
(root store + machine + capability guard + admin role).
## Decisions (pre-made, don't relitigate)
- **Read-only endpoints only.** `GET /stamdata` (catalog) and `GET /stamdata/{table}?peildatum=`
(schema + rows). No POST/PUT/DELETE — the edit lands as a reviewed PR, not a write. The
`stamdata:edit` capability / `CanEditStamdata` gate the _reads_ (naming is the enforce-twin
of a future edit capability; deliberate).
- **Generic, schema-driven.** One endpoint pair + one grid editor serve every table. Columns
are reflected from the typed record (`StamdataTable.Of<T>`); the FE renders inputs by column
type (`date`/`number`/`enum`/`text`). A new table is one line in `StamdataCatalog` — no new
endpoint, UI, or test. (Catalog of one today; this is the shape ADR-0004 prescribed.)
- **Data-file format for `professions`.** `professions.json` (embedded resource) replaces the
hardcoded C# dictionary, deserialized into `ProfessionMapping`. This trades compile-time
_value_ checking (gate #1) for editor ergonomics — the value gate becomes
`StamdataValidationTests` (gate #2), exactly the trade-off ADR-0004's consequences listed.
- **Valid-time.** `geldigVan`/`geldigTot` (half-open `[van, tot)`); a table is temporal iff it
has both columns. `peildatum` previews "which rows applied on date X". `Professions.ByProgram`
preserves pre-valid-time behaviour by filtering to rows active today.
- **Apply path = download → PR.** The editor's `download()` serializes the draft to
`{table}.json`; the admin commits it. `mutation-command` is intentionally not used.
- Admin-only, resource-independent authz — same shape as org-template management (role IS the
decision), denials audited (no PII).
## Files
- Backend: `backend/src/BigRegister.Api/Stamdata/{StamdataCatalog,StamdataTable,StamdataFile,ProfessionMapping}.cs` (new), `Professions.cs` (now loads the data-file), `professions.json` (new), `BigRegister.Api.csproj` (embed `Stamdata\*.json`); `Program.cs` (two GET endpoints + `StamdataAdmin` gate), `Contracts/Dtos.cs` (3 DTOs), `Domain/Authorization/Authz.cs` (`stamdata:edit` + `CanEditStamdata`); tests `StamdataEndpointTests.cs` (new), `StamdataValidationTests.cs` (generic `Every_catalog_table_is_valid`).
- Frontend: `src/app/beheer/**` (contracts / domain + specs / infrastructure + spec / application / ui + organism story); `app.routes.ts` (guarded lazy route), `shared/domain/capability.ts` + `shared/infrastructure/me.adapter.ts` (`stamdata:edit`), `eslint.config.mjs` (`beheer` boundary rules), `tsconfig.json` (`@beheer/*` alias); regenerated `backend/swagger.json` + `src/app/shared/infrastructure/api-client.ts`.
## Acceptance criteria
- [x] `GET /stamdata` and `GET /stamdata/{table}` return admin-only (403 + audit otherwise).
- [x] `professions` served from `professions.json`; `ProfessionMapping` typed; behaviour of
`Professions.ByProgram` unchanged for all-current rows.
- [x] Generic build gate: `Every_catalog_table_is_valid` covers every catalog table (keys
non-blank, no overlapping validity, well-formed windows).
- [x] `beheer/stamdata` route capability-guarded; page shows denial for non-admin; grid
renders from reflected schema; edits update dirty/change-count; `download()` yields a
valid `{table}.json`; `peildatum` before 2000-01-01 → zero rows.
- [x] Full gate GREEN both sides; `npm run gen:api` leaves no drift; new stamdata story passes axe.
## Verification
`cd backend && dotnet test && dotnet format --verify-no-changes`; `npm run lint && npm run
check:tokens && npm test && npm run build && npm run build-storybook && npm run test-storybook:ci`;
`npm run gen:api && git diff --exit-code -- backend/swagger.json src/app/shared/infrastructure/api-client.ts`.
Live: `/beheer/stamdata?role=admin` renders + edits + downloads; without `?role=admin` denies.
## Out of scope
- Runtime persistence of edits (would contradict ADR-0004) — the download-to-PR path is the design.
- Migrating `PolicyQuestions` / document-categories into the catalog (each is a later one-liner).
- A write-back "commit a PR on the admin's behalf" integration (the `download()` seam is where it slots in).
## Risks
- Data-file weakens compile-time value safety for `professions` — mitigated by
`StamdataValidationTests` running in CI (a bad value fails the build, never prod).
- Generic reflection assumes the record's first property is the key and camelCase JSON names —
documented conventions in `StamdataTable`; covered by the endpoint schema test.
@@ -0,0 +1,101 @@
# WP-30 — CI performance follow-ups
Status: done (items 1, 3, 4, 5, 6; item 2 deliberately deferred — see "Status update" below)
Phase: follow-on · CI/infra
## Why
Tier-1 CI speedups shipped in `708d4c2` (CodeQL off the PR path, Playwright/NuGet caches,
`npm ci` flags) and the demo web image shrank to `node:24-slim`. These are the remaining
options that were deliberately deferred — bigger changes, policy calls, or things that need
Gitea runner-admin access. Revisit once there's an actual CI-timing breakdown to prioritise by,
or when someone confirms act_runner access.
Constraint carried over: **CI runs are not observable from the agent's environment** — validate
any workflow edit by watching a real Gitea run; ship one change at a time so a red run is easy to
bisect and revert. **The `docker compose` images are NOT used by CI** (CI = Gitea `ubuntu-latest`
runner image, set on the act_runner host).
## Read first
- `.github/workflows/ci.yml` (current 6 jobs + the Tier-1 caches already in place).
- The `ci-and-local-gate` note (agent memory) — CI traps + what's already done.
- `docker-compose.yml` (demo images; `node:24-slim` done, dotnet SDK still full).
## Candidate items (pick per impact once measured)
1. **Skip `npm ci` install via a `node_modules` cache.** `actions/cache` on `node_modules`
keyed by `package-lock.json` hash; on a hit, `npm ci` is near-instant across the 4 npm jobs.
Bigger win than the existing npm-download cache, but a ~777 MB cache with a small staleness
risk — best if the runner's cache storage is local/fast. Medium effort, low-medium risk.
2. **Smaller CI runner image.**
- _Real fix (needs runner admin):_ point act_runner's `ubuntu-latest` (or a new label) at a
smaller image with node + dotnet preinstalled. Biggest startup win. **Blocked on confirming
act_runner access.**
- _Repo-only partial:_ `container: node:24-slim` on the node-only jobs (`frontend`,
`storybook-a11y`), dropping `setup-node`. Doesn't help the node+dotnet jobs (`e2e`,
`api-client-drift`, `backend`) — a combined image would need building/pushing (new infra).
Risky on act_runner, unverifiable locally → stage alone, last.
3. **Path-filtered jobs.** Skip `backend` on FE-only changes and vice-versa (workflow `paths:`
or `dorny/paths-filter`). Cuts compute on narrow PRs; watch required-check rules that expect
every job to report a status.
4. **Split a fast `lint` job** (lint + format:check + check:tokens) for ~1 min fail-fast
feedback — only worth it once item 1 (node_modules cache) lands, else it duplicates `npm ci`.
5. **Lean deployable backend image** (optional, not for the dev demo): multi-stage prod build on
`mcr.microsoft.com/dotnet/aspnet:10.0` (~220 MB) in a separate `docker-compose.prod.yml`. The
dev `docker-compose.yml` keeps the SDK image because `dotnet run` hot-reload needs it.
6. **Semgrep: triage findings + make it blocking.** Semgrep replaced CodeQL (GitHub-only, couldn't
run on Gitea) and currently runs **report-only** — a local dry-run found 27 findings, mostly
CI/config policy (unpinned GitHub Actions in `ci.yml`, `.npmrc` min-release-age) rather than
app-code vulns. Triage them (fix or `# nosemgrep`/`.semgrepignore` the noise; consider a
tighter ruleset than `p/default` if the GitHub-Actions-policy rules aren't wanted), then add
`--error` to `semgrep scan` so it's a real gate.
## Status update (2026-07-30)
Items 1, 3, 4, 5, 6 implemented, each as its own commit (item 6 `526da76`, item 1 `e46b87b`,
item 4 `e02e8ce`, item 3 `e7db69d`, item 5 see `git log -- backend/Dockerfile`): triaged real
local semgrep findings (25, not the 27 this
file remembered — dependabot cooldown, npm min-release-age, every GH Action pinned to SHA, 2
nosemgrep'd ReDoS false positives) and flipped the gate to `--error`; `node_modules` cache
(skips `npm ci` entirely on a hit) across all 4 npm jobs; a new fast-fail `lint` job split out
of `frontend`; a `changes` job (`dorny/paths-filter`) gating every downstream job's real steps
(not the whole job — the safer "skip steps" variant, so a required-status-check never waits on
a job that never ran) on which side changed; an optional `backend/Dockerfile` +
`docker-compose.prod.yml` (additive, unused by CI or the dev demo).
**Item 2 (smaller runner image) deliberately skipped this round** — the real fix needs
act_runner admin access (unconfirmed), and the repo-only partial (`node:24-slim` on
`frontend`/`storybook-a11y`) conflicts with `storybook-a11y`'s deliberately-chosen
`node:24-bookworm` + memory-cap container (verified against a real OOM risk). Revisit once
act_runner access is confirmed.
**Confirmed GREEN on a real Gitea run (2026-07-30, watched by the user).** The push did catch
one real gap that only a live run could: `backend/Dockerfile` (item 5) predated the item-6
semgrep triage, so its own `dockerfile.security.missing-user-entrypoint` finding (container
running as root) was never locally verified — semgrep's `--error` gate correctly failed the
first push on it. Fixed (`ebf1f8f`): switched to the base image's built-in non-root `app` user
(`$APP_UID`, uid 1654) with `--chown` on both `COPY` layers so SQLite (WP-22, a relative-path
connection string resolved against the container's `/app` cwd) can still write
`bigregister.db`; verified for real (rebuilt, confirmed `whoami` is `app`, hit a live
`GET /brief/preview`, confirmed the db file's actual ownership) before repushing. That run came
back all-green — path-filtering, the node_modules cache, and the split lint job all behave as
designed on the real runner.
## Acceptance criteria
- [x] Each chosen item verified GREEN on a real Gitea run (watched, since it's not observable
from the agent env), landed as its own revertable commit.
- [x] `npm run ci` still passes locally after any workflow/script change (confirmed 2026-07-30,
full run including backend `dotnet test`/`dotnet format` and both drift checks).
## Out of scope
- nx/turbo/remote build caching (overkill for this repo size).
- Rewriting the dev compose into prod images (would lose bind-mount hot reload).
## Risks
- Unverifiable-from-agent workflow edits can only be confirmed on the runner — one change per
commit, revert on red.
- act_runner-level changes depend on infra access this repo doesn't control.
@@ -0,0 +1,52 @@
# WP-31 — Shared store helpers (audit: apply high-value)
Status: done
Phase: 7 — refinements
## Why
A code audit found real duplication across the editor stores. This WP extracts the four
highest-value shared helpers and rewires the stores to them (behaviour unchanged), and
**reports** the lower-value / riskier DDD items as deferred backlog. Extracting `createHistory`
here also unblocks WP-32 (stamdata undo) so it needn't copy-paste the brief pattern.
## Decisions (pre-made, don't relitigate)
- Extract into `shared/application/` (importable by every context; must not import back).
- Apply the four concrete extractions + reuse; **do not** chase the deferred DDD items in this
phase (bound the diff). Behaviour must be identical — the existing store specs are the gate.
## Files
- New (each with a co-located spec): `shared/application/action-state.ts` (`ActionState`/
`SaveState`), `history.ts` (`createHistory<T>`), `debounced-save.ts` (`createDebouncedSave`),
`machine-remote-data.ts` (`machineRemoteData`).
- Rewired: `brief/application/brief.store.ts` (all four), `brief/application/org-template.store.ts`
(types + debounced-save + remote-data), `beheer/application/stamdata.store.ts` (remote-data).
## Acceptance criteria
- [x] `ActionState`/`SaveState` defined once; both brief stores import them.
- [x] `createHistory` backs brief undo/redo (identical semantics; specs pass).
- [x] `createDebouncedSave` backs both brief stores' autosave, integrating `PendingSave`.
- [x] `machineRemoteData` backs the RemoteData projection in all three stores.
- [x] `npm run ci` green; all pre-existing store specs still pass (no behaviour change).
## Deferred (reported, not built) — audit findings for a later WP
- **`contracts/` folder inconsistency:** only `beheer/` + `registratie/` have a `contracts/`
folder; `brief/`/`herregistratie/`/`auth/` declare wire DTOs inline in adapters. Decide whether
inline DTOs are a sanctioned exception or should be normalized.
- **`parse*` traverse combinator:** ~35 `parse*` boundary fns repeat an array-parse-and-collect
shape; a shared `traverse`/`parseAll` `Result` combinator would collapse the common idiom.
- **`Seed { state }` msg boilerplate:** the `Seed`/`return m.state` pair repeats in 6 machines —
cheap and per-machine typed; extract only if it earns its keep.
## Out of scope
- The deferred items above (this WP only applies the four extractions).
## Risks
- Behaviour drift in the central stores — mitigated: the extractions are 1:1 with the originals
and gated by the existing brief/org-template/stamdata specs (all green).
@@ -0,0 +1,39 @@
# WP-33 — In-app dev switchers (scenario + role)
Status: done
Phase: 7 — refinements
## Why
The two dev-only stand-ins — the async `?scenario=` toggle (`scenario.interceptor.ts`) and the
faked `?role=` (`role.interceptor.ts`) — were driven by hand-editing the URL query string.
Awkward for demos: you had to remember the valid values and retype them. This WP surfaces both
as dropdowns in the existing dev panel so a scenario/role can be flipped with a click.
## Decisions (pre-made, don't relitigate)
- **No new component/shell wiring.** The switchers live inside the existing `debug-state`
devtool (the sanctioned dev-only fab/panel already mounted in the shell under `isDevMode()`).
- **Reuse the mechanism modules, don't duplicate their source of truth.** `scenario.ts`/`role.ts`
gain a `set*` setter + an exported valid-values list; the panel imports them. `debug-state` is
added to the `ui→infrastructure` eslint exemption (same precedent as its existing cross-context
exemption) rather than re-declaring the storage keys / valid lists in the UI.
- **Scenario becomes tab-sticky (sessionStorage), mirroring role.** Without this the switcher
would be near-useless: navigation drops the query param and reverts to `default` mid-demo.
- **Apply by `location.reload()`.** Both values are read per-request in interceptors and gate
server-computed decision flags already fetched by eager `httpResource`s — a reload is the
simplest correct way to re-run them. Acceptable for a dev tool.
## Files
- `shared/infrastructure/scenario.ts` — tab-sticky read (mirrors role), `setScenario`, exported
`SCENARIOS`; co-located `scenario.spec.ts`.
- `shared/infrastructure/role.ts``setRole`, exported `ROLES`.
- `shared/ui/debug-state/debug-state.component.ts` — two `<select>`s in the panel.
- `eslint.config.mjs` — extend the debug-state devtool exemption to the ui→infrastructure rule.
## Acceptance criteria
- [x] Dev panel shows role + scenario dropdowns; changing one persists it and reloads.
- [x] Scenario survives navigation within the tab (sticky).
- [x] `npm run ci` green (lint, tests incl. new scenario spec, localized build).
@@ -0,0 +1,45 @@
# WP-34 — Adres: phone field + BRP address read-only
Status: done
Phase: 7 — refinements
## Why
The "Mijn gegevens" screen let the user _edit_ their address (straat/postcode/woonplaats) and
submit it as an adreswijziging. But the BRP (Basisregistratie Personen) is the authority for a
person's address — you change it at the municipality, not in a register self-service portal.
This WP corrects that: the address is shown **read-only** (rendered from the BRP data the
screen already loads), and the editable/submittable contact detail becomes the **phone number**
— the thing CIBG actually holds and the professional can update here.
## Decisions (made while building — no spec existed; flagged for review)
- **The adreswijziging form becomes a telefoonwijziging form.** Address is read-only display;
phone is the one editable field. Same single-step machine idiom (Model/Msg/pure reduce +
value object + submit command) — only the fields changed.
- **New `Telefoonnummer` value object** (parse-don't-validate, lax Dutch format: 10 digits,
leading 0, `+31``0`, formatting stripped). Backend `RejectPhoneChange` re-validates as the
authority (ADR-0001); the FE check is UX-only. Replaces the old address/`RejectChangeRequest`.
- **Phone starts empty.** There is no current-phone anywhere in BRP/seed/Person, so no
current-value round-trip was added (YAGNI) — the form submits a new/updated number. The
read-only BRP address gives the screen its context.
- **Endpoint reused, contract narrowed.** `POST /change-requests` now takes `{ telefoon }`
(category `telefoonwijziging`); the typed client was regenerated (drift check clean).
## Files
- `registratie/domain/value-objects/telefoonnummer.ts` (+spec) — new value object.
- `registratie/domain/change-request.machine.ts` (+spec) — Draft/Valid now `{ telefoon }`.
- `registratie/infrastructure/change-request.adapter.ts` — sends `{ telefoon }`.
- `registratie/ui/change-request-form/change-request-form.component.ts` (+story) — read-only
BRP address block + editable phone field; takes `brpAdres` input.
- `registratie/ui/registration-detail.page.ts` — passes `profile()?.person?.adres`.
- Backend: `Dtos.cs`, `Program.cs`, `SubmissionRules.cs` (+ RuleTests/EndpointTests/IdempotencyTests).
- `src/locale/*` — new/changed `$localize` ids + English targets.
## Acceptance criteria
- [x] BRP address rendered read-only with a "change it at your municipality" note.
- [x] Phone field with format validation (client instant + server authoritative).
- [x] `npm run ci` green (lint, format, tokens, 332 FE tests, localized build, backend 122
tests, api-client drift clean after commit).
@@ -0,0 +1,45 @@
# WP-35 — One Concept per case type (server-enforced)
Status: done
Phase: 7 — refinements
## Why
The FE already keeps at most one Concept (draft aanvraag) per type — but only as a client-side
convenience in `draft-sync.ts` (`resume()`/`findConcept()`/`resumeGate`). Per ADR-0001 the server
is the authority for business rules; the FE guard is best-effort and a cross-tab / stale-list race
can still POST a second Concept. This WP makes "at most one unsubmitted Concept per type" a
**server-enforced invariant**, and makes the FE recover gracefully when the server refuses.
## Decisions (made while building — no spec existed; flagged for review)
- **Enforce at create, not submit.** The invariant is about the _existence_ of Concepts, so the
guard lives in `POST /applications`. Enforcing at submit would only block submitting a duplicate,
not its existence — that doesn't satisfy the title.
- **Race-free in the store.** The check-and-insert happens atomically under the store's single
write gate (`ApplicationStore.CreateConcept`), not as a separate list-then-create in the handler.
- **409 Conflict** (ProblemDetails), matching the applications block's other guards
(cancel-after-submit, submit-twice) — not 422. The generated client now handles 409 explicitly.
- **FE recovery over error banner.** A create-409 means a Concept of this type already exists, so
`ensureId` adopts it (`findConcept`) instead of surfacing an error — the whole point of
one-per-type is that the second attempt lands you on the existing draft. Recovery fires only
when one actually exists; otherwise the original failure is surfaced.
- **Scope: only the persisted-lifecycle types** (`registratie | herregistratie | intake`). The
stateless submits (`telefoonwijziging`, legacy `/registrations` etc.) never create a Concept.
## Files
- `backend/.../Data/ApplicationStore.cs``Create``CreateConcept` (nullable; atomic guard).
- `backend/.../Program.cs``POST /applications` returns 409 when `CreateConcept` returns null.
- `backend/tests/.../ApplicationTests.cs` — helper clears leftover Concepts (tests share one DB);
+3 tests (dup conflicts, different type allowed, new allowed after submit).
- `src/app/registratie/application/draft-sync.ts` (+spec) — `ensureId` adopts the existing Concept
on a create-conflict.
- Regenerated `api-client.ts` / `swagger.json` (create now documents its 409).
## Acceptance criteria
- [x] A second unsubmitted Concept of the same type is refused server-side (409).
- [x] A different type, and a new Concept after the previous is submitted, are allowed.
- [x] FE recovers from the 409 by resuming the existing Concept (no error banner).
- [x] `npm run ci` green (333 FE tests, backend 125, api-client drift clean after commit).
@@ -0,0 +1,49 @@
# WP-36 — Admin cases page + admin delete
Status: done
Phase: 7 — refinements
## Why
Admins can maintain stamdata and org-templates but have no view of the cases (aanvragen) in the
register, and no way to remove an erroneous one. This WP adds an admin-only overview of **all**
cases across owners and an admin **delete** that can remove any case — the back-office counterpart
of the user's own dashboard.
## Decisions (made while building — no spec existed; flagged for review)
- **Single capability `cases:manage`** covers both the list and the delete (one back-office
concern), following the `orgtemplate:edit` / `stamdata:edit` precedent exactly (Authz role→cap +
a `CanManageCases` gate + a `CasesAdmin(ctx,…)` helper; FE `Capability` union + `me.adapter`
`KNOWN` + `capabilityGuard` + nav item + `role.interceptor` prefix).
- **Page lives in `registratie` (not `beheer`).** `registratie` owns the `Aanvraag` aggregate, so
the admin view reuses its `aanvraag-view` labels + `parseApplications` trust boundary instead of
duplicating them — and it respects the layer boundary (`beheer` may not import `registratie`).
This matches the existing pattern (stamdata-admin lives in `beheer` because `beheer` owns
stamdata; org-template-admin in `brief`). Routed at `/beheer/zaken` for a legible admin URL.
- **Admin delete removes ANY case** — any owner, submitted or not — unlike the user-facing
`DELETE /applications/{id}` (owner-scoped, 409 on a submitted case). That is the admin power.
- **Native `confirm()` guards the delete.** No confirm-dialog component exists (the only precedent
is a native `confirm()` in behandel-scherm); the delete is irreversible, so it gets a prompt
rather than the dashboard's no-confirm optimistic cancel.
- **Single owner in practice.** Only `DemoOwner` exists, so the list shows that owner's cases with
an Owner column; no fake multi-user seed was added (the endpoint is cross-owner-capable —
`ListAll()` — so real multi-owner data would just appear).
## Files
- Backend: `ApplicationStore.ListAll()` + `DeleteAny(id)`; `ApplicationSummaryDto.Owner` +
`ToAdminSummaryDto`; `Authz` cap + `CanManageCases`; `Program.cs` `CasesAdmin` gate + `GET
/admin/cases` + `DELETE /admin/cases/{id}`; `AdminCasesTests` (+ update the org-template `/me`
cap-list assertion). SQLite can't `ORDER BY DateTimeOffset``ListAll` sorts client-side.
- FE: `capability.ts` + `me.adapter` `KNOWN` + `role.interceptor` (`/api/v1/admin/cases`);
`aanvraag.ts` `owner?`; `applications.adapter` `listAll`/`deleteAny` + parse owner;
`registratie/application/admin-cases.store.ts` (+spec); `registratie/ui/admin-cases.page.ts`;
route in `app.routes.ts`; nav item in `site-header`; new `$localize` ids + English targets.
## Acceptance criteria
- [x] Admin-only page at `/beheer/zaken` lists all cases (owner + type + status), gated by
`cases:manage` (denial alert for non-admins; server re-enforces via `CasesAdmin`).
- [x] Admin delete removes any case (incl. submitted); confirmed, optimistic with rollback.
- [x] `npm run ci` green (336 FE tests, backend 129, localized build, drift clean after commit).
@@ -0,0 +1,31 @@
# WP-37 — Dev-switcher reset fix (scenario/role)
Status: done
Phase: 8 — platform/DX/showcase
## Why
The WP-33 dev switcher can't reset scenario/role back to `default`/`drafter` — it gets "stuck"
(e.g. on `slow`). Cause: `currentScenario()`/`currentRole()` read the URL `?scenario=`/`?role=`
param **before** sessionStorage, so once a param is in the address bar, `location.reload()` (same
URL) re-reads the stale value and overrides what the switcher just stored.
## Decisions
- Once the switcher is used, **sessionStorage is authoritative**; a leftover URL param must not
win. Strip both dev params from the URL (`history.replaceState`) before reloading.
- Extract the URL rewrite as a **pure** `stripDevParams(href)` so it's unit-testable without
touching `location.reload()`.
## Files
- `src/app/shared/infrastructure/dev-params.ts` (+ `dev-params.spec.ts`) — pure `stripDevParams`.
- `src/app/shared/ui/debug-state/debug-state.component.ts``switchRole`/`switchScenario` call
`applyAndReload()` (replaceState with stripped URL, then reload).
## Acceptance criteria
- [x] Switching scenario/role to any value (incl. default/drafter) sticks after reload, even when
a `?scenario=`/`?role=` param was in the URL.
- [x] `stripDevParams` removes both params, keeps other params + path/hash (spec).
- [x] `npm run ci` green.
@@ -0,0 +1,49 @@
# WP-38 — Dependency graph + declarative boundaries
Status: done
Phase: 8 — platform/DX/showcase
Priority: P1
## Outcome
Adopted **dependency-cruiser**. `.dependency-cruiser.js` is the single declarative source for
context + layer boundaries (incl. the previously-missing `herregistratie` scope + no-circular);
`npm run dep:check` enforces (wired into `ci-local.sh` + the `frontend` CI job), `npm run dep:graph`
emits a mermaid context×layer graph to `docs/reference/architecture/dependency-graph.md`. The
per-context `no-restricted-imports` blocks were **removed** from `eslint.config.mjs` (now only
`no-explicit-any` + template a11y remain); parity verified by planting violations (domain→Angular,
beheer→registratie incl. type-only) and confirming `dep:check` flags them. Doc:
`docs/reference/architecture/dependencies.md`; `new-context` skill updated to the single source.
## Why
Bounded-context + atomic-layer boundaries are enforced only by hand-duplicated
`no-restricted-imports` blocks in `eslint.config.mjs` — pass/fail, no graph, and brittle: the
`new-context` skill literally says "grep the config and copy a block", and `herregistratie` is
missing its explicit ban block (asymmetry). We want to **see** the dependencies AND **enforce**
them from one declarative source.
## Decisions
- **Step 1 — tool fork:** dependency-cruiser (recommended: graph + CI rules on plain Angular) vs
Sheriff (tag-based, DDD/atomic-native, weaker graph). Decide before building.
- Encode context + layer rules once (contexts `shared/auth/registratie/herregistratie/brief/beheer/
showcase`; layers `domain/application/infrastructure/contracts/ui`); **fix the herregistratie gap**.
- Keep ESLint for the intra-file rules it does better (`domain↛@angular`, ApiClient value-import
confinement, `no-explicit-any`); migrate only the cross-module _direction_ rules to the new tool.
- Emit a graph the showcase/teaching can reuse (feeds WP-39).
## Files
- New: `.dependency-cruiser.js` (or `sheriff.config.ts`); `npm run graph` + validate script.
- `scripts/ci-local.sh` + `.github/workflows/ci.yml` — add `depcruise --validate` (non-optional).
- `eslint.config.mjs` — remove the migrated direction rules (keep the rest).
- New doc `docs/reference/architecture/dependencies.md`; embed the graph in a Foundations page.
- `.claude/skills/new-context/SKILL.md` — point at the single declarative source.
## Acceptance criteria
- [x] One declarative config expresses all allowed context/layer edges; herregistratie included.
- [x] `npm run dep:graph` produces a committed mermaid architecture graph; `dep:check` runs in `npm run ci`.
- [x] A deliberately-illegal import fails the validate step (proven, then reverted).
- [x] No loss of enforcement vs the old ESLint blocks; `npm run ci` green.
@@ -0,0 +1,49 @@
# WP-39 — Showcase: linked code snippets + teaching animations
Status: done
Phase: 8 — platform/DX/showcase
Priority: P1
## Outcome
`?raw` imports are NOT supported by Angular's esbuild build (verified), so anti-drift is done via
a generator (mirrors `gen:api`): `// #region showcase:<name>` markers in real source
(registration.ts, remote-data.ts, postcode.ts, change-request.machine.ts, intake.machine.ts) →
`scripts/gen-snippets.mjs` (`npm run gen:snippets`) → committed `src/app/showcase/snippets.generated.ts`.
A CI drift gate (`gen:snippets && git diff --exit-code`) forces a regen after any edit, so the shown
code IS the shipped code. The showcase renders the 5 real snippets (union/fold/parse/machine/steps)
each with a `↳ <source file>` caption, highlighted by a tiny dependency-free `highlightTs` (+spec);
the deliberately-wrong illustrations stay authored (no real source to link). Teaching animation: the
active state node pops (scale) as the wizard transitions, and the parse result animates in
(`animate.enter`), reusing the existing reduced-motion-safe toolkit. Generated file is
prettier-ignored (like api-client.ts) so the drift gate and format:check don't conflict.
## Why
The `/concepts` showcase shows live components, but its code snippets are hand-authored
`innerHTML` strings with manual highlighting — they drift from the real code. And the animation
toolkit (`withViewTransitions`, native `animate.enter/leave`, reduced-motion-safe keyframes in
`styles.scss`) is proven in dashboard/upload but unused for teaching motion.
## Decisions
- **Anti-drift snippets:** load _real_ source excerpts via Vite `?raw` imports with `// #region name`
markers + a tiny pure extractor; render with lightweight highlighting. No more `innerHTML` literals.
- On the Storybook/MDX side, use `<Source>`/Canvas "show code" consistently (extend beyond the
current 4 of 14 pages).
- **Reuse** the existing motion toolkit (no new animation lib): `animate.enter/leave` on the
fold/union demos + a "step through `reduce`" animation for the TEA loop; keep reduced-motion-safe.
## Files
- `src/app/showcase/concepts.page.ts` — replace `innerHTML` snippets with `?raw`+region excerpts;
apply animations.
- New tiny extractor util (pure) + spec (region slicing).
- `src/docs/machines.mdx`, `fp-in-ui.mdx`, `remote-data.mdx` — reference the live linked snippets.
## Acceptance criteria
- [x] The good-side snippets are sourced from real files via region markers (edit source → regen →
snippet updates); a CI drift gate enforces it. Bad-pattern illustrations stay authored.
- [x] The state machine + parse demos animate (reduced-motion respected globally); no new dependency.
- [x] `npm run ci` green.
@@ -0,0 +1,52 @@
# WP-40 — PII kernel: branded `Bsn` VO + masked-value atom
Status: done
Phase: 8 — platform/DX/showcase
Priority: P2
## Outcome
- `shared/kernel/bsn.ts``Bsn` branded VO + `parseBsn` with the **elfproef** (11-test) checksum
(+spec). Wired into the DigiD login boundary (`digid.adapter`): login now does real BSN
validation instead of "any 9 digits" — login hint + e2e BSNs updated to a valid one (`123456782`).
- `shared/kernel/pii.ts` — pure `maskBsn`/`maskTail`/`REDACTED` (+spec), the functional core. Moved
out of `debug-state/mask.ts`, which keeps only `redactProfile` (it depends on the registratie
`BigProfile`, so it can't live in `shared/kernel` — boundary; debug-state is the sanctioned
cross-context devtool). `mask.spec` ported to `pii.spec`.
- `<app-masked-value>` atom (`shared/ui/masked-value`) + story — masked-by-default value with an
optional reveal affordance; **centralises** the `.includes('*')` masked-detection that
`behandel-scherm` used to sniff inline (now refactored to the atom). The atom only emits
`reveal`; the caller keeps the step-up confirm + audited fetch.
- Deviation (flagged): `Session.bsn` stays typed `string` — the brand guards the login ENTRY, but
Session crosses a persistence boundary where the restore path deliberately drops the bsn
(privacy) and JSON can't carry a brand, so re-typing it would add a `''`/nullable ripple for no
real gain. Backend `MaskTail` twin unchanged (still in sync).
## Why
Masking is real but ad-hoc: pure helpers live in `debug-state/mask.ts`, the BSN is a bare `string`
in `session.ts` (no branded VO / checksum, unlike `BigNummer`/`Postcode`), and masked state is
detected by `.includes('*')` sniffing — no reusable atomic-design component. For a register this is
AVG art. 9 (BSN = special category) territory; the pieces should be first-class and reusable.
## Decisions
- `Bsn` branded value object + `parseBsn` with the **elfproef** (9 digits, weighted mod-11)
checksum — parity with the other value objects. Replace the bare `string` in `session.ts`.
- Consolidate the pure maskers into `shared/kernel/pii.ts` (functional core): `maskBsn`, `maskTail`,
`redactProfile`, … Keep the backend `MaskTail` twin in sync.
- `<app-masked-value>` atom (`shared/ui`) — masked by default, optional reveal affordance + a11y;
replaces `.includes('*')` sniffing and composes into the behandel-scherm reveal.
## Files
- New `src/app/shared/kernel/bsn.ts` (+spec, elfproef cases) and `shared/kernel/pii.ts` (+spec).
- `src/app/auth/domain/session.ts``bsn: Bsn`.
- New `src/app/shared/ui/masked-value/` atom (+ story).
- `src/app/shared/ui/debug-state/mask.ts` — re-export/move to the kernel module.
## Acceptance criteria
- [x] `parseBsn` accepts valid elfproef numbers, rejects bad checksum/length/all-zeros (spec).
- [x] `<app-masked-value>` renders masked by default with an optional reveal; behandel-scherm uses it (no more inline sniff).
- [x] Pure maskers have specs (no TestBed); backend `MaskTail` parity noted; `npm run ci` green.
@@ -0,0 +1,46 @@
# WP-41 — Persisted, queryable authz/PII-reveal audit
Status: done
Phase: 8 — platform/DX/showcase
Priority: P2
Depends on: WP-40
## Outcome
New data-minimised EF table `AuthzAuditEntry` (`Data/AuthzAuditStore.cs`, DbSet + key config in
`AppDbContext`, migration `AuthzAudit`): `At, Action, Resource, Decision, Role, CorrelationId`
**never** a name/BSN/value. `AuditAuthz` now persists (via `AuthzAuditStore.Record`) alongside its
log line, so every authz denial + BIG-nummer reveal/step-up attempt is captured. `GET /admin/audit`
(admin-gated by the existing `CasesAdmin`/`cases:manage` — a dedicated `audit:read` cap is a later
refinement) returns the trail newest-first (client-side sort — SQLite can't ORDER BY DateTimeOffset).
+3 backend tests (deny recorded, reveal recorded, **schema-carries-no-PII** reflection test). Typed
client regenerated (`audit()` + `AuthzAuditDto`). No FE consumer yet — a future audit view (WP-42
finish) must add `/api/v1/admin/audit` to the `role.interceptor` ROLE_AWARE list or it silently 403s.
## Why
The security-relevant events (authz denials via `AuditAuthz`, BIG-nummer reveal, step-up) are
**log-only** — never persisted, so there's no queryable trail. The one persisted `AuditEntry` table
covers document lifecycle only. PRD-0002 §8 calls for a persisted authorization audit. A register
(BIO) wants this queryable, and strictly data-minimised.
## Decisions
- Persist authz/reveal/step-up events into a data-minimised EF table extending the existing
`AuditEntry`/`AppDbContext` pattern: role, action, resource-ref, decision, correlation-id,
timestamp — **never** the value or any PII.
- Wire `AuditAuthz` + the reveal endpoint to persist (in addition to the log line).
- Optional thin admin read view — reuse `cases:manage` or add an `audit:read` capability.
## Files
- `backend/.../Data/` — new audit entity + `AppDbContext` DbSet + EF migration.
- `backend/.../Program.cs``AuditAuthz` + reveal endpoint persist.
- Backend tests: events are persisted; **no PII columns**; correlation-id captured.
- (Optional) FE audit read page + adapter/store, gated by the capability.
## Acceptance criteria
- [x] Denials, reveals, and step-up attempts land as rows with no PII/value fields.
- [x] A test asserts the schema carries no name/bsn/value column.
- [x] `dotnet test` (132) + `npm run ci` green; api-client drift clean after commit.
@@ -0,0 +1,53 @@
# WP-42 — Privacy & security showcase page
Status: done (optional Foundations MDX writeup left as a nice-to-have)
## Audit view (added after WP-41)
`/beheer/audit` — an admin page (`beheer/ui/audit.page.ts`) reading the WP-41 `GET /admin/audit`
trail through a `beheer` adapter/store (domain `AuditEntry` + trust-boundary parse), rendered as a
read-only table (time/action/resource/decision/role/correlation-id), capability-gated on
`cases:manage`. Added to `ADMIN_LINKS` (so it shows in the header nav + dashboard "Beheer" section)
and to the `role.interceptor` ROLE_AWARE list (else it silently 403s). This closes the audit half.
Phase: 8 — platform/DX/showcase
Priority: P2
Depends on: WP-40, WP-41
## Outcome (mask/parse half — done, on user request ahead of WP-41)
Added a "6 · PII — maskeren & parsen" section to `/concepts` demonstrating the WP-40 pieces with
FP + atomic design, framed for AVG art. 9 / data-minimisation: a live `<app-masked-value>` atom
(masked-by-default BSN that reveals on click; note points to the real step-up + audited reveal in
behandel-scherm) and a live `parseBsn` elfproef parse mirroring the postcode demo. Both show the
real linked source via the WP-39 snippet mechanism (`// #region showcase:parseBsn` in bsn.ts,
`showcase:mask` in pii.ts, registered in `gen-snippets.mjs``snippets.generated.ts`, drift-gated).
No i18n (showcase is Dutch-only teaching text). No behaviour change outside the showcase.
**Still pending (needs WP-41):** the "log PII / no-PII audit trail" half — visualizing the
persisted authz/reveal audit — plus an optional Foundations MDX writeup.
## Why
Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted
no-PII audit), showcase them as a teaching artifact: how to **mask** and **log** PII safely with FP
(pure functional core) + atomic design (the masked-value atom), tied to the existing reveal +
step-up + capability slice.
## Decisions
- A Foundations MDX + a small `showcase` demo (showcase context may read every context).
- Teach the principles explicitly: data-minimisation, PII out of logs, masked-by-default +
audited reveal, server as authority — framed for a Dutch register (AVG art. 9, BIO).
- Reuse WP-39's linked-snippet mechanism so the shown code can't drift.
## Files
- New `src/docs/privacy-security.mdx` (Foundations).
- New/extended `showcase` demo wiring `<app-masked-value>`, the pure maskers, and the reveal flow.
## Acceptance criteria
- [ ] Page demonstrates mask + no-PII logging with live components and linked (non-drifting) code.
- [ ] Explains the register rationale (AVG/BIO/data-minimisation) concisely.
- [ ] Storybook a11y green; `npm run ci` green.
@@ -0,0 +1,47 @@
# WP-43 — Runnable leaf generators
Status: done (value-object + form-machine; ui-component/bff-endpoint kept as skills)
Phase: 8 — platform/DX/showcase
Priority: P3
## Outcome
Tool = **plop**. `plopfile.mjs` + `plop-templates/` provide two runnable generators for the pure-TS
patterns: `npm run gen:value-object` (branded type + `parseX` + spec, mirrors postcode/bsn) and
`npm run gen:form-machine` (Draft/Valid/Errors + Editing/Submitting/Submitted/Failed union +
`initial`/pure `reduce`/`assertNever` + spec, mirrors change-request.machine). Prompts take
context + PascalCase name (positional-arg bypass for scripting); each emits a co-located spec so the
"pure logic must have a spec" rule holds by default; a post-action reminds to add the English
`<target>` for the generated `$localize` id. Smoke-tested (both generate → lint + dep:check clean),
then the demo output removed. Skills (`value-object`, `form-machine`) point at the generator.
`plop-templates/` is prettier-ignored (Handlebars, not TS).
**Scoped out (kept as skills):** `gen:ui-component` (Angular template `{{ }}` collides with
Handlebars) and `gen:bff-endpoint` (spans the C# backend + `gen:api` regen) — a generator adds
little over the recipe there.
## Why
The house patterns (value object, form machine, bff endpoint, ui component) are documented only as
agent-executed prose skills; there's no `npm run gen:*` a developer runs to emit the boilerplate.
For a template meant to bootstrap real registers, mechanise the highest-churn recipes.
## Decisions
- **Step 1 — tool fork:** plop (recommended: tiny, readable templates, `npm run gen:*`) vs Angular
schematics (native `ng generate`, heavier to author). Decide before building.
- Generators emit house-pattern files **with co-located spec/story**, matching current idioms:
`gen:value-object`, `gen:form-machine` (page/wizard), `gen:bff-endpoint`, `gen:ui-component`.
- Templates become the single source; each matching skill gets a one-line "run `npm run gen:x`, then …".
## Files
- New `plopfile.mjs` (or `schematics/`) + template files; `package.json` `gen:*` scripts.
- Update the corresponding `.claude/skills/*/SKILL.md` to invoke the generator.
## Acceptance criteria
- [x] `gen:value-object` + `gen:form-machine` emit files that pass `npm run lint` + `dep:check`
with no hand-edits beyond the domain specifics (rule/fields) + the en.xlf target.
- [x] Generated units include their co-located spec.
- [x] Smoke-tested both; `npm run ci` green.
@@ -0,0 +1,54 @@
# WP-44 — `gen:context` generator
Status: done
Phase: 8 — platform/DX/showcase
Priority: P3
Depends on: WP-38, WP-43
## Outcome
`npm run gen:context` (plop, extends WP-43's `plopfile.mjs`) prompts for a lowercase context name
and emits: `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` + a starter
`ui/<ctx>.page.ts` (a `PageShellComponent` wrapper — replace with the real first feature slice);
the `@<ctx>/*` tsconfig alias; one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map; and
a lazy, `authGuard`-gated route in `app.routes.ts` inserted before the catch-all.
**Refactored `.dependency-cruiser.js` to make "one config entry" literally true.** The pre-WP file
hand-duplicated each context's forbidden-imports list as a separate `contextRule(name, from,
forbidden)` call — adding a context meant editing N existing calls to add it to their forbidden
list, not adding one entry. Replaced with a single `CONTEXT_ALLOWED` map (context → contexts it may
additionally import) that every rule + the `FEATURES` string is _derived_ from; `showcase` maps to
`null` (unrestricted — the one exempt case) and is skipped when generating rules. Verified
behavior-preserving: `npm run dep:check` reports the same module/dependency counts before and after,
`npm run dep:graph`'s committed output is byte-identical, and a planted cross-context violation
(`auth` importing `@herregistratie`, type-only) is still caught under the new `auth-scope` rule name.
Smoke-tested by generating a real `vergunning` context end-to-end (`dep:check`, `lint`, `build` all
green, including the new lazy chunk), then removed the demo output. `.claude/skills/new-context/
SKILL.md` now points at the generator as step 1.
## Why
Adding a bounded context is currently a manual multi-file edit (folders + tsconfig alias + copied
ESLint boundary block + lazy route) — the `new-context` skill's most error-prone steps. Mechanise it.
## Decisions
- `gen:context` (same tool as WP-43) emits `{domain,application,infrastructure,ui,contracts}`
folders + the `@ctx/*` tsconfig path alias + a lazy route + the boundary entry.
- **Simpler because of WP-38:** boundaries are declarative by then, so the generator adds one config
entry instead of hand-copying ESLint blocks.
- Replaces the manual portion of the `new-context` skill.
## Files
- Extend the WP-43 generator setup with a `context` generator + templates.
- Wire tsconfig alias + boundary config + route insertion (idempotent edits).
- Update `.claude/skills/new-context/SKILL.md`.
## Acceptance criteria
- [x] `npm run gen:context <name>` produces a context that lints clean (boundaries recognised) and
routes lazily.
- [x] Boundary tool (WP-38) validates the new context's allowed edges.
- [x] `npm run ci` green.
@@ -0,0 +1,32 @@
# WP-45 — `create-frontend` bootstrap generator
Status: done (ad7ca31)
Phase: 8 — platform/DX/showcase
Priority: P4
Depends on: WP-43, WP-44
## Why
`new-ssp` is a manual fork-and-strip recipe. To actually bootstrap a production-ready frontend for
a different Dutch register quickly, mechanise it into a runnable script that produces a clean
scaffold keeping the enforced architecture + shared building blocks.
## Decisions
- A `create-frontend` script (Node/plop-driven) that clones-and-strips per the `new-ssp` recipe:
drop the business contexts, prune their routes/aliases/boundary entries, rename `BigRegister.*`,
re-point the `--rhc-*` token bridge, re-seed the backend, re-run `gen:api`.
- Keep-as-is list from `new-ssp` (shared kernel/ui/layout, eslint/boundary config, check-tokens,
CI, nswag, storybook, auth shell, ADRs, CLAUDE.md, the skills).
- Reuse WP-44's `gen:context` to seed the first real context of the new portal.
## Files
- New `scripts/create-frontend.*` (+ any templates); documented in `.claude/skills/new-ssp/SKILL.md`.
## Acceptance criteria
- [ ] Running it yields a portal that builds, lints (boundaries intact), and passes `npm run ci`
with no business contexts left over.
- [ ] The generated scaffold's `gen:api` + token bridge point at the new backend/theme.
- [ ] Documented end-to-end in the `new-ssp` skill.
@@ -0,0 +1,46 @@
# WP-46 — Vitest coverage (report + thresholds)
Status: done
Phase: 8 — platform/DX/showcase
Priority: P1
## Outcome
Coverage is first-class in the `@angular/build:unit-test` builder — no separate vitest config
needed. Added a `coverage` **configuration** to the `test` target in `angular.json` (`coverage: true`,
reporters `text-summary`/`html`/`lcov`, excludes for specs/stories/contracts/generated
`api-client.ts`/`main.ts`/`.d.ts`) + `@vitest/coverage-v8` (pinned to vitest's 4.1.9 to avoid the
mixed-version warning). `npm run test:coverage``ng test --configuration coverage`. **Report-only
(no thresholds)** per the ponytail default — visibility first. CI's test step now runs
`test:coverage` (a strict superset of `npm test`: fails on a failing test, never on coverage).
Baseline at delivery: **~71% statements / 69% branches / 68% functions / 74% lines.** Output dir
`coverage/` was already git-ignored. Path to enforce later: add `coverageThresholds` to the config.
## Why
Tests run via the `@angular/build:unit-test` (Vitest) builder but report no coverage — there's no
visibility into what the house test rules actually cover. A template/showcase should surface this.
## Decisions
- **Step 1 — verify wiring:** confirm how coverage is enabled with the Angular unit-test builder
(`codeCoverage` option and/or a vitest config), add `@vitest/coverage-v8`.
- `npm run test:coverage` → text + HTML/lcov report.
- **Report-only first** (ponytail: visibility before a blocking gate, like the semgrep job).
Threshold focus on the layers the house rules already demand tests for (`domain/**`, pure
combinators, `parse*` boundaries, reducers), not a blanket global %; exclude stories, the
generated `api-client.ts`, and DTOs/contracts.
- Wire into `ci-local.sh`/CI **non-blocking**, with a documented path to flip on enforcement later.
- **Open choice:** report-only vs enforce a minimum now.
## Files
- `package.json` (`test:coverage` + dep), coverage config (builder option or vitest config).
- `scripts/ci-local.sh` / `.github/workflows/ci.yml` — non-blocking coverage step.
- `.gitignore` — ignore the coverage output dir.
## Acceptance criteria
- [x] `npm run test:coverage` produces a report (text-summary + HTML/lcov) with sensible excludes.
- [x] Coverage runs in CI without failing the build (report-only).
- [x] `npm run ci` green.
@@ -0,0 +1,38 @@
# WP-47 — Runtime feature flags (catalog-in-code, admin-toggled)
Status: done
Phase: 8 — platform/DX/showcase
## Why
Ops needs to turn features on/off at runtime without a deploy. Mirrors the two house templates: the
capability spine (server-resolved, FE reads) and the org-template runtime-SQLite config (admin edits
at runtime). Per ADR-0004 the **catalog** (which flags exist + defaults) is config-as-code; only the
**on/off state** is runtime.
## Decisions (locked with the user)
- Catalog in code (typed, build-validated); on/off state in SQLite; admin toggles at runtime.
- **FE + backend enforcement** — the FE hides the surface AND the server enforces (a flag can guard
a real feature, not just UI).
## Outcome
- Backend: `Domain/Features/FeatureFlags.cs` (catalog: one flag `inschrijving-open`, default on) +
`Data/FeatureFlagStore.cs` (`FeatureFlagEntity` in SQLite + migration; `All()` merges catalog
defaults with overrides, `IsEnabled`, `Set` rejects unknown keys). `GET /flags` (readable, drives
FE gating) + `PUT /admin/flags/{key}` (gated by new `flags:manage` cap + `FlagsAdmin`). Enforced
end-to-end: `POST /applications` for a `registratie` returns 403 when `inschrijving-open` is off.
- FE: `shared/domain/feature-flag.ts` + `feature-flags.adapter.ts` (parse boundary) +
`shared/application/feature-flags.store.ts` (root singleton, `enabled(key)` deny-by-default,
`set`). Capability `flags:manage` (union + me.adapter + role.interceptor `/api/v1/admin/flags`).
The "Inschrijven" nav item + dashboard action hide when the flag is off. Admin toggle page
`beheer/ui/feature-flags.page.ts` at `/beheer/functies`, in `ADMIN_LINKS`.
- Tests: catalog-unique + endpoint (admin-only toggle, 404 unknown key, close→403 / reopen→201).
`/me` cap-list test updated. Backend 136; typed client regenerated.
## Acceptance criteria
- [x] Admin toggles a flag at runtime; state persists (SQLite) and the whole app reads it.
- [x] FE hides the flagged feature AND the backend enforces it (registration close → 403).
- [x] `npm run ci` green (dep:check, localized build, backend `dotnet test`, drift clean after commit).
@@ -0,0 +1,40 @@
# WP-48 — Stamdata deletion protection (referential integrity)
Status: done
Phase: 8 — platform/DX/showcase
## Why
Deleting a stamdata row that something relies on (e.g. a `professions.program` a diploma maps
through) would silently break behaviour. Stamdata is config-as-code (PR-applied, CI-gated), so the
authoritative guard belongs at the build gate; the editor gets a fast-feedback nudge.
## Decisions (locked with the user)
- **CI gate (authoritative) + editor warning (fast feedback).**
- **Steer temporal rows toward expiring** (set `geldigTot`) over hard delete.
## Outcome
- **CI gate:** generalized the dangling-reference test in `StamdataValidationTests` into a declared,
extensible reference list (`StamdataRef` records) — "every declared reference into a stamdata key
resolves against the currently-valid stamdata." Today one entry: `Diploma.Opleiding →
professions.program (valid today)`. Resolvers use the "valid today" view (`Professions.ByProgram`),
so removing/renaming a referenced program OR expiring it while current data still references it
**fails the PR build**; expiring once nothing current relies on it passes. Adding a future FK is
one list entry.
- **Editor (fast feedback):** `stamdata-table-editor` now confirms before delete (`@@beheer.removeConfirm`
— warns that a referenced row fails CI and, for a dated table, to close validity instead) and, for
**temporal** tables, adds a **"Sluiten per vandaag"** action that sets `geldigTot` to today
(reusing `CellEdited`) — steering to expire over hard delete. CI stays the authority.
## Acceptance criteria
- [x] A delete/expire that orphans a declared reference fails the build gate (existing seed passes).
- [x] Editor confirms deletes and offers expire (close validity) for temporal tables.
- [x] `npm run ci` green (backend `dotnet test`, localized build).
## Deferred (noted)
A per-row "referenced" hint in the editor DTO (server-computed usage) — would let the editor warn on
the _specific_ referenced rows rather than a generic confirm. Not needed for the authoritative gate.
@@ -0,0 +1,59 @@
# WP-49 — OpenZaak zaken read seam (slice 1)
Status: done
Phase: 9 — OpenZaak / ZGW integration
## Why
The POC serves cases from local SQLite. To grow toward production it must be able to source
them from a real ZGW backend (OpenZaak) **without changing the frontend** (BFF-lite, ADR-0001).
The backend had no data-access abstraction to swap behind, no outbound HTTP, and no JWT. This is
the first thin vertical slice: read-only zaken.
## Read first
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
- [openzaak-integration.md](../reference/openzaak-integration.md)
## Decisions (pre-made, don't relitigate)
- OpenZaak's anti-corruption layer lives in the **BFF**, never the browser.
- Source selected by config (`Zgw:Enabled`, default false) → POC still runs offline.
- Each source maps into the **existing** `ApplicationSummaryDto` → no api-client drift, no FE change.
- Fresh HS256 JWT **per call** (no refresh flow). Hand-rolled (no new NuGet).
- `IZaakSource` is sync (matches the endpoint + local store); OpenZaak source does sync-over-async.
## Files
- `Data/IZaakSource.cs`, `Data/LocalZaakSource.cs`
- `Zgw/{ZgwOptions,ZgwTokenProvider,ZgwZaakMapper,OpenZaakZaakSource}.cs`
- `Program.cs` (DI + resolve `IZaakSource` in `/admin/cases`), `appsettings.json` (`Zgw` section)
- tests: `ZgwTokenProviderTests`, `ZgwZaakMapperTests`, `OpenZaakZaakSourceTests`
## Steps
1. Extract the cases read into `IZaakSource`; `LocalZaakSource` delegates to `ApplicationStore`.
2. Build the `Zgw/` client (options, JWT minter, ZGW→DTO mapper, paginating HTTP source).
3. Wire DI by config; refactor `/admin/cases` to resolve `IZaakSource`.
4. Unit-test the minter, mapper, and source (fixtures + stub `HttpMessageHandler`).
## Acceptance criteria
- [x] `/admin/cases` serves identical DTOs via `LocalZaakSource` (default, offline).
- [x] `OpenZaakZaakSource` follows pagination, resolves+caches zaaktype labels, sends a Bearer token.
- [x] JWT verifies (HS256) with the required claims.
- [x] `dotnet test` green (142); `npm run ci` green with **no api-client drift** (FE untouched).
## Verification
`cd backend && dotnet test`; `npm run ci`; manual: `/beheer/zaken` still lists cases with `Zgw:Enabled=false`.
## Out of scope
Create-zaak (WP-50), Documenten/DRC (WP-51), Notificaties (WP-52), real inbound OIDC/JWT,
OpenZaak in docker-compose.
## Risks
Sync-over-async blocks a thread under load if OpenZaak becomes the default → make the read path
async then (noted at the call site).
@@ -0,0 +1,52 @@
# WP-50 — OpenZaak create-zaak (first write slice)
Status: done (de3bff0)
Phase: 9 — OpenZaak / ZGW integration
## Why
WP-49 made the cases **read** path source-swappable. The next slice is the first **write**:
create a Zaak in OpenZaak when an aanvraag is submitted, still behind the config gate, still
without changing the FE contract.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md) — "How to add the next slice"
- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md), [ADR-0001](../reference/architecture/0001-bff-lite-decision-dtos.md)
## Decisions (pre-made, don't relitigate)
- Route the create through the existing submit/mutation seam; keep the FE response DTO identical.
- A create needs a `zaaktype` **URL** from Catalogi (OpenZaak validates it by fetching) — map
the aanvraag `type` → a configured zaaktype URL.
- Follow the create with a `status` + a `rol` (initiator/betrokkene by BSN) as ZGW expects.
## Files
- Extend `IZaakSource` (or add a write method) + `OpenZaakZaakSource`; `LocalZaakSource` keeps
the current local submit.
- `ZgwOptions`: a `type → zaaktype URL` map + `bronorganisatie`/`verantwoordelijkeOrganisatie` (RSIN).
## Steps
1. Add `CreateZaak` to the source seam; local impl = current submit, OpenZaak impl = POST to ZRC.
2. Map aanvraag `type` → zaaktype URL; POST zaak, then status + rol.
3. Map the created Zaak back into the existing submit response DTO.
## Acceptance criteria
- [ ] Submitting with `Zgw:Enabled=true` creates a Zaak (+ status + rol) in OpenZaak.
- [ ] FE submit response DTO unchanged; no api-client drift.
- [ ] Covered by tests (stub handler asserts the POST bodies + type→zaaktype mapping).
## Verification
`dotnet test`; against a docker OpenZaak if available.
## Out of scope
Documenten (WP-51), Notificaties (WP-52).
## Risks
Create needs read scope on Catalogi (type-URL validation) — provision AC scopes accordingly.
@@ -0,0 +1,52 @@
# WP-51 — OpenZaak Documenten (DRC) upload + link
Status: done (5807937)
Phase: 9 — OpenZaak / ZGW integration
## Why
Uploaded documents currently persist as bytes in local SQLite (`DocumentStore`). To be
production-ready they must live in OpenZaak's **Documenten API (DRC)** as
`enkelvoudiginformatieobjecten`, linked to a Zaak via `zaakinformatieobject` — behind the same
config gate, still without a FE contract change.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md)
- WP-49 (the seam pattern), WP-50 (create-zaak, the zaak to link to)
## Decisions (pre-made, don't relitigate)
- Introduce an `IDocumentSource` sibling of `IZaakSource`; local impl = `DocumentStore`,
OpenZaak impl = DRC. FE upload DTOs unchanged.
- A document needs an `informatieobjecttype` **URL** from Catalogi (like zaaktype for a zaak).
- Upload → returns document URL → `zaakinformatieobject` links it to the zaak URL.
## Files
- `Data/IDocumentSource.cs`, `Data/LocalDocumentSource.cs`, `Zgw/OpenZaakDocumentSource.cs`
- `ZgwOptions`: `informatieobjecttype` URL(s) + `DrcBaseUrl`.
## Steps
1. Abstract the upload/read/link paths behind `IDocumentSource`.
2. OpenZaak impl: POST `enkelvoudiginformatieobjecten` (content), then POST `zaakinformatieobjecten`.
3. Map DRC document metadata back into the existing document DTOs.
## Acceptance criteria
- [ ] With `Zgw:Enabled=true`, an upload lands in DRC and is linked to its zaak.
- [ ] FE upload/list DTOs unchanged; no api-client drift.
- [ ] Tests cover the DRC POST bodies + the zaak-link step (stub handler).
## Verification
`dotnet test`; against a docker OpenZaak if available.
## Out of scope
Notificaties (WP-52), content virus-scanning / blob-storage tuning.
## Risks
Large file content over base64/multipart — mind memory; stream if needed.
@@ -0,0 +1,67 @@
# WP-52 — OpenZaak Notificaties (NRC) live status
Status: done (`c4dd846` endpoint, tests/docs/config finished this session)
Phase: 9 — OpenZaak / ZGW integration
## Why
With cases in OpenZaak, case status changes in the backoffice, not in this app. Production
"live" status needs the **Notificaties API (NRC)**: subscribe to zaak events and update on
webhook, rather than polling. Last slice of the ZGW integration arc.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md)
- WP-49/50/51 (the read/write/document slices this builds on)
## Decisions (pre-made, don't relitigate)
- The BFF exposes a webhook endpoint that NRC calls; it validates an Authorization header the
BFF issued, then invalidates any cached case data / notifies the FE.
- Subscription (`abonnement` on the `zaken` kanaal) is provisioning/config, not runtime code.
- FE update mechanism reuses the existing RemoteData reload — no new FE contract.
## Files
- A new BFF webhook endpoint (`POST /zgw/notificaties`) + Authorization validation.
- `ZgwOptions`: `NrcBaseUrl` + the webhook shared secret.
## Steps
1. Add the webhook endpoint (auth-checked, no PII logged — reuse the audit seam).
2. On a zaak event, invalidate cache / push an update to the FE.
3. Document the `abonnement` provisioning (out-of-band, one-time).
## Acceptance criteria
- [x] A posted NRC event (correct auth) is accepted (204); a bad-auth post is rejected (401).
- [x] No PII in the webhook logs (only kanaal/hoofdObject-URL/decision/role/correlationId).
- [x] Tests cover auth accept/reject (`NotificatieTests.cs`).
## Verification
`dotnet test` (151/151 green, incl. 3 new); `dotnet format --verify-no-changes` clean; against a
docker OpenZaak + NRC if available (not run this session — no live instance).
## Out of scope
Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload. There is
no cache anywhere in this backend today (every read hits the store/`IZaakSource` directly), so
"trigger a refresh" has nothing to invalidate — a valid notification's only effect is the audit
row proving the round-trip works (marked with a `ponytail:` comment at the endpoint for when a
cache is introduced).
## Risks
Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code.
## Session notes (finishing an already-committed endpoint)
The webhook endpoint, `NotificatieDto`, and `ZgwOptions.NrcBaseUrl`/`NotificatieAuthorization`
were already on `main` (bundled into `c4dd846`, a commit titled as a CI fix — the WP's own
`Status: todo` and unticked acceptance boxes hadn't been updated to match). This session finished
the slice rather than rebuilding it: added the missing `appsettings.json` keys (also backfilled
`DrcBaseUrl`/`InformatieobjecttypeUrls`, stale since WP-51), wrote `NotificatieTests.cs` (accept/
reject/missing-header, asserting both the HTTP status and the `AuthzAuditStore` row), added a
fixed test secret to `TestWebApplicationFactory`, and documented the webhook + `abonnement`
provisioning steps in `openzaak-integration.md`.
@@ -0,0 +1,140 @@
# WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam)
Status: done
Phase: 9 — OpenZaak / ZGW integration
## Why
WP-49 made the cases read path swappable, but everything runs as a **stubbed identity**: the
principal comes from an `X-Role` header and the "owner" is a single hardcoded BSN. A real
OpenZaak integration needs a genuine per-request user in order to (a) fill the ZGW JWT
`user_id`/`user_representation` audit claims, and (b) **scope zaken to the logged-in citizen**
(you must never return another citizen's cases). This WP threads a real identity through the
system **without** building DigiD/OIDC itself — CLAUDE.md keeps real auth out of scope, so the
deliverable is the _seam_: a per-request `CallerIdentity` (subject BSN + display name) produced
by a **pluggable, stubbed** provider, consumed everywhere the hardcoded owner is used today.
Production later swaps the stub for OIDC/DigiD without touching any consumer.
## Context — current state (read before designing; this is the handoff, no prior chat needed)
Identity is faked in these exact places — this WP replaces the fakes with one identity flow:
- **Backend principal**: `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs`
`ResolvePrincipal(ctx)` reads the `X-Role` header (drafter/approver/admin). Its own doc
comment says _"A real system builds this from verified AD/OIDC claims … everything else in
this file carries over unchanged once that swap happens."_ That is the seam to formalize.
- **Hardcoded owner/BSN**: `backend/src/BigRegister.Api/Data/DocumentStore.cs`
`public const string DemoOwner = "19012345601";` (the single seeded citizen's BIG-nummer).
Grep `DemoOwner` across `Program.cs` + stores — every "whose data is this" decision uses it.
- **Owner-scoped stores** already take an `owner` string:
`Data/ApplicationStore.cs` (`List(owner)`, `Get(id, owner)`, `CreateConcept(type, owner)`,
`Submit(id, owner, …)`) and `Data/DocumentStore.cs`. They are ready to receive a real BSN —
today the endpoints pass `DocumentStore.DemoOwner`.
- **ZGW JWT user claims** are static: `Zgw/ZgwTokenProvider.cs` `Mint()` reads
`ZgwOptions.UserId` / `ZgwOptions.UserRepresentation` (constant strings). These must become
**per-request** (the acting citizen), or the ZGW audit trail is wrong.
- **The cases read interface** `Data/IZaakSource.cs` has one method, `ListCases(now)`, with **no
caller** — it returns the admin cross-owner list. There is no citizen-scoped "my cases" read
yet, and `OpenZaakZaakSource` lists ALL zaken (`{ZrcBaseUrl}/zaken`, no filter).
- **Correlation middleware** (`Program.cs`, the `app.Use(...)` block setting
`X-Correlation-Id`) is the pattern/location to add an identity-resolution middleware next to.
- **Frontend** identity is the dev role switch: `?role=drafter|approver|admin` + the `⚙ state`
panel + `SessionStore` (`src/app/auth/`), documented in `docs/reference/roles-and-access.md`.
The FE already persists a session (localStorage). No FE change is required for the backend
seam, but the citizen's BSN must originate from the session, not a constant — note where.
ZGW detail that drives the scoping query: OpenZaak filters a citizen's zaken via the query
param `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn>` on `GET {ZRC}/zaken`.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md) — the seam + the "two nested
ACLs" section (this WP is about the identity that flows through both).
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
("Deferred: real inbound OIDC/JWT auth" — this WP formalizes the seam, not the provider).
- [ADR-0002 — user groups & bounded contexts](../reference/architecture/0002-user-groups-and-bounded-contexts.md),
[roles-and-access.md](../reference/roles-and-access.md).
- `CLAUDE.md` → "Out of scope: Real auth/DigiD" — respect it: build the seam + a stub, not DigiD.
## Decisions (pre-made, don't relitigate)
- **Seam, not provider.** Introduce a `CallerIdentity` (subject BSN + display name + role) and an
`IIdentityProvider` with a **`StubIdentityProvider`** (reads the existing `X-Role` + a
configurable/`X-Subject` BSN, defaulting to the seeded citizen). Production swaps the provider;
no consumer changes. Do **not** add DigiD/OIDC.
- **One source of "who".** Resolve `CallerIdentity` once per request (middleware, beside the
correlation block) and flow it to: `Authz.ResolvePrincipal`, the store `owner` arguments
(replace `DocumentStore.DemoOwner` call sites), and `ZgwTokenProvider.Mint(caller)`.
- **Citizen-scoped reads are separate from admin reads.** Keep the admin cross-owner list
(`cases:manage`) as-is; add a citizen-scoped "my zaken" path that filters by the caller's BSN
(ZGW `rol__…__inpBsn`; local store: `List(owner)`).
- **Ownership stays server-authoritative.** The BSN comes from the resolved identity, never from
a client-supplied body field.
## Files
- New: `Domain/Authorization/CallerIdentity.cs`, `Domain/Authorization/IIdentityProvider.cs` +
`StubIdentityProvider.cs`; an identity-resolution middleware in `Program.cs`.
- Edit: `Domain/Authorization/Authz.cs` (build the principal from `CallerIdentity`),
`Zgw/ZgwTokenProvider.cs` (`Mint(CallerIdentity)`), `Zgw/OpenZaakZaakSource.cs` (BSN filter on
the citizen read), `Data/IZaakSource.cs` (+ a caller-scoped read), `Program.cs` (replace
`DemoOwner` call sites with the resolved BSN; DI-register the provider).
- Tests: identity resolution (stub), token carries the per-request user, citizen read filters by
BSN (stub handler asserts the query param), admin read still cross-owner.
## Steps
1. Add `CallerIdentity` + `IIdentityProvider` + `StubIdentityProvider` (X-Role + X-Subject BSN,
default = seeded citizen); DI-register; resolve once in middleware into `HttpContext.Items`.
2. Route `Authz.ResolvePrincipal` and every `DemoOwner` call site through the resolved identity.
3. `ZgwTokenProvider.Mint(caller)` — per-request `user_id`/`user_representation`.
4. Add a caller-scoped cases read to `IZaakSource` (+ both impls); `OpenZaakZaakSource` adds the
`rol__…__inpBsn` filter; local uses `ApplicationStore.List(owner)`.
5. Tests as above; keep the admin list unchanged.
## Acceptance criteria
- [x] No `DocumentStore.DemoOwner` reference remains in request handling (grep clean); ownership
comes from the resolved identity.
- [x] ZGW JWT carries the acting citizen's `user_id`/`user_representation` (test-verified).
- [x] A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged.
- [x] `dotnet test` green; `npm run ci` green with **no api-client drift** (FE contract intact).
## Verification
`cd backend && dotnet test` (159/159, incl. 8 new); `dotnet format --verify-no-changes` clean;
`npm run ci` green (no api-client drift). Manual: `X-Role`/`X-Subject` still switch identity
offline (no header → the seeded citizen, drafter); with `Zgw:Enabled=true` (WP-54 harness) a
citizen would see only their zaken via the new `rol__…__inpBsn` filter.
## Out of scope
Real DigiD/OIDC/JWT validation (this is the seam + stub only), FE login redesign, multi-tab
session sync (CLAUDE.md out-of-scope list).
## Risks
- Missing a `DemoOwner` call site → a citizen sees another's data. Mitigated: grep gate (clean)
- `ApplicationTests.Applications_are_scoped_to_the_caller_bsn` (two `X-Subject` identities,
HTTP end-to-end) proving neither sees the other's cases.
- ZGW rol filter param name is exact and version-sensitive; asserted in
`OpenZaakZaakSourceTests.ListMyCases_filters_by_the_callers_bsn`.
## Session notes
Built as designed — no premise in the Decisions/Context block turned out stale. One
implementation choice not spelled out in the WP: `Authz.ResolvePrincipal(HttpContext ctx)` kept
its exact signature (now `new(ctx.Caller().Role)` instead of re-reading `X-Role` itself), so
none of its ~15 call sites needed touching — "flow it to Authz.ResolvePrincipal" didn't require
threading `CallerIdentity` through every endpoint that resolves a `Principal`. `ZgwTokenProvider`
grew a `Mint(CallerIdentity)` overload alongside the existing parameterless `Mint()` (kept for
calls not tied to one citizen — the admin cross-owner `ListCases`, and Catalogi metadata lookups)
rather than replacing it outright, so `ZgwOptions.UserId`/`UserRepresentation` stay meaningful as
the BFF's own system identity. `IZaakSource`/`IDocumentSource` gained an explicit `CallerIdentity`
parameter on every citizen-scoped method (`ListMyCases`, `CreateZaak`, `Upload`, `LinkToZaak`)
rather than resolving it ambiently via `IHttpContextAccessor` — kept it unit-testable without any
DI/HttpContext ceremony (see `StubIdentityProviderTests`, the `ZgwTokenProviderTests` addition).
`GET /applications` (the citizen's own dashboard list) is now routed through
`IZaakSource.ListMyCases` instead of calling `ApplicationStore` directly — closing the exact gap
`openzaak-integration.md`'s ACL caveat used to flag for that endpoint; under `Zgw:Enabled=true` it
would now source from OpenZaak (BSN-filtered) like `/admin/cases` already did.
@@ -0,0 +1,145 @@
# WP-54 — Docker OpenZaak integration-test harness
Status: done
Phase: 9 — OpenZaak / ZGW integration
## Why
Everything ZGW so far is verified against **fixtures + a stub `HttpMessageHandler`** — nothing
exercises a real OpenZaak. That is fine for unit-testing the mapper/JWT/pagination, but it does
not prove the BFF actually talks to OpenZaak (auth accepted, real response shapes, real
pagination, zaaktype resolution). This WP stands up a **local OpenZaak via docker**, seeds a
minimal catalog + a client credential matching `ZgwOptions`, and adds an **opt-in** integration
test that points the BFF at it with `Zgw:Enabled=true`. It closes the "no live instance" gap
called out in the roadmap and makes WP-50/51/52 developable against something real.
## Context — current state (read before designing; self-contained handoff)
- **What exists to test**: `backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs` (lists zaken,
follows `{count,next,previous,results}`, resolves zaaktype labels from Catalogi, Bearer auth
via `ZgwTokenProvider`), selected when `Zgw:Enabled=true`. Config is the `Zgw` section of
`appsettings.json` (`ZrcBaseUrl`, `ZtcBaseUrl`, `ClientId`, `Secret`, `UserId`,
`UserRepresentation`) → `Zgw/ZgwOptions.cs`.
- **Current tests** (the pattern to extend, not replace): `ZgwZaakMapperTests` (inline JSON),
`OpenZaakZaakSourceTests` (stub handler). These stay as fast unit tests; the new integration
test is a **separate, opt-in** category so the default `dotnet test` and CI stay fast/offline.
- **Existing compose**: repo root `docker-compose.yml` runs FE + BFF only (Swagger at :5000).
Do **not** bolt OpenZaak onto it — OpenZaak is heavy (postgres + redis + celery). Use a
**separate** compose file so `docker compose up` stays light for everyone else.
- **Test project**: `backend/tests/BigRegister.Tests/` (xunit). It uses
`Microsoft.AspNetCore.Mvc.Testing` (`TestWebApplicationFactory.cs`) — the factory can be
configured to override the `Zgw` config section to point at the compose instance.
- **CLAUDE.md / backlog GREEN**: the default local gate and CI must remain runnable without
docker/OpenZaak. Anything requiring the harness is explicitly separate (like e2e is today —
a distinct job, not chained into `npm run ci`).
OpenZaak facts that shape the harness (from the ZGW research):
- OpenZaak is the reference impl of the 5 ZGW APIs; the published
`docker-compose.yml` (github.com/open-zaak/open-zaak) brings up **openzaak (web + celery),
postgres, redis** and (optionally) Open Notificaties. A one-time **bootstrap** is required:
create a superuser, register an **Application** in the Autorisaties API with a **Client
ID/Secret + scopes** (`zaken.lezen`, `catalogi.lezen`, …), and import/create a **Catalogus
with a zaaktype** (published) so there is something to read.
- Auth = the HS256 JWT the BFF already mints; the harness's client credentials must **match**
`ZgwOptions.ClientId`/`Secret`.
- Reading a zaak needs read scope on **both** Zaken and Catalogi (zaaktype resolution).
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md) (the seam + config keys).
- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md).
- WP-19 (`WP-19-e2e-smoke.md`) — the precedent for a heavy, separate, opt-in test job (mirror
its "not chained into the default gate" structure).
- Upstream: OpenZaak docker docs (https://open-zaak.readthedocs.io/) + the repo's
`docker-compose.yml` and its `import` fixtures for a demo catalogus.
## Decisions (pre-made, don't relitigate)
- **Separate compose file** (e.g. `backend/openzaak/docker-compose.openzaak.yml`), never merged
into the root compose. `docker compose up` stays FE+BFF only.
- **Opt-in test category.** Tag the integration test `[Trait("Category", "Integration")]`;
exclude it from the default run (`dotnet test --filter Category!=Integration`) and from the
standard CI jobs. Provide a documented command / optional manual CI job to run it.
- **Bootstrap is scripted, not manual.** A checked-in setup (compose `import` fixture or a small
bootstrap script) creates the client credentials (matching `ZgwOptions`), a catalogus, and one
published zaaktype + one zaak — so the test is deterministic and repeatable.
- **Provider stays stubbed** (WP-53) — this WP is about the ZGW round-trip, not real user auth.
## Files
- New: `backend/openzaak/docker-compose.openzaak.yml` + bootstrap fixture/script + a short
`backend/openzaak/README.md` (how to bring it up, credentials, teardown).
- New: `backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs` (Category=Integration):
configure the BFF (`WebApplicationFactory`) with `Zgw:Enabled=true` + the compose URLs/creds,
hit `/admin/cases`, assert the seeded zaak comes back mapped.
- Edit: `docs/reference/openzaak-integration.md` (add a "Run against real OpenZaak" section);
optionally a manual/gated CI job in `.github/workflows/ci.yml` mirroring the e2e job's shape.
## Steps
1. Add the separate compose file bringing up OpenZaak + postgres + redis; pin image versions.
2. Script the bootstrap: superuser, Autorisaties Application (Client ID/Secret = `ZgwOptions`),
a catalogus + one published zaaktype + one zaak with a natuurlijk-persoon rol (the seeded BSN).
3. Write the opt-in integration test: point the BFF at the compose instance, assert the mapped
zaak (id = uuid, type = zaaktype label, status) via `/admin/cases`.
4. Document `docker compose -f … up` + the run command; optionally add a manual CI job.
## Acceptance criteria
- [x] `docker compose -f backend/openzaak/docker-compose.openzaak.yml up` yields a reachable
OpenZaak with the seeded catalogus + zaak, and credentials matching `ZgwOptions`.
- [x] The Category=Integration test passes against it; the BFF returns the seeded zaak mapped to
`ApplicationSummaryDto` through the real HTTP + JWT path.
- [x] Default `dotnet test` and `npm run ci` are unaffected (integration test excluded, no docker
needed); `docker compose up` (root) is unchanged.
## Verification
`docker compose -f backend/openzaak/docker-compose.openzaak.yml up -d`
`./backend/openzaak/bootstrap-catalogus.sh``dotnet test --filter Category=Integration`
green; then teardown. Actually run (not just planned) during this WP — see Deviations below
for what that surfaced.
## Out of scope
Documenten/DRC + Notificaties services in the harness (add when WP-51/52 land), a permanent
always-on CI job (keep it opt-in/manual — OpenZaak startup is slow), performance testing.
## Risks
- OpenZaak startup is slow + resource-heavy → keep it opt-in; a permanent CI job would blow the
same runner budget WP's storybook cap just fixed.
- ZGW API/version drift vs the pinned image can change response shapes → pin image versions and
keep the fixture in the repo.
- Bootstrap client scopes must include `catalogi.lezen` or zaaktype resolution 403s — cover in
the setup script.
## Deviations from the original plan
- **`heeft_alle_autorisaties: true` instead of granular scopes.** The plan called out
`zaken.lezen`/`catalogi.lezen` specifically; in practice OpenZaak's scripted config
(`vng_api_common_applicaties_config`, upstream's own documented `setup_configuration` YAML
mechanism) exposes an all-scopes flag on the one `Applicatie` this harness ever creates. Since
that application exists for nothing but this throwaway test instance, granular scopes would
add YAML-schema risk for no real least-privilege benefit — took the simpler, equally-scripted
option.
- **A live run found a real production bug, not just a harness wrinkle**: `ZgwHttpClient.cs`
never sent `Content-Crs`/`Accept-Crs` on any ZGW call. Every ZGW write 412s ("Content-Crs
header ontbreekt") without it — a real OpenZaak enforces this; the stub `HttpMessageHandler`
every prior Zgw test used never modelled header requirements, so nothing from WP-49/50 caught
it before now. Fixed in `ZgwHttpClient.cs` alongside the harness (see
`docs/reference/openzaak-integration.md`) — this is precisely the class of bug this WP exists
to catch.
- **Publishing a zaaktype needs more seed data than the plan anticipated**: OpenZaak refuses to
publish a zaaktype with fewer than one resultaattype or fewer than two statustypen (begin +
eind), and a resultaattype's `selectielijstklasse` must share a `procesType` with the
zaaktype's own `selectielijstProcestype` — both cross-checked live against the public VNG
selectielijst API (`selectielijst.openzaak.nl`). `bootstrap-catalogus.sh` seeds all of this;
see its comments for the exact values used and why.
- **No celery/celery-beat/nginx in the harness**, unlike upstream's own compose — trimmed for a
faster-booting, single-purpose harness (this test never asserts on notification delivery,
which is celery's job). `NOTIFICATIONS_DISABLED=true` is required as a consequence: without a
celery worker, `NotificationsConfig` has no client, and OpenZaak's `notify()` hook otherwise
raises inside the same DB transaction as the create — turning a missing-worker problem into a
500 that rolls back the create it was supposed to just notify about.
@@ -0,0 +1,91 @@
# WP-55 — Real secrets + TLS for the OpenZaak harness
Status: done
Phase: 10 — OpenZaak production hardening
## Why
`backend/openzaak/docker-compose.openzaak.yml` is explicitly a throwaway dev/test harness:
`SECRET_KEY: wp-54-local-harness-not-for-prod`, `POSTGRES_HOST_AUTH_METHOD=trust` (no DB
password), `IS_HTTPS: 'no'`, `DISABLE_2FA: 'true'`. Before anything else in this phase can
be called "production," the instance needs real secrets, real DB auth, and TLS. The BFF
side is already fine — `ZgwOptions.cs` binds from `IConfiguration`, so this is a deploy-config
change, not application code.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md)
- `backend/openzaak/README.md`
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
## Decisions (pre-made, don't relitigate)
- Secrets come from the deployment environment (env vars / secrets manager), never
checked into compose or appsettings.
- TLS termination happens at a reverse proxy/ingress in front of OpenZaak — OpenZaak
itself doesn't need built-in cert handling.
- The existing dev harness stays as-is for local iteration (WP-54's trimmed rig is
intentional and still valuable); this WP adds a production compose/override or an
env-driven parameterization of the same file, not a replacement of the dev rig.
## Files
- `backend/openzaak/docker-compose.openzaak.yml` (or a new `docker-compose.openzaak.prod.yml` override)
- `backend/openzaak/README.md`
- `backend/src/BigRegister.Api/appsettings*.json` / `Zgw/ZgwOptions.cs` (confirm only, likely no change)
## Steps
1. Parameterize `SECRET_KEY`, DB user/password, and the ZGW JWT secret via env vars;
remove hardcoded values from the committed file.
2. Switch `POSTGRES_HOST_AUTH_METHOD` from `trust` to password auth, password from env.
3. Set `IS_HTTPS: 'yes'`; document the required reverse-proxy/ingress TLS termination.
4. Update `backend/openzaak/README.md` with the required env vars and the TLS note.
5. Confirm the BFF's JWT secret already comes from config — no code change expected.
## Acceptance criteria
- [x] No secret value is hardcoded in any committed compose/config file.
- [x] The production compose fails fast (or docs state clearly) when secrets aren't
supplied — no silent fallback to a real-looking default.
- [x] README documents exactly which env vars must be set and how TLS is terminated.
## Deviation from the original plan
The WP's own "Files" section expected the secret to be parameterized directly inside
`docker-compose.openzaak.yml`'s environment or a straight env-var override. That covers
`SECRET_KEY`/DB password/`IS_HTTPS` fine (compose does key-based environment merging across
`-f` files even though the base file writes some blocks as YAML mappings and others as
anchors), but the ZGW client secret lives inside `setup_configuration/data.yaml`, a file
OpenZaak's own `setup_configuration` management command reads — compose has no mechanism to
interpolate env vars _inside_ a mounted file's contents. Solved by templating that one file
(`data.prod.yaml.template`, no secret) + a tiny host-side `render-prod-secrets.sh`
(`envsubst`, fail-fast via `${VAR:?...}`) that produces a gitignored `data.prod.yaml`, which
`docker-compose.openzaak.prod.yml` mounts over the container's `data.yaml` (bind-mounting a
single file inside an already bind-mounted read-only directory works fine in Docker/Podman —
verified via `docker compose config` with the override applied). No new dependency: `envsubst`
is part of `gettext`, already present on this machine.
Verified for real: `docker compose -f docker-compose.openzaak.yml -f
docker-compose.openzaak.prod.yml config` succeeds with all required env vars set and both
environment overrides (SECRET_KEY, DB password/auth method) present in the merged output;
fails with a clear `${VAR:?...}` error when any is missing. `render-prod-secrets.sh` itself
fails fast (tested) when `OPENZAAK_CLIENT_SECRET` etc. are unset, and its rendered
`data.prod.yaml` was inspected and matched the template with real values substituted.
`cd backend && dotnet test` (WP-54 harness untouched): 159/159 green. The dev harness
(`docker-compose.openzaak.yml` alone, `setup_configuration/data.yaml`) is untouched.
## Verification
`docker compose -f backend/openzaak/docker-compose.openzaak.yml config` with required env
vars set; `cd backend && dotnet test` (WP-54 harness tests unaffected); manual: the local
dev harness still works with its dev-only values documented as dev-only.
## Out of scope
Catalogus provisioning (WP-56), client scope narrowing (WP-57), notifications (WP-58).
## Risks
If TLS/secrets docs lag an actual deploy, someone could ship with dev defaults — mitigate
by making the prod compose fail without required env vars rather than silently defaulting.
@@ -0,0 +1,105 @@
# WP-56 — Idempotent catalogus provisioning
Status: done
Phase: 10 — OpenZaak production hardening
## Why
`backend/openzaak/bootstrap-catalogus.sh` seeds catalogus/zaaktype/statustype/roltype/zaak
via hand-rolled curl+JWT and is explicitly **not idempotent** (fails on `domein`+`rsin`
uniqueness on rerun) — fine for a one-shot WP-54 harness, wrong for an environment that
needs to be rebuildable. OpenZaak already ships a documented, scripted alternative — the
`setup_configuration` mechanism (already used in the harness for the JWTSecret/Applicatie,
see `setup_configuration/data.yaml`) — this WP extends that same mechanism to the catalogus
content too.
## Read first
- `backend/openzaak/bootstrap-catalogus.sh`
- `backend/openzaak/setup_configuration/data.yaml`
- `backend/openzaak/docker-compose.openzaak.yml` (`web-init` service)
- OpenZaak's own `setup_configuration` / `openzaak_config_cli` docs (upstream)
## Decisions (pre-made, don't relitigate)
- Move catalogus/zaaktype/statustype/roltype provisioning into `setup_configuration`,
run by the existing `web-init` one-shot service, instead of the separate curl script.
- Keep `bootstrap-catalogus.sh` only for whatever content `setup_configuration` genuinely
can't express (e.g. a demo zaak instance) — confirm what's left at kickoff.
- Provisioning must be safe to run against an already-provisioned instance — either
genuinely idempotent, or the compose is structured to only run it once per fresh
volume (document which, don't leave it ambiguous).
## Files
- `backend/openzaak/setup_configuration/data.yaml`
- `backend/openzaak/bootstrap-catalogus.sh` (trim to whatever remains)
- `backend/openzaak/docker-compose.openzaak.yml`
- `backend/openzaak/README.md`
## Steps
1. Express the catalogus/zaaktype/statustype/roltype definitions currently created by
curl as `setup_configuration` YAML.
2. Wire it into the `web-init` command alongside the existing JWTSecret/Applicatie config.
3. Trim `bootstrap-catalogus.sh` to only what setup_configuration can't cover, if anything.
4. Test: tear down + `docker compose up` twice in a row (fresh volume, then existing
volume); confirm no failure on rerun.
5. Update the README describing the provisioning flow.
## Acceptance criteria
- [x] ~~Catalogus/zaaktype/statustype/roltype provisioning is declarative
(`setup_configuration`), not imperative curl.~~ Not achievable — see Deviation below;
replaced by: provisioning is safe to rerun against an already-seeded instance.
- [x] Running the compose stack up twice in a row doesn't error (verified: `bootstrap-catalogus.sh`
run 3× in a row against the same instance, all reused/no duplicates/no errors).
- [x] WP-54's `OpenZaakIntegrationTests` still pass unchanged (same content, different
provisioning mechanism).
## Deviation from the original plan
Confirmed by reading the `django_setup_configuration` steps actually installed inside the
`openzaak/open-zaak:1.29.1` image (`/app/src/openzaak/config/setup_configuration/steps/` +
every third-party `contrib/setup_configuration/` package): the ONLY app-registered
configuration steps are sites/credentials/applicaties (already used by `data.yaml`) and
Selectielijst API config. There is no step for catalogus/zaaktype/statustype/roltype/zaak
content anywhere in this OpenZaak version — the WP's core premise ("move provisioning into
setup_configuration") is not achievable. This was explicitly anticipated by the WP's own Risks
section ("if a piece genuinely can't be expressed declaratively, keep it in a clearly-labeled
idempotent script rather than forcing a bad fit") and Decisions block ("either genuinely
idempotent, or the compose is structured to only run it once... document which").
Chose **genuinely idempotent**: `bootstrap-catalogus.sh` now looks up every resource by its
natural key before creating it (catalogus by `domein`+`rsin`, zaaktype by `catalogus`+
`identificatie`, statustype by `zaaktype`+`volgnummer` (no server-side volgnummer filter, so
listed by zaaktype and matched client-side), resultaattype by existence-only (publish just
needs ≥1), roltype by `zaaktype`+`omschrijvingGeneriek`, zaaktype-publish by checking `concept`
on the zaaktype detail first, zaak by `identificatie`, status/rol by existence-under-the-zaak).
Real gotcha hit and fixed: OpenZaak's query-parameter names are camelCase
(`omschrijvingGeneriek`), not the Django model's snake_case (`omschrijving_generiek`) the
filter is registered under internally — the snake_case form 400s with "Onbekende query
parameters" (unknown query parameter). Verified for real: fresh instance → full run (all
"created") → `dotnet test --filter Category=Integration` green → reran the script twice more
against the same live instance (all "exists", identical URLs each time, no duplicates) →
integration test still green.
Not attempted: moving anything into `setup_configuration/data.yaml` (nothing there to move,
per the finding above).
## Verification
`docker compose -f backend/openzaak/docker-compose.openzaak.yml up` twice in a row (fresh
volume, then existing volume); `cd backend && dotnet test --filter Category=Integration`
against the harness.
## Out of scope
Secrets/TLS (WP-55, unrelated but sequenced first in the table only by number), client
scopes (WP-57), notifications (WP-58).
## Risks
OpenZaak's `setup_configuration` coverage for zaaktype/besluittype content may be
incomplete upstream — if a piece genuinely can't be expressed declaratively, keep it in a
clearly-labeled idempotent script rather than forcing a bad fit.
@@ -0,0 +1,104 @@
# WP-57 — Least-privilege client scopes
Status: done
Phase: 10 — OpenZaak production hardening
## Why
The harness's OpenZaak client is granted `heeft_alle_autorisaties: true` in
`setup_configuration/data.yaml` — acceptable for a disposable test rig, wrong for anything
closer to production, where the BFF's client should hold only the Autorisaties it actually
exercises.
## Read first
- `backend/openzaak/setup_configuration/data.yaml`
- `backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs` and `OpenZaakDocumentSource.cs`
(the actual ZGW endpoints/verbs called)
## Decisions (pre-made, don't relitigate)
- Scope precisely to what the BFF calls today: zaken (aanmaken, bijwerken, lezen),
statussen (aanmaken), rollen (aanmaken), documenten/zaakinformatieobjecten (aanmaken,
lezen) — enumerate exactly at kickoff from the client code, don't guess broader.
- No wildcard/all-scope grant in any environment beyond the pre-WP-56 disposable dev rig.
## Files
- `backend/openzaak/setup_configuration/data.yaml` (Autorisaties block)
- `backend/openzaak/README.md`
## Steps
1. Grep `OpenZaakZaakSource.cs` and `OpenZaakDocumentSource.cs` for every ZGW
endpoint/verb called.
2. Replace `heeft_alle_autorisaties: true` with an explicit `autorisaties` list matching
exactly that set.
3. Re-run the full integration suite against the narrowed client; add any scope a 403
surfaces.
## Acceptance criteria
- [x] Client config has no wildcard/all-scopes grant.
- [x] `OpenZaakIntegrationTests` (WP-54) pass unchanged against the narrowed client.
## What actually happened
`vng_api_common`'s `ApplicatieConfigurationModel` (the class backing
`setup_configuration`'s `vng_api_common_applicaties` step, read from the installed package
inside the `openzaak/open-zaak:1.29.1` image) only has fields for
`uuid`/`client_ids`/`label`/`heeft_alle_autorisaties` — there is no YAML field for granular
`autorisaties` at all. So `data.yaml` now sets `heeft_alle_autorisaties: false` (both the dev
harness and the prod template), which leaves `bigregister-test` with **zero** Autorisaties
until something else grants them.
That "something else" can't be the JWT-authenticated Autorisaties REST API — a zero-scope
client can't grant itself scope over an API gated by scope (confirmed from
`ApplicatieViewSet.required_scopes`: `update`/`partial_update` need
`autorisaties.bijwerken`). `bootstrap-catalogus.sh` grants the scopes directly via the ORM
instead (`docker compose exec web python manage.py shell`, workdir `/app/src`) — no
JWT/REST layer involved, so no circularity. Two grants, both idempotent (delete-then-create):
- `ztc`: `catalogi.lezen` + `catalogi.schrijven` — granted up front (no zaaktype dependency).
Only `catalogi.schrijven` is provisioning-only; the BFF itself only ever reads Catalogi.
- `zrc`: `zaken.aanmaken` + `zaken.bijwerken` + `zaken.lezen`, scoped to the one zaaktype
(`zaaktype=<ZT-HERREG url>`, `max_vertrouwelijkheidaanduiding=openbaar` — both fields are
_required_ by OpenZaak's `AutorisatieValidator` for any `zaken.*` scope) — granted once
`zaaktype_url` is known, right after the zaaktype is created/resolved.
Reading the actual `RolViewSet`/`StatusViewSet`/`ZaakInformatieObjectViewSet`
`required_scopes` (not just the scope docstrings, which are aspirational/descriptive) showed
the decision text's "statussen (aanmaken), rollen (aanmaken)" don't map to separate OpenZaak
scopes — `zaken.aanmaken` alone (OR'd against alternatives) already covers the first status
and the initiator rol; there is no `rollen.aanmaken` scope. `documenten`/`zaakinformatieobjecten`
scope was **not** granted: `Zgw:InformatieobjecttypeUrls` is empty in `appsettings.json`, so
`OpenZaakDocumentSource.Upload` can't function in this harness regardless of scope (throws
before any HTTP call) — nothing to scope precisely to yet. Left as a documented follow-up
(the script would also need to seed an `informatieobjecttype` to have something concrete to
scope `documenten.aanmaken` to).
Verified for real: `down -v` fresh volume → `up -d``bootstrap-catalogus.sh` (all
"created", scopes granted, `heeft_alle_autorisaties: False` confirmed via `manage.py shell`)
`dotnet test --filter Category=Integration` green → reran `bootstrap-catalogus.sh` again
under the now-narrowed client (all "exists", scopes re-granted idempotently, no 403s) →
confirmed the narrowing is real, not just untested, by DELETEing the seeded zaak with a
hand-rolled JWT for this client: 403 `permission_denied` (zaak deletion needs
`zaken.verwijderen`/`zaken.geforceerd-bijwerken`, neither granted).
## Verification
`cd backend && dotnet test --filter Category=Integration` against the harness with the
narrowed client.
## Out of scope
Rotating/expiring the client credential itself — defer until multi-tenant/production ops
actually need it.
## Risks
An overlooked scope only surfaces as a runtime 403 against a real instance — mitigated by
running the full integration suite, which already exercises every current call path
(WP-54).
Depends on: WP-56 (provisioning mechanism this scopes down).
@@ -0,0 +1,128 @@
# WP-58 — Real notifications (celery + scripted abonnement)
Status: done
Phase: 10 — OpenZaak production hardening
## Why
The WP-54 harness deliberately trims celery/celery-beat/celery-flower and nginx, and sets
`NOTIFICATIONS_DISABLED: 'true'` — without a Celery worker, OpenZaak 500s and rolls back on
every write to a notified resource. Fine for a fixture-driven integration harness; a real
deployment that wants live Notificaties (WP-52's webhook) needs the workers running and the
`abonnement` (subscription) actually registered against the BFF's public callback URL —
today that registration step is manual.
## Read first
- `backend/openzaak/docker-compose.openzaak.yml` (top-of-file ponytail note)
- [openzaak-integration.md](../reference/openzaak-integration.md) (Notificaties section)
- [WP-52](WP-52-openzaak-notificaties.md)
## Decisions (pre-made, don't relitigate)
- Add celery + celery-beat as additional compose services (same `openzaak/open-zaak`
image, different command), pointed at the same redis broker already in the harness.
- Registering the `abonnement` becomes a scripted, idempotent step — not a manual
admin-UI action — parameterized by the BFF's real public URL.
- Keep the existing WP-54 harness variant (`NOTIFICATIONS_DISABLED: 'true'`) available for
fast local iteration where a live webhook round-trip isn't needed; this WP is additive
(a "with notifications" profile/override), not a replacement.
## Files
- `backend/openzaak/docker-compose.openzaak.yml` (or an override file)
- New script/config for `abonnement` registration
- `docs/reference/openzaak-integration.md`
## Steps
1. Add celery/celery-beat services to a notifications-enabled compose profile.
2. Flip `NOTIFICATIONS_DISABLED` off for that profile.
3. Script the `abonnement` registration (POST to the NRC, pointed at the BFF's
`/zgw/notificaties` endpoint from WP-52), idempotent on rerun.
4. Verify a real write (e.g. a status change) triggers a live webhook delivery to the BFF.
## Acceptance criteria
- [x] A notifications-enabled harness profile runs a celery worker and delivers a real
notification end-to-end to the BFF's webhook.
- [x] Provisioning is a script, re-runnable without erroring on an already-configured target.
## What actually happened
The Decisions block assumed OpenZaak itself could be pointed at, celery-wired, and made to
deliver to a subscribed `abonnement` — checking the running image (`grep -ril abonnement` inside
the `web` container) found nothing: **OpenZaak does not serve the Notificaties API.** It's a
separate application (`openzaak/open-notificaties`, its own image/DB/celery/beat stack).
Standing one up for real `abonnement`/kanaal-filtered routing would mean ~5 new services (a
second Postgres, web, worker, beat, plus the NRC↔AC authorization chain) for a benefit this
harness doesn't need — there is exactly one subscriber (this repo's own BFF), never N. Re-scoped
before writing any code (confirmed with the user): OpenZaak's own `NotificationsConfig` points
straight at the BFF's webhook via a `zgw_consumers.Service` (`auth_type=api_key`) instead — no
NRC, no `abonnement`, same delivery proof (a real write → OpenZaak's celery worker → a real HTTP
POST → the BFF's audit trail). The two "no `abonnement`" acceptance-criteria words above were
edited out for the same reason.
- `docker-compose.openzaak.notificaties.yml` — an opt-in overlay (not `profiles:`, matching
WP-55's prod-override precedent) adding one celery worker (not celery-beat: `send_notification`
is a plain async task fired on save, not a scheduled one — beat only matters on a real NRC's
polling side) and flipping `NOTIFICATIONS_DISABLED` off. The two changes are inseparable:
`NOTIFICATIONS_GUARANTEE_DELIVERY` defaults true, so the moment that flag is false, every write
to a notified resource 500s-and-rolls-back unless `NotificationsConfig` already has a client —
hence `bootstrap-notificaties.sh` configuring it is not a separate step.
- Reaching the BFF from the worker turned out to be the real obstacle, not the Django/celery
wiring. `extra_hosts: host.docker.internal:host-gateway` (the plan's first choice) resolves
fine but every TCP connect through it timed out — confirmed live: this environment's rootless
Podman doesn't route container→host-port traffic that way. Fix: join the overlay's `celery`
service to the repo root's own `docker compose up` network (`external: true`, by the
`atomic-design-poc_default` name compose derives from the repo directory) and reach the BFF by
its container name (`api`) instead — container-to-container, which this exact stack already
proved reliable (`celery` already talks to `db`/`redis` that way). One more trap on that path:
`docker compose run --name api ...` does **not** register the `api` DNS alias other containers
need (only `docker compose up -d api` does) — cost a debugging round-trip before switching to
`up -d` (via a temporary, uncommitted `docker-compose.override.yml`) for the live verification.
- `bootstrap-notificaties.sh``update_or_create` on the `Service`'s fixed slug (idempotent);
preflights the BFF's webhook with a synthetic notification body first (204 required) so a
misconfigured target fails before touching OpenZaak, not after (a later write would otherwise
500-and-rollback with no obvious cause).
- `verify-notificatie.sh` — the runnable end-to-end check. First attempt triggered the write via
a second `statussen` POST (the "final" status) — 403'd: WP-57's narrowed `zaken.aanmaken` scope
permits exactly **one** status per zaak ("Met de 'zaken.aanmaken' scope mag je slechts 1 status
zetten"). Switched the trigger to a zaak `PATCH` (`toelichting`), covered by the already-granted
`zaken.bijwerken` and trivially repeatable. Second attempt used the _final_ statustype anyway
for a different reason and got a 400 ("Zaak has no resultaat") — OpenZaak requires a `resultaat`
before the closing status; the `PATCH` sidesteps that precondition entirely too.
- Verified for real, twice: `bootstrap-catalogus.sh` (idempotent re-run, all "exists") →
`bootstrap-notificaties.sh` (preflight 204, `Service` configured) → `verify-notificatie.sh`
(PATCH → polled `/admin/audit` → found the delivered `zgw:notificatie`/`allow` row) → reran
both WP-58 scripts again under the same running harness (still idempotent, delivered again).
Also confirmed the negative case directly: `POST /zgw/notificaties` with no `Authorization`
header, and with a wrong one, both 401 — the shared-secret gate isn't just accepting anything.
Backend suite stayed green throughout (159/159, `dotnet test --filter Category!=Integration`).
Test infrastructure (the temporary `docker-compose.override.yml`, the manually-created `api`
container) was torn down / reconciled back to the pre-session baseline afterward.
## Verification
Bring up the notifications-enabled profile (`backend/openzaak/README.md`'s "Notifications-enabled
profile" section); run `./bootstrap-catalogus.sh && ./bootstrap-notificaties.sh &&
./verify-notificatie.sh`. The last script fails loudly (with celery/worker log diagnostics) if no
delivered notification shows up in the BFF's `/admin/audit` within 60s.
## Out of scope
Cache invalidation on notification receipt (flagged separately in
`openzaak-integration.md` as a `ponytail:` marker, not part of this slice);
celery-flower/monitoring UI. A real Notificaties API (NRC) + `abonnement`/kanaal-filtered
routing (see "What actually happened") — add one if a later WP needs more than this harness's
single subscriber.
## Risks
Celery adds real operational surface (another process to keep alive) — scope this WP to
"works, documented," not a fully monitored deployment. The direct-to-BFF shortcut means this
harness doesn't exercise real `abonnement`/kanaal-filter validation — a production deployment's
NRC-based path (documented in `openzaak-integration.md`) is untested by this harness by
construction.
Depends on: WP-56 (provisioning mechanism this extends).
@@ -0,0 +1,83 @@
# WP-59 — Per-document-type confidentialiteit config
Status: done
Phase: 10 — OpenZaak production hardening
## Why
`OpenZaakDocumentSource` hardcodes `vertrouwelijkheidaanduiding` to `"openbaar"` for every
uploaded document, regardless of document type. Real BIG-register documents (diploma's, ID
scans) plausibly need different confidentiality levels. This repo already has a house
pattern for exactly this kind of business-tunable value — stamdata-as-code (ADR-0004) — so
this slice is "apply the existing pattern," not invent a new one.
## Read first
- [ADR-0004 — Stamdata as code](../reference/architecture/0004-stamdata-as-code.md)
- `backend/src/BigRegister.Api/Stamdata/` (an existing table for the shape to imitate)
- `backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs`
## Decisions (pre-made, don't relitigate)
- Confidentiality level is keyed by document type (whatever type already distinguishes
uploads, e.g. diploma vs. id-bewijs) via a new Stamdata table, using the existing
`StamdataTable.Of<T>` mechanism — not a new ad hoc config format.
- Default/fallback value stays `"openbaar"` if a document type isn't in the table, to
avoid a silent upload failure.
## Files
- `Stamdata/` (new table + validation)
- `Zgw/OpenZaakDocumentSource.cs`
- `StamdataCatalog.cs` (register the new table)
## Steps
1. Add a `DocumentConfidentialiteit` stamdata table (document type →
vertrouwelijkheidaanduiding), validated at build like every other stamdata table
(`StamdataValidationTests`).
2. Register it in `StamdataCatalog` so it's editable via the existing `/beheer/stamdata`
grid.
3. `OpenZaakDocumentSource` looks up the level by document type instead of hardcoding
`"openbaar"`.
## Acceptance criteria
- [x] Confidentiality level for a real upload varies by document type per the new
stamdata table (`identiteit``vertrouwelijk`; everything else → `openbaar`).
- [x] `StamdataValidationTests` cover the new table (a bad edit fails CI, per ADR-0004).
- [x] `/beheer/stamdata` can edit the new table without a code change (existing generic
editor — the `StamdataCatalog` registration is the only wiring needed).
## What actually happened
Implemented mostly as planned — one gap found and closed: the diff as first written
registered `DocumentConfidentialiteit` in `StamdataCatalog` and wired the lookup into
`OpenZaakDocumentSource`, plus a positive test (`identiteit``vertrouwelijk`) and a
fallback test (an unmapped category, `org-logo`, → `openbaar`), but had **no**
`StamdataValidationTests` reference-integrity entry for the new table — the second
acceptance box was unchecked. Added one: a `StamdataRef` resolving every
`documentconfidentialiteit.json` `categoryId` against the real set of document category
ids (`DocumentRules.AllCategoriesFor` across `registratie`/`herregistratie`/`org-template`),
so a typo'd or stale `categoryId` now fails the build instead of silently never matching
(`OpenZaakDocumentSource.ConfidentialiteitFor`'s dictionary lookup would otherwise just
fall back to `"openbaar"` forever with no signal). `org-logo` deliberately stays absent
from the confidentialiteit table (falls back to `"openbaar"`) and correctly still
resolves as a known category — the reference check validates "is this a real category",
not "must every category be configured."
## Verification
`cd backend && dotnet test` (161/161 green, incl. the 2 new `OpenZaakDocumentSourceTests` plus
the new `StamdataValidationTests` reference entry); `dotnet format --verify-no-changes` clean.
Manual: `/beheer/stamdata` shows and edits the new table; an upload for a mapped document type
carries the mapped confidentiality level (test asserted).
## Out of scope
Any UI-facing confidentiality display/change on the citizen side (FE keeps rendering
decisions, not recomputing them, per ADR-0001).
## Risks
None significant — this is a config/data-shape change reusing an established mechanism.
@@ -0,0 +1,94 @@
# WP-60 — Write-divergence resilience (local + ZGW writes)
Status: done
Phase: 10 — OpenZaak production hardening
## Why
A citizen action today does a local `Aanvraag`/`Document` write and a paired ZGW write
(create zaak/status/document); these aren't transactional. If the ZGW call fails after the
local write succeeds (or vice versa), the two diverge silently —
`openzaak-integration.md` flags this explicitly as "acceptable for a demo backend; a
production arc needs retry/reconciliation or an outbox." This is the one genuine
correctness gap standing between the current integration and something safe to call
production.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md) (the section discussing
this gap)
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`,
`Zgw/OpenZaakZaakSource.cs` (the two write sides)
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
## Decisions
Picked **(b), narrowed further: bounded synchronous retry + flag, no reconcile job.** The
`planner` agent's kickoff review found the write side smaller than either option assumed:
- The only ZGW writes are `OpenZaakZaakSource.CreateZaak` (zaak/status/rol, one POST sequence
per submit) and `OpenZaakDocumentSource.Upload`/`LinkToZaak` (DRC + zaakinformatieobject).
There is no standalone status-transition write path yet (that's WP-66) — Step 2 below is
corrected accordingly.
- Every path already does the local write first and never rolls it back on a ZGW failure — "the
ZGW half fails, local succeeded" is the only real scenario; the reverse can't happen.
- An outbox was rejected: three request-triggered write paths don't justify a persisted queue,
and a ZGW call's `CallerIdentity` (needed for the JWT's audit claims, WP-53) would mean PII
sitting in a new table — the "generic outbox framework" this WP's own Risks section warns
against.
- A reconcile job was judged unnecessary for the acceptance criteria: flagging (not silent
divergence) is sufficient, and repair is always possible on demand because a zaak's
`identificatie` equals the aanvraag's `Referentie` — no reconcile job ships in this WP.
Shipped: bounded retry (3 attempts, doubling backoff from 200ms) in `ZgwHttpClient` for
transport-shaped failures only (429/502/503/504/408 + connection errors/timeouts — deliberately
**not** 500, which can follow a partial commit on the non-idempotent `/statussen`/`/rollen`
POSTs); `Aanvraag.ZgwError` + a `zgw:divergence` audit row when a ZGW write still fails after
retry (`Program.cs`'s submit endpoint, two separate try/catches so a create-zaak failure doesn't
also skip the still-local document link); `OpenZaakDocumentSource.Upload` catches and logs
without a separate flag column (`DrcUrl == null` already means "not registered in ZGW yet").
Full reasoning + rejected sub-options: [openzaak-integration.md](../reference/openzaak-integration.md)'s
"Write resilience" section.
## Files
`Zgw/ZgwHttpClient.cs` (retry), `Data/ApplicationStore.cs` (`ZgwError` column + migration),
`Program.cs` (submit endpoint rewire + `RecordZgwDivergence` + HttpClient timeouts),
`Zgw/OpenZaakDocumentSource.cs` (non-throwing upload). No new file for a mechanism — no
outbox/background worker shipped (see Decisions).
## Steps
1. Design review with the `planner` agent — pick outbox vs. retry+reconcile. Done: retry+flag
(see Decisions).
2. Implement the chosen mechanism for the create-zaak and document (upload + link) write
paths — not "status-transition" as originally scoped here; that path doesn't exist yet
(arrives with WP-66).
3. Add a test that simulates a ZGW failure mid-write and asserts the system recovers
(retries successfully, or is left in a detectably-inconsistent-but-flagged state)
rather than silently diverging.
## Acceptance criteria
- [x] A simulated ZGW failure after a successful local write no longer leaves permanent
silent divergence — either it retries to consistency or the divergence is
detectable/flagged.
- [x] No new synchronous latency added to the happy path beyond what the chosen mechanism
requires.
## Verification
A new integration test that fails a stubbed ZGW call mid-write and asserts
recovery/flagging behavior; `cd backend && dotnet test`.
## Out of scope
A general-purpose outbox framework reusable beyond this one write pair (YAGNI unless a
second write pair appears — note WP-66 is exactly that second pair, so revisit scope if
WP-66 lands first); UI surfacing of reconciliation state (backend-only fix for now).
## Risks
Over-building this (a generic outbox/saga framework) for a POC's actual write volume —
ladder check at kickoff: does a bounded retry + periodic reconcile job cover it before
reaching for an outbox table?
@@ -0,0 +1,83 @@
# WP-61 — Bootstrap the behandelportal app
Status: done (behandelportal@6b0c6ce — separate sibling repo, not a commit in this one)
Phase: 11 — Behandelportal
## Why
ADR-0002 already designed the Behandelaar/backoffice as a separate sibling frontend app,
not a folder in this repo. Nothing exists yet — `/beheer/zaken` is confirmed to be only a
cross-owner list+delete, no treatment workflow. The `new-ssp` skill exists precisely to
bootstrap a new portal from this template; this slice is running that recipe for real,
with no business context yet — an empty, correctly-scaffolded shell.
## Read first
- `.claude/skills/new-ssp/SKILL.md`
- [ADR-0002 — user groups & bounded contexts](../reference/architecture/0002-user-groups-and-bounded-contexts.md)
- [ADR-0001 — BFF-lite decision DTOs](../reference/architecture/0001-bff-lite-decision-dtos.md)
## Decisions (pre-made, don't relitigate)
- Follow `new-ssp`'s own "keep vs. strip" checklist as-is: keep the `shared/ui` kernel,
tooling/CI gates, ADRs 0001-0003; strip the four citizen contexts and citizen branding.
- The new app talks to the same `BigRegister.Api` backend — no new backend service
(confirmed by ADR-0002: contexts integrate through the backend).
- Repo layout: **separate sibling repo** at `/home/eho/repos/behandelportal` (fresh clone —
`scripts/create-frontend.mjs`'s own header comment says to run it inside a fresh clone, not
against this repo's working tree).
## Resolution (kickoff deviation from the vanilla recipe)
`create-frontend.mjs` unconditionally renames the backend project regardless of
`--skip-backend` (that flag only gates the `gen:api` regen step) — which conflicts with
"no new backend service" above. Resolved by running the script as-is
(`--name Behandelportal --context behandeling --skip-backend`), then in the new repo:
deleting `backend/` entirely, vendoring its `swagger.json` into `api-contract/swagger.json`,
and pointing `gen:api` at `nswag run nswag.json` against that vendored doc instead of
regenerating from a live backend. Dev workflow is two terminals: `BigRegister.Api` runs from
_this_ repo on `:5000`, Behandelportal serves on `:4201` via its own unchanged
`proxy.conf.json`.
Also found and fixed by hand (real bugs in `create-frontend.mjs`, not specific to this WP):
`plop context`'s `.dependency-cruiser.js` insertion anchors on the `showcase`
`CONTEXT_ALLOWED` entry, which is already stripped by the time `plop context` runs in the
same invocation — so a freshly scaffolded context silently gets **no boundary-fence
entry**, and `dep:check` passes green with the architecture guarantee simply absent. Not
re-fixed in `create-frontend.mjs` itself this pass (out of scope here) — just compensated for by
hand in the new repo.
## Files
Whatever `new-ssp`'s recipe touches (new app root, `package.json`, shared/ui copy or
workspace reference, CI config) — enumerate at kickoff by following the skill.
## Steps
1. Run the `new-ssp` bootstrap per its own checklist.
2. Confirm the known un-genericizable rough edges it flags (`shared/ui/debug-state/`, the
`/dashboard` route) are handled per the skill's own guidance (delete / TODO stopgap)
rather than re-solved from scratch.
3. Land an empty landing/login page only — no `behandeling` context yet (that's WP-64+).
4. Get the new app's own CI green.
## Acceptance criteria
- [x] New app boots and its own `npm run ci` is green.
- [x] No citizen-facing business context (`registratie`, `herregistratie`, `brief`,
`showcase`) present.
- [x] Points at the same backend (`BigRegister.Api`) as this repo, no new backend stood
up.
## Verification
`npm run ci` in the new app; manual smoke — app loads to an empty shell page.
## Out of scope
Any `behandeling` screens (WP-64/65), identity (WP-62), status lifecycle (WP-63).
## Risks
`new-ssp`'s own docs already name its rough edges (`debug-state`, `/dashboard`) — budget
time for those rather than being surprised by them.
@@ -0,0 +1,96 @@
# WP-62 — Backend: medewerker caller identity + authz seam
Status: done
Phase: 11 — Behandelportal
## Why
The backend's only identity today is `CallerIdentity` (BSN + display name, from WP-53)
modeling a single zorgverlener actor. ADR-0002 requires a second actor kind
(medewerker/employee) that authenticates differently (no BSN, has `rollen`) and needs its
own capability checks for backoffice calls. This slice adds that identity + authz surface
on the backend only — unused by any frontend until WP-64 calls it, matching the same
"seam, not provider" discipline WP-53 used for citizen identity (stub, no real employee
SSO — out of scope per CLAUDE.md, same as DigiD).
## Read first
- [ADR-0002 §3 — Principal union](../reference/architecture/0002-user-groups-and-bounded-contexts.md)
- `backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs`,
`IIdentityProvider.cs`, `StubIdentityProvider.cs` (WP-53's pattern to extend/mirror)
- [WP-53](WP-53-inbound-identity-and-citizen-scoping.md)
## Decisions (pre-made, don't relitigate)
- Model the two actor kinds as a discriminated union (mirroring ADR-0002 §3:
`{ kind: 'zorgverlener'; bsn }` | `{ kind: 'medewerker'; medewerkerId; rollen }`),
backend-side, extending `CallerIdentity` rather than introducing a parallel type.
- Stub the medewerker identity the same way WP-53 stubbed citizen identity (a
header-driven `StubIdentityProvider` variant) — no real employee SSO/eHerkenning.
- New capability checks (e.g. `canBeoordelen`) are computed backend-side and exposed only
as decision flags, never a permission matrix shipped to a frontend (ADR-0001 discipline,
reaffirmed by ADR-0002 §3).
## Files
- `Domain/Authorization/CallerIdentity.cs` (extend to the union)
- `Domain/Authorization/StubIdentityProvider.cs` (medewerker variant)
- `Domain/Authorization/Authz.cs` (medewerker capability checks)
- Tests
## Steps
1. Extend `CallerIdentity` to the two-actor-kind union.
2. Extend the stub identity provider to produce a `medewerker` identity from a
header/config, alongside the existing zorgverlener stub.
3. Add capability checks a backoffice caller needs (start with `canBeoordelen`; extend as
WP-65 needs more).
4. Unit tests for both identity kinds and the new capability checks — no consumer exists
yet (WP-64+ will call this).
## Acceptance criteria
- [x] `CallerIdentity` represents both actor kinds without breaking any existing
zorgverlener call site (WP-53's tests still green).
- [x] A stub medewerker identity resolves from a request header, mirroring the existing
citizen stub.
- [x] At least one capability flag (`canBeoordelen`) computable for a medewerker
identity, unit-tested.
## Verification
`cd backend && dotnet test` (existing WP-53 tests unaffected + new medewerker tests
green) — 182/182 (168 baseline + 14 new). `dotnet format --verify-no-changes` clean.
## Out of scope
Any actual backoffice endpoint using this (WP-64+); real employee SSO/eHerkenning.
## Risks
If the union is modeled as a bolt-on rather than replacing the flat type, existing
zorgverlener call sites could break — mitigated by keeping WP-53's existing tests as a
regression gate.
## Outcome notes
- **The `Files` list undersold the blast radius.** `CallerIdentity` became `abstract`
with two derived records (`ZorgverlenerCaller`, `MedewerkerCaller`), which is a hard
compile error at every `new CallerIdentity(...)` and every `.Bsn` read outside
`Domain/Authorization/` — 17 `ctx.Caller().Bsn` reads in `Program.cs` alone, plus 6
seam signatures (`IDocumentSource.Upload`, `IZaakSource.ListMyCases` and their
Local/OpenZaak implementations) narrowed to `ZorgverlenerCaller` where `.Bsn` is used
as an ownership key, plus test fixtures in 4 test files.
`CallerIdentity.SubjectId` (BSN or medewerkerId) is the trick that kept the
token-mint-only call sites (`ZgwTokenProvider.Mint`, `ZgwHttpClient`, `IZaakSource
.CreateZaak`, `IDocumentSource.LinkToZaak`) compiling with zero signature changes —
they never needed the BSN specifically, just _an_ id for the ZGW audit trail.
- **`Role` (`PrincipalRole`, the existing dev-role stand-in) stays on the base record**,
not per-variant — it's an orthogonal axis (both actor kinds can be any dev role),
which is why `Authz.ResolvePrincipal(ctx) => new(ctx.Caller().Role)` and its ~15
call sites needed no changes at all.
- **A new extension, `ctx.Zorgverlener()`**, narrows `CallerIdentity` to
`ZorgverlenerCaller` or throws — deliberately a 500, not a 403, since no medewerker
reaches any SSP endpoint today (nothing sends `X-Medewerker` yet). WP-64 should
map this to a 403 once real backoffice traffic exists; flagging it now so it isn't
mistaken for an oversight.
@@ -0,0 +1,86 @@
# WP-63 — Backend: aanvraag status lifecycle as a published DTO
Status: done
Phase: 11 — Behandelportal
## Why
The FE currently infers "in behandeling" from a single boolean, `pendingHerregistratie`
(`big-profile.store.ts:53`) — explicitly called out in ADR-0002 as "a temporary stand-in
for a real, backend-owned status." The full lifecycle (`Ingediend → In behandeling →
(Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen`) needs to become a real backend-published
value before either frontend can render it meaningfully — the SSP needs it as a richer
read (this WP), the behandelportal needs it as the thing it advances (WP-65).
## Read first
- [ADR-0002](../reference/architecture/0002-user-groups-and-bounded-contexts.md) (status
lifecycle diagram)
- `src/app/registratie/application/big-profile.store.ts` (the current boolean)
- [ADR-0001 — BFF-lite decision DTOs](../reference/architecture/0001-bff-lite-decision-dtos.md)
## Decisions (pre-made, don't relitigate)
- Status lives on the existing `Aanvraag`/`ApplicationSummaryDto` aggregate (extend,
don't invent a parallel status resource).
- The DTO change is additive: the SSP's `pendingHerregistratie` boolean can be derived
from the new status field (or kept as a computed convenience) so this ships with zero
required FE behavior change — a pure backend + contract widening.
- Only the status _value_ is published here; any transition (advancing it) is a separate
write endpoint, not part of this slice (that's WP-65's mutation).
## Files
- `Data/ApplicationStore.cs` (status field/enum)
- `Contracts/Dtos.cs` (extend `ApplicationSummaryDto`/status DTO)
- The FE `infrastructure/*.adapter.ts` + `parse*` boundary consuming it
- `big-profile.store.ts` (derive the existing boolean from the new field)
## Steps
1. Model the full status enum backend-side (`Ingediend`, `InBehandeling`,
`MeerInfoGevraagd`, `Goedgekeurd`, `Afgewezen`) on `Aanvraag`.
2. Publish it on the existing DTO the SSP already consumes.
3. Regenerate the typed client (`npm run gen:api`); update the FE `parse*` boundary to
read the new field.
4. Point `pendingHerregistratie` (or its replacement) at the new field so the SSP's
existing behavior is unchanged, just backed by a real value.
## Acceptance criteria
- [x] Backend publishes the full status lifecycle value on the existing aanvraag DTO.
- [x] `npm run gen:api` leaves no drift; SSP's existing "pending" display is unchanged in
behavior, now backed by the real status.
- [x] `dotnet test` + `npm run ci` green.
## Outcome
Implemented as a pure type-system widening, not a behavior change: `AanvraagStatusTag`
(`Ingediend | InBehandeling | MeerInfoGevraagd | Goedgekeurd | Afgewezen`) is a new C# enum
backing `Mappers.ToStatusDto`'s existing string literals — `AanvraagStatusDto.Tag` stays a
plain string, so the OpenAPI schema (and `npm run gen:api`) don't change at all, satisfying
"zero required FE behavior change" trivially. `Ingediend`/`MeerInfoGevraagd` aren't reachable
from any code path yet (no behandelaar action exists to produce them) — that's WP-65's
transition endpoint, exactly per this WP's own Risks note. The FE `AanvraagStatus` union,
`parseAanvraagStatus`, `statusLabel`/`submittedRow`/`detailRows`, `blockActions`, and the
dashboard's sort order were all widened to the two new tags so TypeScript's exhaustiveness
checking forces every switch to handle them once WP-65 starts emitting them.
`big-profile.store.ts`'s `pendingHerregistratie` was deliberately left untouched — it's a
pure client-side optimistic UI flag unrelated to any DTO field (not what the WP's "Why"
section implied), and the decision text's "or kept as a computed convenience" explicitly
allows this.
## Verification
`cd backend && dotnet test`; `npm run gen:api` (no drift); `npm run ci`; manual: SSP
dashboard still shows the same pending/approved states it does today.
## Out of scope
Any endpoint that _advances_ the status (WP-65); the behandelportal consuming it (WP-64).
## Risks
If the enum doesn't anticipate a state WP-65 needs (e.g. distinguishing who can transition
from what), it gets revised there — acceptable, this slice only needs to cover the states
already named in ADR-0002's diagram.
@@ -0,0 +1,98 @@
# WP-64 — Behandelportal: werkvoorraad (queue) screen
Status: done
Phase: 11 — Behandelportal
## Why
First real screen in the new app — a read-only list of aanvragen needing treatment (the
"werkvoorraad"), gated by the medewerker identity from WP-62 and backed by the real status
DTO from WP-63. This is the smallest useful vertical slice of actual case-treatment
functionality — usable and demoable on its own, even before any decision can be recorded
(WP-65).
## Read first
- WP-61/62/63 outcomes
- `bff-endpoint` skill (screen-shaped decision DTO recipe)
- `src/app/registratie/ui/admin-cases.page.ts` (the existing cross-owner list, for what
to avoid repeating — that page is audit/delete, this one is a queue)
## Decisions (pre-made, don't relitigate)
- New BFF-lite endpoint (decision-enriched DTO) shaped for a werkvoorraad screen — not a
reuse of the existing `/admin/cases` endpoint, which is audit-shaped, not queue-shaped
(per CLAUDE.md's per-screen endpoint discipline).
- Gated by the `canBeoordelen`-style capability from WP-62, not a new ad hoc role check.
- Domain first, then infrastructure, application, UI — per the house `new-feature`
recipe.
## Files
New backend endpoint + DTO in `BigRegister.Api`; new `behandeling` context in the
behandelportal app (domain/infrastructure/application/ui per the house layering).
## Steps
1. Backend: new screen-shaped endpoint returning aanvragen needing treatment
(status = `InBehandeling`/`Ingediend`), gated by WP-62's capability.
2. FE: scaffold the `behandeling` context (domain → infrastructure → application → ui),
following `new-feature`.
3. UI: a list page (queue), composed from the shared `shared/ui` kernel — no new atoms
unless nothing existing fits.
4. Storybook story for the new list component/page, a11y-checked.
## Acceptance criteria
- [x] Werkvoorraad screen lists aanvragen needing treatment for an authenticated
medewerker.
- [x] `npm run ci` green in the behandelportal app; Storybook story present.
- [x] Endpoint follows BFF-lite discipline (decision-enriched, not raw passthrough).
## Outcome
`GET /werkvoorraad` reuses the existing `ApplicationSummaryDto`/`IZaakSource.ListCases`
no new DTO — filtered server-side to `Status.Tag is "Ingediend" or "InBehandeling"`. Gated
by a new `Werkvoorraad` local-function twin of `CasesAdmin`, but checking
`Authz.CanBeoordelen(ctx.Caller())` (the CallerIdentity directly) rather than a
role-only `Principal` — a zorgverlener with `X-Role=admin` is still denied (covered by a test).
One course correction beyond the pre-made decisions: `GET /me` didn't expose any
capability a medewerker could hold (`RoleCapabilities` only switches on `PrincipalRole`,
which every `MedewerkerCaller` also carries but doesn't determine `CanBeoordelen`). Added
one line — `if (Authz.CanBeoordelen(ctx.Caller())) caps.Add("aanvraag:beoordelen")` — and a
matching `Capability` union member in `libs/shared`, so the FE page can use the same
deny-by-default `AccessStore`/`capabilityGuard` idiom every other gated page uses (avoids a
wasted 403 GET and a denial flash), instead of inventing a second gating mechanism.
FE: the queue item's domain type (`behandeling/domain/werkvoorraad-item.ts`) is
deliberately **narrower** than the SSP's full `AanvraagStatus` union — only
`Ingediend`/`InBehandeling` — so a case the backend's filter let through with any other
tag is a parse error, not a silently-rendered row. Composed entirely from existing
`libs/shared/ui` molecules (`ApplicationListComponent` + `ApplicationLinkComponent`, the
same ones ssp's dashboard uses) via one new organism, `WerkvoorraadListComponent` — no new
atom. The stopgap `behandeling.page.ts`/`BehandelingPage` (WP-61's scaffold placeholder,
its own TODO said to replace it) is gone; `/dashboard` now loads `WerkvoorraadPage`
directly, and the redundant `/behandeling` route (same placeholder, two paths) was dropped.
**Correction (found during WP-65):** this WP's Verification line ("manual: log in as a stub
medewerker, see the queue populated") could not actually have passed — the behandelportal's
login was still WP-61's copy-pasted citizen/BSN DigiD flow, nothing sent `X-Medewerker`, so
`WerkvoorraadPage` always rendered its denial alert in a real browser. CI stayed green
regardless (none of this WP's tests exercise the browser gate). Fixed in WP-65 with a
dev-only `medewerkerInterceptor` — see that WP's Progress notes.
## Verification
`npm run ci` in the behandelportal app; `cd backend && dotnet test`; manual: log in as a
stub medewerker, see the queue populated from seeded aanvragen.
## Out of scope
Viewing a single zaak's detail (WP-65); recording any decision (WP-65).
## Risks
None major — this is a read-only composition slice once WP-61-63 exist.
Depends on: WP-61, WP-62, WP-63.
@@ -0,0 +1,186 @@
# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen
Status: done (65a — detail read — done; 65b — decision write — done)
Phase: 11 — Behandelportal
## Why
The core case-treatment write path — a medewerker opens one aanvraag's detail (including
its documents) and records a decision (goedkeuren/afwijzen/meer info opvragen), advancing
the status lifecycle WP-63 published. This is the first genuinely new _write_ capability
in the system beyond what the citizen SSP already does to itself.
## Read first
- `mutation-command` skill
- `form-machine` skill (the decision action is a state-changing form, same idiom as
everywhere else in this house)
- [WP-63](WP-63-aanvraag-status-lifecycle.md) (the status field being advanced)
## Decisions (pre-made, don't relitigate)
- The decision action is modeled as a `*.machine.ts` (Model/Msg/reduce) + a `submit-*`
command returning `Result`, per this house's one-idiom-for-forms rule — not a
hand-rolled mutable field.
- The mutation endpoint is a new BFF-lite write (per `mutation-command` recipe) that
transitions the status field from WP-63; it validates the transition is legal
server-side (e.g. can't approve an already-approved case) — the backend remains the
authority.
- Runs against `LocalZaakSource` for this slice; wiring the decision into real OpenZaak is
explicitly WP-66, not bundled here — keeps this slice's surface to app-level behavior
only.
## Files
New mutation endpoint + command in `BigRegister.Api`; `behandeling/ui` detail page +
`behandeling/application` decision machine in the behandelportal app.
## Steps
1. Backend: mutation endpoint advancing aanvraag status (goedkeuren/afwijzen/meer-info-
opvragen), validating the transition.
2. FE: zaak-detail page (documents + current status) + a decision form machine + submit
command.
3. Wire the werkvoorraad list (WP-64) to link into this detail page.
4. Storybook stories + a11y for the new detail/decision UI.
## Progress notes (65a — done)
This WP's own Risks note offered an explicit escape hatch ("split detail-view (read) from
decision-recording (write) into two sessions if it feels too big"). Taken: 65a below is done
and committed; 65b (the mutation + decision machine + form) is the remaining half — see its
own separate session notes once started. Don't relitigate the split; do relitigate nothing
else pre-made here.
**Blocking gap found and fixed, not in this WP's original scope:** the behandelportal's
login was still WP-61's copy-pasted citizen/BSN DigiD flow — nothing sent `X-Medewerker`, so
`StubIdentityProvider` always resolved a zorgverlener, `GET /me` never contained
`aanvraag:beoordelen`, and WP-64's `WerkvoorraadPage` always rendered its denial alert in a
real browser. WP-64's own Verification line ("manual: log in as a stub medewerker, see the
queue populated") could not have passed as written — CI stayed green because none of its
tests exercise the browser gate. Fixed with a dev-only `medewerkerInterceptor`
(`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` +
`medewerker.ts`), mirroring `@shared/infrastructure/role.ts`'s `?role=` idiom but app-local
(`?rollen=`, sticky per tab, default `behandelaar`) — real employee-SSO login stays out of
scope (ADR-0002 §3: the two apps' login flows are expected to diverge). Documented in
`docs/reference/roles-and-access.md`.
**Backend (`GET /beoordeling/{id}`):** reads through `IZaakSource.ListCases` (no new seam
method — one would force an `OpenZaakZaakSource` get-by-id + mapper, which is WP-66's
surface), filters to the requested id, 404s a Concept or unknown id. Documents come from
`DocumentStore.ByIds` (new method, mirrors `ByLocalIds`) — `DocumentStore` directly, not
`IDocumentSource`, since that seam has no read method and its own header comment already
says `DocumentStore` stays the record of truth for preview/download/audit regardless of
config. The owner BSN is masked (`MaskTail`, same helper `/brief`'s BIG-nummer masking
uses) — WP-64's queue row leaks the full BSN via the same `ApplicationSummaryDto.Owner`
field; flagging as a follow-up, not fixed here or ssp's `/admin/cases` page moves too.
The gate that was `Werkvoorraad(ctx, action)` is now `Beoordelen(ctx, resource, action)`
one gate for every behandelaar endpoint, `resource` feeding the audit row; the one existing
`/werkvoorraad` call site was updated to pass `"werkvoorraad"`.
**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to
65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current
_computed_ status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable;
`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet,
so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds
the mutation, the `Besluit` enum, and the transition-legality check that reuses this same
function.
**FE:** `BeoordelingStatus` (`domain/beoordeling.ts`) is the five-tag union (all of ssp's
`AanvraagStatus` minus `Concept`, which 404s) — wider than WP-64's `WerkvoorraadStatus` (two
tags). `TYPE_LABELS`/`statusLabel` moved from `werkvoorraad-item-view.ts` into a new
`domain/beoordeling-view.ts` (the file owning the wider union) and are re-exported from the
old location so no consumer or existing spec needed to change. Detail page composes
`<app-data-block>`/`<app-data-row>` (mirrors ssp's `aanvraag-detail.page.ts`) plus one new
organism, `beoordeling-documenten` (plain links to the existing, pre-existing-unauthenticated
`/uploads/{id}/content` endpoint — not `ApplicationLinkComponent`, whose `to` is a
`routerLink`, not an external href). The werkvoorraad list's rows now link to
`/aanvraag/:id`, gated by the same `aanvraag:beoordelen` capability. No `MaskedValueComponent`
reveal affordance — this screen never reveals the owner, so pulling in that atom's unused
reveal machinery would be speculative.
**Deferred to 65b, deliberately:** the mutation endpoint, the `Aanvraag.BesluitStatus`
column + EF migration, `Mappers.ToStatusDto`'s "a recorded decision wins" branch, the
`besluit.machine.ts` + `besluit-form` UI, and re-running the werkvoorraad/ssp-dashboard
end-to-end smoke this WP's acceptance criteria actually asks for (a decision advancing
status, illegal transitions rejected). None of WP-65's acceptance criteria are checked off
yet — 65a is infrastructure the decision screen needs, not a slice of the AC itself.
`npm run ci` green (lint, dep:check ×2, both apps' localized builds, both Storybook builds,
test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift
step shows the expected pre-commit diff (this WP's own uncommitted endpoint).
## Progress notes (65b — done)
**Backend (`POST /beoordeling/{id}/besluit`):** runs against `ApplicationStore` directly
(not the `IZaakSource` seam) — same reasoning as 65a's GET: a new seam method would force
an `OpenZaakZaakSource` write now, which stays WP-66's surface. A new `Besluit` enum
(`Goedkeuren | Afwijzen | MeerInfoOpvragen`) backs a nullable `Aanvraag.BesluitStatus` +
`Aanvraag.BesluitToelichting` column pair (EF migration `BesluitStatus`). Like every other
enum in this backend, `Besluit` never crosses the wire as a raw C# enum — no
`JsonStringEnumConverter` is configured, so `RecordBesluitRequest.Besluit` is a plain
`string`, parsed with `Enum.TryParse` (400 on an unknown name) — the same wire convention
`AanvraagStatusDto.Tag` already established. The endpoint reuses
`BeoordelingRules.CanDecide` — the SAME function the read side's `canBesluiten` flag calls
— as the transition-legality check, so the two can never drift (409 on an illegal
transition, e.g. deciding an already-`Goedgekeurd` case again). Toelichting is required
(400) for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced server-side because
the published `AanvraagStatusDto`'s `Reden` field is non-optional on those two tags (the
FE's existing `parseBeoordelingStatus` already required it). `Mappers.ToStatusDto` gained
"a recorded decision wins" between the submit-time `Reden` check and the auto-approve
computation — the two never collide in practice (a submit-time-rejected case is already
terminal and never reaches the werkvoorraad/beoordeling screens, so no behandelaar ever
records a besluit on one). `MeerInfoGevraagd` is not terminal: `CanDecide` still allows a
further besluit afterwards, so a behandelaar can ask for info, then later approve/reject
once it arrives — the same `BesluitStatus` column is simply overwritten.
**FE:** `besluit.machine.ts` is the same single-step Editing/Submitting/Submitted/Failed
union as `change-request.machine.ts` (form-machine skill) — `Draft.besluit` stays a raw
string (parsed into the narrow `BesluitTag` union only in `validate`, "parse, don't
validate"), so the generic `SetField` reducer case needs no per-field typing gymnastics.
`besluit-form` (organism) composes `RadioGroupComponent` (the three actions) +
`FormFieldComponent`/`TextInputComponent` (toelichting, plain single-line — no textarea
atom exists and this form doesn't justify adding one) — no new shared atom. On a
successful decision it emits `decided`, and `BeoordelingPage` just calls
`BeoordelingStore.reload()` — the server is the authority on the new status, the page
never guesses it. The form only renders when the server's `canBesluiten` flag is true
(ADR-0001: render the decision, don't recompute the lifecycle).
Re-ran the full acceptance-criteria smoke by hand against `LocalZaakSource`: created a
manual registratie case, opened it via werkvoorraad → beoordeling, recorded Afwijzen with a
toelichting (status → Afgewezen, reason shown), confirmed a further besluit on that same
case now 409s. `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, all four
projects' test suites, both apps' localized `nl`+`en` builds, backend `dotnet format
--verify-no-changes` + `dotnet test` — 201/201 including this WP's 5 new tests, api-client
regenerated). One pre-existing, unrelated finding: `format:check` was already red on this
branch before this session touched anything — `docs/project/backlog/README.md` has a
long-standing prettier drift (a big markdown table) untouched by this WP; fixed the same
class of drift in this file's own body (`*computed*``_computed_`) since this WP was
already editing it, left `README.md` alone as out of scope.
## Acceptance criteria
- [x] A medewerker can view one aanvraag's detail and record a decision that advances its
status.
- [x] Illegal transitions are rejected server-side (tested).
- [x] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back
in the queue.
- [x] `npm run ci` (behandelportal app) + `dotnet test` green.
## Verification
Manual/automated smoke test of the full werkvoorraad → beoordeling → besluit flow against
`LocalZaakSource`; `npm run ci`; `cd backend && dotnet test`.
## Out of scope
Writing the decision to real OpenZaak (WP-66).
## Risks
This is the largest FE slice in Phase 11 — if it feels too big at kickoff, split
detail-view (read) from decision-recording (write) into two sessions; the WP as scoped
already keeps them in one slice because a detail view with no decision action isn't
independently useful for a caseworker.
Depends on: WP-64.
@@ -0,0 +1,83 @@
# WP-66 — Wire the decision into OpenZaak
Status: done (d996ca2)
Phase: 11 — Behandelportal
## Why
WP-65's decision currently only updates local state (`LocalZaakSource`). For the
behandelportal to actually function against a real register, the recorded decision needs
to also write a besluit/status transition to ZGW — extending the write capability that
already partially exists (`CreateStatusRequest`, `CreateRolRequest` in
`OpenZaakZaakSource.cs`) rather than building a new ZGW client from scratch.
## Read first
- `backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs` (existing write records)
- ZGW's Besluiten API (referenced in `openzaak-integration.md` if covered, or the ZGW
standard docs) for besluit creation
- [WP-50](WP-50-openzaak-create-zaak.md) (the first ZGW write slice, for the pattern to
follow)
## Decisions (pre-made, don't relitigate)
- Extend `IZaakSource`/`OpenZaakZaakSource` with a besluit/status-transition write,
following the same pattern WP-50 established for create-zaak (a records + mapper
addition, not a new abstraction).
- Gated by `Zgw:Enabled` like every other ZGW write — the behandelportal keeps working
against `LocalZaakSource` when it's off.
- Best done after Phase 10's WP-60 (write-divergence resilience) lands, since this is
exactly the second write pair that resilience work should already cover — but not
strictly blocked on it if Phase 10 is still in progress (call out the residual risk
explicitly if shipped first).
## Files
`Zgw/OpenZaakZaakSource.cs` (besluit/status write), `Data/IZaakSource.cs` (new write
method), tests.
## Steps
1. Add the besluit/status-transition write to `OpenZaakZaakSource`, mirroring WP-50's
create-zaak pattern.
2. Wire WP-65's decision command to call it when `Zgw:Enabled=true`.
3. Integration test against the WP-54 harness (extend `OpenZaakIntegrationTests`).
## Acceptance criteria
- [x] A recorded decision writes a real besluit/status transition to OpenZaak when
`Zgw:Enabled=true`. Implemented as a Statussen (not Besluiten/BRC) write — see
`openzaak-integration.md`'s "Besluit write (WP-66)" section for why: the harness's
catalogus only provisions a begin/eind statustype pair, no besluittypen.
- [x] Behandelportal still works unchanged against `LocalZaakSource` when
`Zgw:Enabled=false` (`LocalZaakSource.RecordBesluit` is a no-op).
- [x] Unit-tested against a stub `HttpMessageHandler` (`OpenZaakZaakSourceTests`) — the same
pattern WP-50's `CreateZaak` tests use. Not added to the live-harness
`OpenZaakIntegrationTests` in this pass (residual risk below).
## Verification
`cd backend && dotnet test --filter Category=Integration` against the (ideally
Phase-10-hardened) OpenZaak harness; manual smoke with `Zgw:Enabled=true`.
## Out of scope
Any further behandelportal screens beyond beoordeling.
## Risks
WP-60 (write-divergence resilience) has landed: bounded retry lives in `ZgwHttpClient`, so
this write pair inherits it automatically. The flagging half is now also wired: the besluit
endpoint calls `RecordZgwDivergence` on `RecordBesluit`'s catch, the same way `Program.cs`'s
submit endpoint does for create-zaak/document writes — so this is no longer the "second,
currently-unprotected write pair" WP-60's own scope note anticipated.
**Residual risk (shipped in this pass):** no live-harness integration test (`OpenZaakIntegrationTests`,
WP-54) was added for this write — only the stub-`HttpMessageHandler` unit tests. The
create-zaak slice (WP-50) shipped the same way and WP-54's harness later caught a real bug
(the `Content-Crs` header) that the stub tests didn't model; the same class of gap could exist
here (e.g. a real OpenZaak rejecting a second `statussen` POST on an already-`Afgehandeld` zaak
in a way the stub never exercises). Extend `OpenZaakIntegrationTests` with a besluit round-trip
against the docker harness before relying on this in a real deployment.
Depends on: WP-65.
@@ -0,0 +1,137 @@
# WP-67 — Merge behandelportal into this repo as a monorepo
Status: done
Phase: 11 — Behandelportal
## Why
WP-61 bootstrapped `behandelportal` as a **separate sibling repo**, per ADR-0002's original
"separate frontend application" reading taken literally as "separate git repository." That
produced real, measured friction: a hand-vendored, manually-kept-in-sync copy of the
backend's OpenAPI doc instead of a live-generated one; `shared/ui`+`shared/layout` forked at
WP-61 and already silently diverging by the time this WP checked (7 files differed); a
`beheer` (admin/stamdata) context and the `styles.scss` token bridge duplicated byte-for-byte
across both repos; a second CI/lint/CLAUDE.md to hand-maintain. The user asked to collapse
this into one repo so the two apps share one CI, one shared UI library, and one generated API
client — the standard monorepo payoff, now that a second real frontend exists.
## Read first
- [ADR-0002](../reference/architecture/0002-user-groups-and-bounded-contexts.md) — its
"Amendment (WP-67)" section records exactly what changed and why the underlying
actor/bounded-context reasoning didn't.
- [dependencies.md](../reference/architecture/dependencies.md) — the per-app
dependency-cruiser split this WP introduced.
- `behandelportal-bootstrap` memory (prior sessions) for WP-61/62's own decisions.
## Decisions (made during this WP, not pre-made — see the plan file for the questions asked)
- **Unify** `libs/shared` into one library both apps import (not two copies) — the user's
explicit call, given the two trees had already started diverging.
- Restructure into **`apps/ssp/` + `apps/behandelportal/`** (not an Angular-CLI `projects/`
addition next to an untouched `src/app/`) — the user's call, accepting the larger diff.
- **Leave** the old sibling repo (`/home/eho/repos/behandelportal`) untouched — no deletion.
- Bring the content in as a **fresh commit**, no git-history import — that repo is itself a
fork of this repo's own pre-WP-61 history, so a subtree merge would fight to reconcile two
copies of the same ancestor commits for one commit's worth of real new content.
- **Course corrections found during execution** (squarely within "unify shared," not
separately asked): `libs/beheer` (admin/stamdata — already identical in both apps, not
actor-specific) and `libs/shared/styles.scss` (the token bridge, also byte-identical) were
folded in alongside `libs/shared/ui`. `auth` was deliberately **not** unified — ADR-0002
models it as actor-specific (different `Principal` variants), so today's accidental
similarity is expected to diverge, not something to force together.
- **Two Storybook instances, not one** (`.storybook-ssp/`, `.storybook-behandelportal/**`) —
a structural necessity, not a simplification choice: both apps' `auth` (and other) context
aliases share the name `@auth/*` but resolve to different physical directories, so no
single tsconfig can compile both apps' stories in one pass.
- Each app's own `shell/nav.config.ts` supplies its primary nav + admin links to the shared
`SiteHeaderComponent` via two new injection tokens (`HEADER_NAV_ITEMS`,
`HEADER_ADMIN_LINKS`) rather than the component hardcoding one app's routes — the same
"shared component takes copy as an input, the domain caller supplies it" idiom CLAUDE.md
already used for `shared/ui` copy, extended to injection tokens for this DI-shaped case.
The dev-only "state" panel (`DebugStateComponent`) moved out of `libs/shared` into
`apps/ssp` entirely (it's coupled to `BigProfileStore`, an ssp-only store) and is now hosted
by the shared `ShellComponent` via a `DEBUG_PANEL` injection token — no provider, no panel.
## Files
Nearly the whole repo, mechanically (`git mv`), plus:
- `angular.json` — rewritten for 4 projects: `ssp`, `behandelportal` (real apps),
`shared`, `beheer` (library projects whose only real purpose is giving `ng test` a
`buildTarget` to satisfy — see `libs/*/src/test-entry.ts`'s comment).
- `tsconfig.json` (root, no more `paths` — see its comment) + a new `tsconfig.json` per
app/library declaring that project's own full alias map.
- `.dependency-cruiser.base.js` (rule factory) + `.dependency-cruiser.ssp.js` +
`.dependency-cruiser.behandelportal.js` (replacing the single `.dependency-cruiser.js`).
- `eslint.config.mjs`, `package.json` scripts, `docker-compose.yml`, `.github/workflows/ci.yml`,
`scripts/ci-local.sh`, `scripts/check-tokens.sh`, `scripts/gen-snippets.mjs`,
`scripts/serve-i18n.mjs`, `scripts/dep-graph.sh`, `plopfile.mjs`, `nswag.json` — all
re-pointed at the new paths / made per-app aware.
- `CLAUDE.md`, `ARCHITECTURE.md`, `dependencies.md`, ADR-0002 — updated for the new layout.
## Steps
1. Branch, then `git mv` the SSP's `src/app/*` (minus `shared/`) → `apps/ssp/src/app/`, plus
`main.ts`/`index.html`/`locale/`/`proxy.conf.json`/`tsconfig.app.json`/`tsconfig.spec.json`.
2. `git mv src/app/shared``libs/shared/src`; `git mv src/app/beheer``libs/beheer/src`;
fold in `environments/` and the Storybook `docs/*.mdx` (both found byte-identical between
the two repos — same treatment as `shared/ui`).
3. Copy (not `git mv` — a different repo) behandelportal's `auth`/`behandeling` +
`main.ts`/`index.html`/`locale/` into `apps/behandelportal/src/`.
4. Rewrite `angular.json` for the two real projects + two library test-only projects; fix
every relative import that broke (`environments/environment``@shared/environments/environment`;
MDX docs' `../app/shared/...``../src/...`).
5. Split `.dependency-cruiser.js` into a base factory + one config per app; broaden
`eslint.config.mjs`'s `files` glob.
6. Rewrite `package.json` scripts, `docker-compose.yml` (added a `web-behandelportal`
service), `.github/workflows/ci.yml` + `scripts/ci-local.sh` (both apps built/tested,
path filters widened), the four `scripts/*.mjs`/`*.sh` helpers, `plopfile.mjs`'s three
generators (value-object/form-machine paths now resolve `shared`/`beheer` to `libs/`, the
`context` generator's tsconfig/dep-cruiser/routes edits re-anchored on the ssp config).
7. Split `.storybook/` into `.storybook-ssp/` + `.storybook-behandelportal/` (forced by the
`@auth/*` alias collision); moved the CIBG token bridge (`styles.scss`) and the generated
`documentation.json` to avoid two more collisions.
8. Add `HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`/`DEBUG_PANEL` injection tokens to
`SiteHeaderComponent`/`ShellComponent`; give each app its own `shell/nav.config.ts` and
(ssp only) `shell/debug-state/`.
9. Update `CLAUDE.md`, `ARCHITECTURE.md`, `dependencies.md`, and amend ADR-0002.
## Acceptance criteria
- [x] `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, test:coverage ×4
projects, `ng build --localize` ×2, npm audit, backend test, snippets drift,
api-client drift).
- [x] Both apps' dev servers run against the one shared backend
(`npm start` / `npm run start:behandelportal`, ports 4200/4201, backend on 5000).
- [x] Both apps' production + localized builds succeed.
- [x] Both Storybook instances build (`build-storybook[:behandelportal]`).
- [x] `npm run gen:api` generates the one client into `libs/shared` with zero drift.
- [x] `docker compose up` serves both apps + the shared backend.
## Verification
`npm run ci`; `npx ng build ssp --localize && npx ng build behandelportal --localize`;
`npx ng run ssp:build-storybook && npx ng run behandelportal:build-storybook`; manual:
`npm start` on :4200 and `npm run start:behandelportal` on :4201 against
`dotnet run --project backend/src/BigRegister.Api`, log in on both.
## Out of scope
- Deleting or archiving the old sibling repo (`/home/eho/repos/behandelportal`) — left
untouched per the user's explicit choice.
- `scripts/create-frontend.mjs` / the `new-ssp` skill (bootstrapping a _third_ portal as a
fresh standalone repo) — whether future portals should also join this monorepo is a
separate decision.
- Extending the `storybook-a11y` CI job to behandelportal's own Storybook instance — it
still only covers ssp's, unchanged from before this WP.
- Reconciling `auth` between the two apps — left duplicated, deliberately (see Decisions).
## Risks
The `libs/shared`/`libs/beheer` "library" Angular projects exist solely to give the
unit-test builder a `buildTarget` to resolve (`@angular/build:unit-test` always requires
one, even for a project that's only ever tested) — `test-entry.ts` + `tsconfig.app.json` in
each are a `ponytail:`-flagged workaround, not a real buildable/publishable library. If
either library ever needs to actually build (e.g. an ng-packagr distributable), replace this
with a real library target then.
@@ -0,0 +1,337 @@
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
Status: done (a394950..472a49f)
Phase: 12 — DDD hardening
## Why
An architecture review on 2026-08-05 (bounded contexts, aggregates, CQRS, DDD/BDD test
alignment, measured against this repo's own documented pattern) found the context boundaries,
the FP/TEA idioms and the read/write separation to be sound — and found four defects clustered
in one place: **the backend's aggregate roots do not guard their own invariants, and the
aanvraag status lifecycle is a computed string living in the contracts layer.**
The four in this WP, in dependency order:
- **F1 — `submit` links client-supplied `documentId`s with no ownership check.**
`Program.cs:353-356` takes document ids straight from the request body and hands them to
`ApplicationStore.Submit` and `documents.LinkToZaak`; `DocumentStore.Link` has no `owner`
parameter and performs no check (`DocumentStore.cs:113-125`). Same for `SyncDraft`
(`Program.cs:317`). A caller who knows a foreign document GUID can attach another citizen's
upload to their own aanvraag — where it appears on the behandelaar's beoordeling screen with
its filename (`Program.cs:434`) and is POSTed to OpenZaak as a zaakinformatieobject on
_their_ zaak — and flips the victim's `Linked = true`, which permanently blocks the victim's
own delete (`DeleteOwned``DeleteResult.Linked`). ADR-0001 is explicit that the FE holds
no authority; this trusts it anyway.
- **F3 — the aanvraag status lifecycle is a computed string in `Contracts/`.** Three
compounding facts: the status is derived in `Contracts/Mappers.ToStatusDto`
(`Mappers.cs:44-63`), not in the domain; `Concept` is **not** a member of
`AanvraagStatusTag` (`ApplicationStore.cs:14`) but a magic string the mapper emits; and the
write path reads its own guard back out of the read DTO —
`a.ToStatusDto(now).Tag` → compare `"Concept"``Enum.Parse<AanvraagStatusTag>`
(`Program.cs:466-468`). This violates the repo's non-negotiable #3 ("make illegal states
unrepresentable") on the backend's most important type: the status is
`enum + one string that is not in the enum`, so `Enum.Parse` is a runtime throw waiting for
a new tag. It is also the one genuine CQRS symptom in the codebase — a command deriving its
invariant from a read projection — and it is _why_ F2 exists: there is no domain object that
could have owned the guard.
- **F2 — the besluit invariant is checked outside the write transaction.**
`Program.cs:469` calls `BeoordelingRules.CanDecide`; the write happens later in
`ApplicationStore.RecordBesluit` (`ApplicationStore.cs:278-291`), which takes the lock and
assigns unconditionally. Two concurrent besluiten both pass the check and both write, so the
second silently overwrites a terminal decision the rule exists to freeze. The codebase
already documents the correct pattern three methods earlier — `CreateConcept`: _"Race-free:
the existence check and the insert share the single write gate."_ This is an internal
inconsistency, not a missing concept.
- **F6 — a besluit rule with no home in `Domain/`.** "Toelichting verplicht bij Afwijzen /
MeerInfoOpvragen" lives inline at `Program.cs:473`, although `BeoordelingRules`' own
doc-comment says the decision-recording rules were meant to land there. It therefore has no
unit test, only the endpoint test `Afwijzen_requires_a_toelichting`.
Plus one documentation correction (**F5**, see Decisions — the enforcement itself is deferred
to WP-69, because it needs a wire change).
The review's remaining findings are listed under "Follow-ups" and are **not** this WP's scope.
## Read first
- `CLAUDE.md` §"The decisions" #3 (make illegal states unrepresentable) and #4 (BFF-lite)
- [ADR-0001 — BFF-lite + decision DTOs](../../../reference/architecture/0001-bff-lite-decision-dtos.md)
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (the `Aanvraag` entity, the store's
lock discipline, `AanvraagStatusTag`, `RecordBesluit`)
- `backend/src/BigRegister.Api/Contracts/Mappers.cs` (`ToStatusDto` — the logic to move)
- `backend/src/BigRegister.Api/Program.cs` lines 300-500 (draft sync, submit, beoordeling GET,
besluit POST)
- `backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs` (**the second producer of the status
DTO** — easy to miss)
- `backend/src/BigRegister.Api/Data/DocumentStore.cs` (`Link`, `DeleteOwned`, the existing
`DeleteResult` enum this WP copies)
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`
## Prerequisite
**Commit or stash the working tree first.** At review time it carried the WP-66 id-mismatch fix
across 11 modified files plus the untracked `backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs`.
Do not start a cross-cutting refactor on top of uncommitted work.
## Decisions
Pre-made — do not relitigate.
### F3 — the status type
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`).
**`ApplicationStore.ProcessingWindow` stays where it is.** The original text here said to
move it too "because `StatusAt` needs it" — but `StatusAt` is an instance method on
`Aanvraag`, itself defined in `ApplicationStore.cs`, so it already sits in the same file/
namespace as `ProcessingWindow` and can reference it directly with no cross-namespace
issue. Moving it would have been motion without a reason, and — found only once
implementation started — `ApplicationTests.cs` references `ApplicationStore.ProcessingWindow`
directly in two tests this WP's own acceptance criteria require to stay **unmodified**;
moving the constant would have forced a choice between breaking that criterion or adding a
forwarding shim for no gain. Leave it.
2. **`AanvraagStatusTag` is NOT given a `Concept` member — implemented differently, deliberately.**
The original text said to add `Concept` as the first member. That directly conflicts with
this WP's own acceptance criterion that `AanvraagStatusTag_covers_the_published_lifecycle`
(which asserts `Enum.GetNames<AanvraagStatusTag>()` equals exactly the five published-lifecycle
names) passes **unmodified** — adding a sixth name breaks it. Found only once implementation
started; resolved in favor of the harder constraint (the regression-net test) and a cleaner
design: **`AanvraagStatus.Tag` is `AanvraagStatusTag?`, null exactly for Concept.** This
still closes the actual finding (a magic string with no corresponding enum member,
round-tripped through the DTO and `Enum.Parse`d) without touching the enum the test pins,
and without the reduce-only "boolean + tag" shape rule #3 warns against — a nullable
discriminator is the standard two-case union, not a second boolean bolted on. `Ingediend`
is unaffected by this and is still kept reserved (see below).
Keep `Ingediend` even though nothing produces it today (verified: neither `ToStatusDto` nor
`ZgwZaakMapper` emits it) — `BeoordelingRules.CanDecide` accepts it, the FE's
`BeoordelingStatus` union declares it, `statusLabel` has a `$localize` id for it, and
`Only_open_statuses_are_decidable` tests it. Deleting it would ripple into
`messages.en.xlf`. Mark it reserved with a comment instead.
3. **New `Domain/Applications/AanvraagStatus.cs`**: a `sealed class` (not a `record` — no
external mutation via `with` is wanted, and record value-equality/`ToString` boilerplate
buys nothing for a short-lived read model) carrying `AanvraagStatusTag? Tag` (null =
Concept) plus the same optional payload fields the DTO has (`StepIndex`, `StepCount`,
`Referentie`, `Manual`, `Reden`), constructed **only** via static factories —
`Concept(stepIndex, stepCount)`, `InBehandeling(referentie, manual)`,
`Goedgekeurd(referentie)`, `Afgewezen(referentie, reden)`,
`MeerInfoGevraagd(referentie, reden)`.
**Rejected: a full abstract-record union** (one subrecord per tag). It is the purer
modelling, but it forces exhaustive switches at four call sites and a per-case mapper for a
marginal gain over "the factories are the only construction path". Not worth the diff here.
4. **`Aanvraag.StatusAt(DateTimeOffset now)`** — an instance method on the entity carrying the
logic currently in `ToStatusDto` **verbatim**, including the "a recorded decision wins over
the auto-approve computation" ordering.
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`, via a shared
`Mappers.ToDto(this AanvraagStatus s)` extension (also used by `ZgwZaakMapper` — see below,
point 7 — so both status producers agree on one projection):
`new(s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
6. **`AanvraagStatusDto` is unchanged — `Tag` stays a `string`.** This is the safety property
that makes F3 an internal refactor: **no wire change, no `gen:api` drift, no frontend
change, no `messages.en.xlf` change.** Do not "improve" the DTO in this WP.
7. **`ZgwZaakMapper` is the second producer** and must be converted too, or the string literals
survive: `ToSummaryDto` and `ToCreatedStatusDto` build `AanvraagStatus` via the factories and
project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →
`InBehandeling` with `Manual: true`; closed → `Goedgekeurd`) — `ZgwZaakMapperTests` is the net.
8. **The besluit endpoint stops going through the DTO**: `var status = a.StatusAt(now);`
compare `status.Tag == AanvraagStatusTag.Concept`, pass `status.Tag` to `CanDecide`. The
`Enum.Parse` at `Program.cs:468` is deleted.
9. **One `Enum.Parse` may remain** — the beoordeling GET at `Program.cs:438`, which parses a tag
off a DTO returned by the `IZaakSource` seam. That is a genuine wire→domain trust boundary,
not a smell. Keep exactly one, make it non-throwing for an unknown tag, and comment it as the
seam boundary. **Changing `IZaakSource` to return domain types is out of scope.**
### F2 — the besluit guard
`ApplicationStore.RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)`
returns `(RecordBesluitOutcome Outcome, Aanvraag? Aanvraag)` with
`enum RecordBesluitOutcome { Ok, NotFound, Conflict }` — mirroring the existing
`DocumentStore.DeleteResult` precedent rather than inventing a new result idiom. Inside the
lock: find, `StatusAt(now)`, `CanDecide``Conflict` if refused, then write. **The endpoint
drops its own pre-check** and maps the outcome to 200/404/409, so there is one source of truth
for the transition. The endpoint keeps its id-resolution and its `Concept` → 404 (both need the
`IZaakSource` lookup the store cannot see).
### F1 — document ownership
New `DocumentStore.ForeignIds(IEnumerable<string> ids, string owner)` returning the ids that do
**not** resolve to a document owned by `owner` (returning the offending ids, not a bool, so the
ProblemDetails can name them). Called in `POST /applications/{id}/submit` **before** any write,
and in the draft-sync endpoint (`Program.cs:317`); non-empty → 400 ProblemDetails.
Endpoint-level check only. `IDocumentSource.LinkToZaak` keeps its current signature (two
implementations, and the endpoint has now validated its input) — add a comment saying so.
"A document already linked to a different aanvraag of the same owner" is **not** covered here;
note it as a follow-up, do not build it.
### F5 — narrowed to a doc fix
`IntakePolicy`'s XML doc-comment claims _"the backend re-validates on submit as the
authority"_. It does not: the constant's only consumer is `Program.cs:155`, which echoes it, and
both submit paths apply `SubmissionRules.RejectZeroUren` only. Verified cause: **neither
`SubmitApplicationRequest(DiplomaHerkomst, Uren, Documents)` nor `IntakeRequest(int Uren)`
carries a scholing answer at all**, so the server cannot re-validate without a contract change,
and the wizard's answers (`scholingGevolgd`, `punten``intake.machine.ts:26,37`) never reach
it. Reading them out of the opaque `Draft` JSON is rejected: the backend's documented posture is
that the draft is opaque (`AppDbContext` header comment).
**In this WP: correct the doc-comment to state the gap, and nothing else.** The enforcement is
WP-69 (a real FE+BE slice: request fields, `IntakePolicy.RejectMissingScholing`, wizard payload,
`gen:api`).
## Files
- `Domain/Applications/AanvraagStatus.cs` (new — tag enum, `Besluit`, `ProcessingWindow`, the
status record + factories)
- `Data/ApplicationStore.cs` (`Aanvraag.StatusAt`, `RecordBesluit` signature + in-lock guard,
enums moved out)
- `Contracts/Mappers.cs` (`ToStatusDto` reduced to a projection)
- `Zgw/ZgwZaakMapper.cs` (both producers converted)
- `Data/DocumentStore.cs` (`ForeignIds`)
- `Domain/Beoordeling/BeoordelingRules.cs` (`RequiresToelichting`)
- `Domain/Intake/IntakePolicy.cs` (doc-comment only)
- `Program.cs` (submit + draft-sync ownership checks; besluit endpoint simplified)
- `tests/BigRegister.Tests/``RuleTests.cs` (new `AanvraagStatusTests` nested class +
`RequiresToelichting`), `ApplicationTests.cs` (ownership), `BeoordelingTests.cs` (concurrency)
No migration: no persisted column changes (`BesluitStatus` already stores `Besluit`, whose
member names are unchanged).
## Steps
1. Commit/stash the WP-66 working tree (see Prerequisite).
2. **F1**`DocumentStore.ForeignIds` + the two endpoint checks + tests. Independent of the
rest; land it first so the correctness fix is not blocked by the refactor.
3. **F3** — the status type, in Decisions order 1→9. `dotnet test` green with
`AanvraagStatusTag_covers_the_published_lifecycle`,
`AutoApprovable_flips_to_goedgekeurd_after_the_window` and `ZgwZaakMapperTests` **unchanged**
— those three are the regression net for the refactor.
4. **F2**`RecordBesluitOutcome`, guard moved inside the lock, endpoint maps the outcome.
5. **F6**`BeoordelingRules.RequiresToelichting` + unit test; endpoint calls it.
6. **T3** — the lifecycle spec that F3 makes expressible: one `[Theory]` over
(status × besluit) → allowed/denied, asserting among others that Afgewezen → Goedgekeurd is
refused as a _domain_ statement, not only at the endpoint.
7. **F5** — correct the `IntakePolicy` doc-comment; open WP-69 for the enforcement.
8. Run the full gate (see Verification).
## Acceptance criteria
- [x] Submitting (or draft-syncing) an aanvraag with a `documentId` owned by another citizen is
rejected with 400, and the other citizen's document remains deletable
(`DeleteResult.Ok`). (`Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner`,
`Draft_sync_with_a_foreign_documentId_is_rejected`.)
- [x] `AanvraagStatusTag` does NOT contain `Concept` — implemented instead as
`AanvraagStatus.Tag` being `AanvraagStatusTag?`, null exactly for Concept (see Decisions
§F3.2 for why this replaced the original "add Concept to the enum" instruction). No
_internal domain_ code compares a status against the `"Concept"` string; the one
remaining comparison (`Program.cs`'s beoordeling GET, against `IZaakSource`'s wire DTO)
is the deliberate wire-boundary exception, paired with the one allowed `Enum.TryParse`
below.
- [x] `Enum.Parse`/`TryParse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at
the `IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown
tag (`Enum.TryParse` there, not `Enum.Parse`).
- [x] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
- [x] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
- [x] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
`libs/shared/src/infrastructure/api-client.ts` beyond F1's new 400 responses (verified —
the only diff after F3 is the two `.ProducesProblem(400)` blocks F1 added; proof F3
changed no wire shape).
- [x] Two concurrent `POST /beoordeling/{id}/besluit` racing on the same still-open aanvraag
yield exactly one 200 and one 409; the persisted status matches whichever request won
(`Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success`, stable across 5
repeated runs).
- [x] `BeoordelingRules.RequiresToelichting` exists, is unit-tested
(`Only_a_non_approval_requires_a_toelichting`), and is the only place the rule lives.
- [x] A `[Theory]`/aggregate-level test covers the transition table
(`A_terminal_decision_refuses_any_further_besluit`,
`MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal` — via
`Aanvraag.StatusAt` + `BeoordelingRules.CanDecide`, not just a bare-tag `[Theory]`, since
`CanDecide` doesn't vary by which besluit is attempted — see Decisions for why a literal
status×besluit cross-product theory would have been redundant with
`Only_open_statuses_are_decidable`).
- [x] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists
(`docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md`).
## Verification
```bash
cd backend && dotnet test # while iterating
npm run gen:api && git diff --exit-code backend/swagger.json libs/shared/src/infrastructure/api-client.ts
npm run ci # the full gate before pushing
npm run e2e # after F1/F2/F3 — needs the backend + `npm start` running
```
The three existing tests named in step 3 must pass **unmodified**; if a refactor step needs one
of them changed, the refactor changed behaviour and is wrong.
**Result:** `npm run ci` passed fully green — lint, format:check, check:tokens, all four test
suites, both localized builds, `npm audit`, backend `dotnet format`+`dotnet test` (216 passing,
up from 207 at the start of this WP), snippet-generator drift, and API-client drift (only F1's
new 400 responses; F3 shows zero additional wire diff, per acceptance criteria). `npm run e2e`
could **not** be verified in this session: port 4200 was already occupied by an unrelated
container (`team-monitor-web-1`, a different repo) that Playwright's local `reuseExistingServer`
reused as if it were this app, so every test timed out waiting for a `BSN` field that container
doesn't have — a pre-existing local port collision, not a regression (nothing in this WP touches
ports/docker), and per CLAUDE.md's GREEN definition `npm run e2e` isn't part of the local GREEN
gate regardless. Free port 4200 (or set `E2E_BASE_URL`) and re-run `npm run e2e` to close this
out if end-to-end confirmation is wanted.
## Out of scope
Deliberately excluded — each is a separate WP if wanted:
- **F4** — backend layer enforcement. `Domain/Beoordeling/BeoordelingRules.cs` and
`Domain/Authorization/Authz.cs` import `BigRegister.Api.Data` (and `Authz` also
`.Contracts`, returning `BriefDecisionsDto`), with nothing in CI checking direction — the FE
has `dep:check`, the backend has only `dotnet format` + `dotnet test`. This WP's step 3
removes the `BeoordelingRules` violation as a side effect; the `Authz` one and the ~6-line
reflection convention test are WP-70.
- **F5 enforcement** → WP-69 (see Decisions).
- **F7**`ApplicationStore.Submit` and `DocumentStore.Link` take separate locks with no
transaction and no compensation; a link failure leaves a submitted aanvraag whose documents
are still deletable. Same failure class WP-60 closed for ZGW and left open locally. Fix is to
route it through the existing divergence flag + audit row, not to merge the aggregates.
- **F8** — pushing invariants from the static stores onto `Aanvraag` as instance methods
(`TryRecordBesluit`). This WP does the two that matter; the general move can wait.
- **F9**`Authz` spans five contexts and its four admin gates are byte-identical
`role == Admin` checks with **no direct unit test** and no test denying `Approver`.
- **F10** — splitting `Program.cs` (917 lines, 50 endpoints). **Deliberately deferred and
flagged as risky:** `OrgAdmin`, `StamdataAdmin`, `Beoordelen`, `Submit` and `AuditAuthz` are
non-static **local functions** (`Program.cs:756+`) that every endpoint lambda closes over, so
splitting means converting all of them to static helpers with explicit dependencies across
all 50 registrations — with the deliberate authz ordering (Forbidden before Conflict) as the
thing that breaks silently. Lowest value of the review's findings; do it alone, with tests as
the net, or not at all.
- **F11** — three FE adapter fetch idioms; two loaders `throw` instead of returning `Result`;
`runSubmit` (which mints an `Idempotency-Key`) is used for **reads** in `brief.adapter.ts:56`,
`org-template.adapter.ts:39,51`, `stamdata.adapter.ts:27,42`. Fix is `runQuery`/`runCommand`
over one shared try/catch, ~10 lines.
- **T2** — ~54 FE `it()` titles are named after `Msg` tags (`'SetField updates the draft'`,
`'SubmitConfirmed maps Submitting to Submitted'`), against `bdd.mdx` rule 3. Titles only.
- **T5** — named coverage gaps: `OrgTemplateRules.RejectDraft` (both identity branches, no
margin boundary test), the four `Authz` admin gates, `DocumentRules.CategoriesFor`'s
`herregistratie`/`org-template` branches, `SubmissionRules.NewReference`, FE
`isStatusConsistent` (tested on the backend, never on the FE), the FE herregistratie window
boundary, and the FE/BE margin constants which mirror each other with no contract test.
- **T6** — trust-boundary `describe` naming has three dialects; 7 `parse*` specs use none.
- **ADR-0006 "CQS without CQRS"** — the review's learning deliverable: the read/write
separation already present, why the emit-and-enforce rule (one function feeding both the
decision flag and the enforcement) makes a read/write stack split actively harmful here, and
WP-60's deferred outbox as the documented trigger that would change the answer. Prose only,
no runtime code.
- Anything CQRS-mechanical: MediatR, handler classes, a separate read store, event sourcing,
repositories/unit-of-work, Gherkin/Reqnroll. All explicitly rejected by the review.
## Risks
- **Scope creep on F3.** The temptation is to "fix" `AanvraagStatusDto` into a proper wire union
while in there. That turns a zero-diff internal refactor into an FE + `messages.en.xlf` +
`gen:api` change. The acceptance criterion "`gen:api` leaves no diff" exists to catch it.
- **Missing the second producer.** `ZgwZaakMapper` is easy to overlook because it lives under
`Zgw/`, not `Contracts/`. If it is missed, the string literals survive and the finding is only
half fixed.
- **Over-modelling.** A full abstract-record status union, or a repository/unit-of-work layer to
"properly" own the aggregate, would be a bigger diff than the defects justify — see Decisions.
@@ -0,0 +1,226 @@
# WP-69 — Enforce the scholing threshold server-side
Status: done (5d73ca2)
Phase: 12 — DDD hardening
## Verification result (2026-08-18)
Backend 230 → 245 tests (+8 `Domain/IntakeRuleTests`, +7 `Acceptance/IntakeSubmissionTests`);
frontend ssp 238 → 242. `npm run ci` green incl. `check:seam` (`OK … (1000)`), api-client and
behaviour-spec drift.
**The bypass was proven closed against a running backend, not just by green tests:**
`POST /api/v1/intakes {"uren": 500}` with no scholing fields → **400** with the Dutch detail;
an intake-typed Concept submitted via `POST /applications/{id}/submit {"uren": 500}`**400**,
and `GET /applications/{id}` afterwards still reports `"tag":"Concept"` (no state change, still
retryable); the same request plus `"aanvullendeScholing": false`**200** with a referentie and
`InBehandeling`.
**Deviation from the Files list:** `EndpointTests.Worked_hours_submission_succeeds` had to be
touched despite being on the do-not-modify list — it posts `{ uren: 40 }` with no scholing
answer, which _is_ the crafted-POST bypass this WP closes, so the existing test was itself
asserting the vulnerable behaviour. Fixed minimally by adding `aanvullendeScholing = false`
(unknown to and ignored by `HerregistratieRequest`, so the paired `/herregistraties` row is
unaffected). The zero-hours 422 rows — the ordering regression net — are unmodified as planned.
## Why
WP-68 (F5) found that `IntakePolicy`'s doc-comment claimed _"the backend re-validates on
submit as the authority"_ — it doesn't. `GET /intake/policy` only echoes `ScholingThreshold`;
neither `SubmitApplicationRequest` (`DiplomaHerkomst`, `Uren`, `Documents`) nor `IntakeRequest`
(`Uren`) carries a scholing answer at all, so there's nothing for the server to re-validate.
Both submit paths apply only `SubmissionRules.RejectZeroUren`. A crafted POST — bypassing the
wizard entirely — can skip the scholing requirement (`scholingGevolgd`/`punten` in
`intake.machine.ts`) even though it's presented as mandatory in the UI. ADR-0001's canonical
"config value" example (the FE applies the threshold for instant feedback, the backend
re-validates as authority) is unenforced for the one rule it was written to illustrate.
## Read first
- `backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs` (the corrected doc-comment,
WP-68)
- [ADR-0001 — BFF-lite + decision DTOs](../../../reference/architecture/0001-bff-lite-decision-dtos.md)
§"config value"
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (`lageUren`, `scholingGevolgd`,
`punten` — the wizard's existing FE-side rule and its answers)
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` (`SubmitApplicationRequest`,
`IntakeRequest`, `DocumentRefDto`)
- `backend/src/BigRegister.Api/Program.cs` — the `intakes` and `applications/{id}/submit`
endpoints
## Stale premises in the original placeholder (verified 2026-08-18)
- **"Both submit paths" is half-stale.** `POST /api/v1/intakes` is **dead from the UI** — the
wizard submits via `draft-sync``POST /applications/{id}/submit`; no code in `apps/` or
`libs/` calls the generated `intakes()`/`herregistraties()` methods. It is still a live
crafted-POST surface, so fix both; do **not** delete it here (see Risks).
- **The submit endpoint does not distinguish intake from herregistratie**
`Program.cs` lumps them: `_ /* herregistratie | intake */ => (RejectZeroUren(...), true)`.
`herregistratie.machine.ts` has no scholing question, so the new check **must** be gated on
`existing.Type == "intake"` or the herregistratie wizard starts 400-ing for every low-uren user.
- **`RejectMissingScholing(uren, scholing)` is under-specified.** The rule is three-valued
(answer present / `true` needs punten / punten without `true` is illegal); two parameters
cannot express it.
- **A live FE bug shares this rule and must be fixed here.** `intake.machine.ts:116` requires
`punten` whenever `scholingGevolgd === 'ja'` **regardless of `lageUren`**, while the template
renders both fields only inside `@if (scholingZichtbaar())` (= `lageUren`). Answer scholing
`'ja'`, then raise `uren` above the threshold: either the user is blocked by an error on an
**invisible** field, or `validateAll` emits `aanvullendeScholing: undefined` **together with**
`punten: 150` — exactly the payload the new server rule rejects. Both branches reachable today.
## Decisions
Made by a `planner` pass on 2026-08-18 — do not relitigate.
### 1. What the rule is (and deliberately is not)
The FE rule is **completeness**, not merit: below the threshold the scholing question must be
**answered**; `'nee'` is a legal answer that still submits. So the server authority is:
- `uren < IntakePolicy.ScholingThreshold` ⇒ an answer must be present;
- answer `true` ⇒ punten present and `>= 0` (mirrors `parseUren`);
- answer not `true` ⇒ punten must be **absent**.
**Out of scope, deliberately:** turning "few uren + no scholing" into an `Afgewezen` decision.
The wizard accepts that today; inventing a substantive rejection would create a _new_ FE/BE
divergence in the WP that closes one. **Boundary is `<`, not `<=`** — mirrors `lageUren`.
### 2. Wire shape
Two nullable fields appended (positionally last, defaulted) to both request records in
`Contracts/Dtos.cs`: `bool? AanvullendeScholing = null, int? ScholingPunten = null`.
- **`ScholingPunten`, not `Punten`** — `SubmitApplicationRequest` is shared by all three wizard
types and the herregistratie wizard has its own unrelated `punten`.
- **Illegal states are representable on the wire, unrepresentable past the boundary.** A JSON
DTO consumed by NSwag can't carry a union without hand-written polymorphism, and both fields
must be optional for the other wizards anyway. Closure happens at the rule boundary — the same
posture WP-68 took for `AanvraagStatus`. _Rejected:_ a nested `ScholingDto` (removes one of
three illegal combinations, adds a DTO); a closed `ScholingAnswer` type (one call site, not
persisted — ceremony).
- **Not persisted.** Submit-time rule input, not aggregate state: no `Aanvraag` column, **no EF
migration**. The draft JSON stays opaque (WP-68) — the answer arrives as an explicit field.
### 3. Rule home — `IntakePolicy`, not `SubmissionRules`
`public static string? RejectIncompleteScholing(int uren, bool? aanvullendeScholing, int? scholingPunten)`
— same "reason or null" idiom as `SubmissionRules`, so endpoints compose both identically.
The rule _is_ the threshold's enforcement and the class already owns the constant. Putting it in
`SubmissionRules` would either re-declare `1000` there (silent drift — exactly what WP-71's
`check:seam` exists to catch, and which it would **not** catch outside `IntakePolicy.cs`) or make
the generic cross-wizard class depend on one wizard's policy. `SubmissionRules.cs` and
`SubmissionRuleTests.cs` are **not modified**.
**`check:seam` constraint (load-bearing):** `scripts/check-seam.sh` greps _all_
`ScholingThreshold\s*=\s*[0-9]+` matches in `IntakePolicy.cs`. The new code must **reference**
the const (`uren < ScholingThreshold`, `$"…{ScholingThreshold}…"`) and must never introduce a
second literal (e.g. a default parameter `int scholingThreshold = 1000`) — a second match makes
`backend_value` two lines and fails with a misleading "drift" message.
### 4. HTTP shape: 400 ProblemDetails, matching WP-68 F1
A missing/contradictory conditionally-required field is a **contract violation**, not a business
outcome → `Results.Problem(detail: …, statusCode: 400)`. Deliberately unlike `RejectZeroUren`,
which is a _merit_ rejection (422 legacy / `Afgewezen` + 200 on the aanvraag path).
**Ordering: the zero-uren rejection wins.** Guard with `reject is null &&` so `{ uren: 0 }` is
decided on merit and completeness is moot — this keeps `EndpointTests`' 422 rows passing
**unmodified**. Place the check **before** the document-ownership check and
`ApplicationStore.Submit`, so a rejected submit leaves the aanvraag a Concept (retryable).
Gated on `existing.Type == "intake"`. `/applications/{id}/submit` already declares
`.ProducesProblem(400)` (WP-68 F1) — no metadata change; `/intakes` needs one added, with the
check _outside_ the `Submit(...)` helper so the 400 is not cached in `IdempotencyStore`.
Detail copy (Dutch, like all backend ProblemDetails — backend copy is not `$localize`d):
missing answer → `$"Beantwoord de vraag over aanvullende scholing: bij minder dan {ScholingThreshold} gewerkte uren is dit verplicht."`;
`true` without punten → `"Vul het aantal behaalde nascholingspunten in."`;
punten without `true``"Nascholingspunten horen alleen bij een gevolgde aanvullende scholing."`
### 5. Backwards compatibility
Fields optional on the wire, conditionally required by the rule (the same DTO serves registratie
and herregistratie, which never send them). **In-flight Concept drafts (WP-22) are unaffected**
the draft JSON already holds `scholingGevolgd`/`punten`, its format doesn't change, and the new FE
derives the request fields at submit time. The one real incompatibility is a **stale FE bundle**
submitting a below-threshold intake: it gets a 400 with an actionable Dutch detail via
`problemDetail()`. Accepted — the POC has no API versioning, and the alternatives (grace period,
inferring from the draft) are what WP-68 forbade. _Rejected:_ a feature flag whose only purpose
is to leave a security gap open.
### 6. Frontend changes
- Wizard payload gains `aanvullendeScholing` + `scholingPunten` (`undefined` members are dropped
by `JSON.stringify` and bind to `null` server-side).
- **`intake.machine.ts` needs two narrowing edits** (see Stale premises — this is a live bug):
`validateStep('werk')` requires punten only when `lageUren(…) && scholingGevolgd === 'ja'`
(matching the template's `@if`), and `validateAll` computes punten from
`aanvullendeScholing === true` rather than `scholingGevolgd === 'ja'`, so a stale answer left by
raising `uren` can't leak into `ValidIntake`. `Answers` (the raw record) is unchanged — stale
raw answers are fine; `ValidIntake`, the _parsed_ type, must be honest.
- **No change** to `SCHOLING_THRESHOLD_DEFAULT`, `lageUren`, `SetPolicy`, the policy
adapter/store, or the template. **No new `$localize` id ⇒ no `messages.en.xlf` change.**
### 7. Test plan (WP-71 conventions)
G/W/T bodies, `Domain/<Aggregate>RuleTests.cs`, `Acceptance/`, fixtures via the `Given` builder —
never hand-built initializers.
**New `Domain/IntakeRuleTests.cs`** (pure rule, no HTTP; the arguments _are_ the Given, so these
degenerate to When/Then per `bdd.mdx`): answer required below threshold; not required _at_ the
threshold (pins `<` vs `<=`); `niet gevolgd` is a complete answer (pins §1's scope); `gevolgd`
requires punten; zero punten valid; negative refused; `[Theory]` — punten without `gevolgd`
refused (two rows, incl. the stale-punten shape §6 removes).
**New `Acceptance/IntakeSubmissionTests.cs`** (HTTP, both paths, `Given.Concept(type: "intake")`
- a local `Persist` mirroring `BesluitLifecycleTests`; the builder's default owner **is**
`StubIdentityProvider`'s default caller, so no header juggling): below threshold without an
answer → 400 **and still a Concept**; answered → 200; above threshold → 200; punten without
gevolgd → 400; **herregistratie unaffected** (guards the `Type` gate); `{ uren: 0 }` still
`Afgewezen` + 200, not 400 (pins the ordering); legacy `/intakes` enforces it too.
**Not modified:** `EndpointTests.cs` (its 422 rows are the ordering regression net),
`SubmissionRuleTests.cs`, `ApplicationTests.cs`, `Builders/AanvraagBuilder.cs`.
**Frontend:** `intake.machine.spec.ts` — drops punten when raising uren hides the question; does
not require punten for a hidden question. `intake.acceptance.spec.ts` — one journey: low uren →
`'ja'` + punten → back → raise uren → submit → both fields `undefined`.
### 8. Sequencing
1. `IntakePolicy.RejectIncompleteScholing` + `Domain/IntakeRuleTests.cs` (red→green, no wire change).
2. `Contracts/Dtos.cs` + both endpoints + `/intakes`' `.ProducesProblem(400)`.
3. `Acceptance/IntakeSubmissionTests.cs`; `dotnet test`.
4. **`npm run gen:api`** — after step 2, before the FE payload change. Commit `backend/swagger.json`
- `libs/shared/src/infrastructure/api-client.ts`. CI's drift job fails if skipped/hand-edited.
5. FE: `intake.machine.ts` narrowing + specs, then the wizard payload.
6. `npm run gen:snippets` (expect no diff) and **`npm run gen:behaviour-spec`** (will diff — new
test names; commit it or CI's drift step fails).
7. Docs in the same diff: rewrite `IntakePolicy`'s doc-comment from "gap deferred to WP-69" to what
it now guarantees; one line in ADR-0001 §"config value"/worked example B; `backend/README.md`'s
`/api/intakes` row (add the 400) + its `IntakePolicy.cs` bullet.
8. `npm run ci`.
## Out of scope
- Turning "few uren + no scholing" into an `Afgewezen` **decision** (§1) — a decision flag, an FE
change, and a separate WP.
- Deleting the dead `/intakes` + `/herregistraties` endpoints (with `EndpointTests`,
`backend/README.md`, `gen:api`) — real cleanup, but not this WP's security fix.
- The herregistratie wizard's `jaren`/`punten`, equally un-re-validated server-side.
- `docs/reference/fp-tea-atomic-design.md:587` / `ARCHITECTURE.md:464` still teach a
`visibleSteps`-with-a-`'scholing'`-step intake the fixed-3-step wizard no longer matches.
## Risks
- **Ordering regression (highest).** Running completeness before `RejectZeroUren` silently turns
`{ uren: 0 }` from 422/`Afgewezen` into 400 and breaks two existing endpoint tests. The
`reject is null &&` guard is load-bearing — keep the comment saying why.
- **`check:seam` false failure** if a second `ScholingThreshold = <digits>` literal lands in
`IntakePolicy.cs` (§3). The message will say "FE/BE seam drift" and mislead.
- **Missing the `Type == "intake"` gate** breaks the herregistratie wizard for every low-uren
user; the `herregistratie is unaffected` test is the only net.
- **Stale-bundle 400 loop:** the wizard's `Retry` re-sends the identical payload, so a pre-deploy
tab loops until reloaded. Acceptable for a POC.
@@ -0,0 +1,118 @@
# WP-70 — Test-data builders: illegal fixtures unrepresentable
Status: done (2eea860..a82332f)
Phase: 12 — DDD hardening
## Why
Decision #3 in `CLAUDE.md` — "make illegal states unrepresentable" — is honoured in
production code (`AanvraagStatus`'s private-ctor/factory shape, the FE's tagged-union
machines, branded value objects behind `parse*`) but **not** in the test suites that exercise
them. Every layer independently reinvented ad-hoc, hand-built fixtures that reach around the
production construction path:
- Backend: `Aanvraag` is a mutable EF-backed bag with independent public setters. Its own
`StatusAt` dereferences `Referentie!` three times on the unstated assumption
"Submitted ⇒ Referentie != null" — a convention two test files (`RuleTests.cs`,
`OpenZaakZaakSourceTests.cs`) kept consistent by hand across eight inline fixtures.
- Frontend: no shared fixture helper existed anywhere. Every machine spec redefined its own
throwaway literal helper (`editing1/2/3`, `editingWith`), each hardcoding fields like
`errors: {}` — asserting against shapes the real reducer may never produce.
- E2E: the seeded BSN and a diploma id were copy-pasted across all three specs, coupled to
`SeedData.cs`'s exact shape by comment only.
## Read first
- ADR-0006 (`docs/reference/architecture/0006-test-data-builders.md`) — the principle and the
full decision table this WP implements.
- `CLAUDE.md` §"The decisions" #3, #5.
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (`Aanvraag`, `StatusAt`).
- `backend/src/BigRegister.Api/Domain/Applications/AanvraagStatus.cs` — the exemplar this
WP's backend builder mirrors.
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`.
## Decisions (pre-made, don't relitigate)
1. **No `With*`-per-field builders anywhere.** A builder that opens every field back up is an
object literal with extra syntax — reject that shape on either side of the seam.
2. **Backend: a type-state builder.** `Given.Concept()``ConceptAanvraag` (only `.AtStep`/
`.Submitted`/`.Build` exist) → `SubmittedAanvraag` (only `.Decided`/`.Build` exist) →
`DecidedAanvraag`. `Decided(...)` validates a toelichting by calling the real
`BeoordelingRules.RequiresToelichting`, not by re-stating the rule.
3. **`Aanvraag` itself stays mutable** — WP-68 deliberately kept it an EF-backed class; fixing
that for real is an EF-mapping refactor, out of scope here (see Follow-ups).
4. **Frontend: replay, don't fabricate.** One combinator, `given(reduce, initial)(...msgs)`
(`libs/shared/src/testing/machine.ts`), replaces every hand-written state literal. Value
objects: `unwrapOk(parseX(raw))`, never a cast. `RemoteData`: named constructors
(`loading()`/`success(v)`/`failure(e)`), replacing duplicated per-file literals.
5. **E2E stays a flat smoke suite** (WP-19's scope). Only extract shared `Actors`/`SeedRefs`/
`loginAs` — no page-object layer, no Given/When/Then runner, no dev-only seeding API.
The shared-mutable-backend isolation problem is a documented follow-up, not fixed here.
6. **Convert worst offenders only**, not a full sweep: `RuleTests.cs`'s `Decided()` helper +
`OpenZaakZaakSourceTests.cs`'s seven inline initializers (backend);
`herregistratie.machine.spec.ts` + `change-request.machine.spec.ts` + both RemoteData
specs (frontend); all three e2e specs (actors/seed-refs only).
## Files
| Area | Path |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| New (BE) | `backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs`, `Acceptance/BesluitLifecycleTests.cs` |
| Edit (BE) | `RuleTests.cs`, `OpenZaakZaakSourceTests.cs` |
| New (FE) | `libs/shared/src/testing/{machine,remote-data,value-object}.ts`, `herregistratie/domain/intake.testing.ts`, `intake.acceptance.spec.ts` |
| Edit (FE) | `herregistratie.machine.spec.ts`, `change-request.machine.spec.ts`, `remote-data.spec.ts`, `machine-remote-data.spec.ts`, both `tsconfig.app.json`, `angular.json` |
| New (e2e) | `e2e/support/actors.ts` |
| Edit (e2e) | `smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts` |
| Docs | ADR-0006, `libs/shared/docs/testing.mdx`, `.claude/skills/test-strategy/SKILL.md`, this file + backlog README row |
## Steps
Executed as three file-disjoint parallel tracks (backend / frontend / e2e), each ending its
own layer's tests green, then a combined gate, then docs written up against the interfaces as
actually shipped.
## Acceptance criteria
- [x] `Given.Concept().Decided(...)` does not compile (proved live: temporarily inserted the
call, confirmed `dotnet build` fails with `CS1061`, reverted).
- [x] `Decided(Besluit.Afwijzen)`/`MeerInfoOpvragen` with no toelichting throws, via the real
`BeoordelingRules.RequiresToelichting`.
- [x] Backend tests: 220/220 passing (was 216 before; +4 from `BesluitLifecycleTests`).
- [x] Frontend: `npm test` green across all four projects (ssp/behandelportal/shared/beheer);
converted specs assert the same behaviour as before (diffed, not just re-passed) —
one case (`editing3`'s hardcoded `errors: {}` at step 3 with invalid punten) was
confirmed reachable via `SetField` after `Next`, not an unrepresentable state, so the
assertion carried over unchanged.
- [x] No fixture-only export (`givenIntake` etc.) leaks into a production bundle — confirmed
via `grep -rl` on `dist/` after both a plain and a `--localize` build.
- [x] `npm run ci` green (lint, format, tokens, both localized builds, audit, backend
format+test, snippet-drift, api-client-drift).
- [~] `npm run e2e` — refactor reviewed line-by-line (zero assertions changed), but not run to
completion in this environment: port 4200 was occupied by an unrelated container
(`team-monitor-web-1`), not this repo's stack. Confirm on a clean runner/CI before
relying on it; not a regression introduced by this WP.
## Verification
```bash
cd backend && dotnet format --verify-no-changes && dotnet test # 220/220
npm run ci # green (2026-08-18)
npm run e2e # run on a clean port 4200
```
## Out of scope
- E2E test isolation (a dev-only seed endpoint) — the real fix for the shared-mutable-backend
problem; a new production-adjacent surface needing its own security review.
- Making `Aanvraag` itself illegal-states-unrepresentable (EF-mapping refactor).
- `RegistrationStatus`'s equivalent flat-record gap (`Domain/Registrations/`) — same class of
defect, separate WP.
- E2E coverage for `apps/behandelportal` (currently zero).
## Risks
- The backend type-state builder only guards the fields it models (`Submitted`, `Referentie`,
`SubmittedAt`, `BesluitStatus`, `BesluitToelichting`); other `Aanvraag` fields (e.g.
`ZaakUrl`) are still set post-`.Build()` directly, since `Aanvraag` remains mutable. A
future field added to the lifecycle needs a deliberate builder update, or it silently
reopens the same gap this WP closed.
@@ -0,0 +1,134 @@
# WP-71 — Test framework coherence: BDD/DDD alignment + closing the illegal-state escape hatches
Status: done (b937e55..3652ff8)
Phase: 12 — DDD hardening
## Why
WP-70 shipped test-data builders and ADR-0006, then a three-angle audit (BDD conventions,
DDD alignment, type-safety of test code) asked whether the framework as a whole is "the best
way to set up testing." It was not — and the gap was not where WP-70 looked.
**WP-70 built the door but left the walls open.** `unwrapOk` had zero call sites; `given()`
was adopted in 4 specs. Meanwhile 76 `as any` casts survived in the three biggest wizard
specs, and the reason was systemic: `eslint.config.mjs` blanket-exempted every `*.spec.ts`
from the `any` ban, and no gate anywhere ran `tsc --noEmit` over spec files, so a wrong cast
could never fail the build. One assertion
(`org-template.machine.spec.ts`, `s.tag === 'loaded' && s.dirty`) passed vacuously whenever
the tag was wrong.
Alongside that: the documented "never assert on `$localize` copy" ban was broken in 5 files;
`bdd.mdx` mis-cited its own exemplar as "one transition per test"; `layers.mdx` still taught
the pre-WP-67 six-context structure with no `apps/`+`libs/` split; backend tests were
organised by technical concern (`RuleTests.cs` held 5 aggregates as nested classes) rather
than by aggregate; and duplicated FE/BE rules (the scholing threshold, the phone-format regex)
had no test spanning the seam, so they could silently diverge.
## Read first
- `docs/reference/architecture/0006-test-data-builders.md` (WP-70's ADR).
- `libs/shared/docs/bdd.mdx`, `libs/shared/docs/layers.mdx` (both rewritten by this WP).
- `backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs` — the canonical G/W/T
shape both docs now point at.
## Decisions (pre-made, don't relitigate)
1. **No Gherkin/Cucumber.** Feature files bind steps by runtime string matching, which
directly undoes the compile-time guarantees WP-70 added, and need two frameworks
(.NET + TS) for an audience of developers, not scenario-co-authoring stakeholders. Instead:
generate a business-readable behaviour spec FROM the test names (`gen-behaviour-spec.mjs`,
modeled on the existing `gen-snippets.mjs`), gated for drift in CI. Test names stay the
single source of truth.
2. **Playwright stays** — no change to the e2e framework.
3. **Given/When/Then becomes the default structure for ALL tests** (user override of the
audit's initial recommendation). `bdd.mdx`'s old "no G/W/T ceremony" clause is removed and
inverted; ADR-0006 already matched. Present-tense declarative naming is unchanged. The
_retrofit_ in this WP covers the acceptance specs, the canonical exemplars, and every file
the other tracks already opened — not a mechanical sweep of all ~600 tests (tracked as a
follow-up).
4. **Hardening = one helper + four gates.** `expectTag(state, tag)` replaces every unsafe
narrowing cast; the ESLint spec exemption is removed; `npm run typecheck` is added; a
dependency-cruiser rule keeps test helpers out of production.
5. **FE/BE seam: document + one worked pattern**, not full seam coverage. `check-seam.sh`
catches the scholing-threshold literal drift; the other three divergences (phone regex,
disjoint eligibility fixtures, toelichting rule) are documented, not fixed. Scholing
_enforcement_ stays WP-69's job.
## Files
| Track | Representative paths |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A · hardening | `libs/shared/src/testing/expect-tag.ts` (new); the three wizard machine specs + `*has-progress.spec.ts` + `aanvraag-view.spec.ts`; `eslint.config.mjs`, `.dependency-cruiser.base.js`, `package.json`, CI; `Builders/AanvraagBuilder.cs`, endpoint test files |
| B · BDD | `libs/shared/docs/bdd.mdx`, ADR-0006; the 5 copy-assertion files; the multi-behaviour title splits |
| C · DDD | `libs/shared/docs/layers.mdx`; `RuleTests.cs``Domain/*RuleTests.cs`; new specs for `isStatusConsistent`, `OrgTemplateRules.RejectDraft`, both apps' `session.ts` |
| D+E · living docs + seam | `scripts/gen-behaviour-spec.mjs`, `libs/shared/docs/behaviour-spec.mdx` (generated), `scripts/check-seam.sh` |
## Steps
Executed as four tracks: A/C/D+E ran file-disjoint in parallel first; B ran after, since its
doc rewrite needed to reflect what A/C actually converted. Each track ended its own layer
green; a combined gate followed; then per-track commits.
## Acceptance criteria
- [x] `expectTag` replaces all 76 `as any` + 12 `as Extract<>` state-narrowing casts across
the three biggest wizard specs and the `*has-progress`/`besluit`/`change-request` specs.
Zero tests legitimately started failing — every wrong-variant read the casts were hiding
turned out to already be correct.
- [x] The vacuous assertion in `org-template.machine.spec.ts` (and, on inspection, every
sibling instance of the same pattern in that file) is fixed.
- [x] Four new gates proven to actually fail before being trusted: `npm run lint` fails on a
planted `any`; a deliberately-wrong `expectTag` call fails with a named
"expected X, got Y" error, not `undefined`; `npm run dep:check` fails on a planted
production import of `libs/shared/src/testing`; `npm run typecheck` fails on a planted
type error in a spec.
- [x] `check-seam.sh` proven to fail when the two scholing-threshold literals are set to
different values, with both file paths and values named in the error.
- [x] Backend test count: 220 (WP-70 baseline) → 230 (+9 `OrgTemplateRuleTests`, +1 from the
`Unknown_id_404s_and_zorgverlener_is_forbidden` split). File count in
`RuleTests.cs`'s place: 0 (deleted) → 7 files under `Domain/`, same total test count
moved (plus the new file).
- [x] Frontend test count grew only from legitimate title splits (no assertion dropped) and
the new session/isStatusConsistent specs — before/after counts reported per file by the
owning track.
- [x] `layers.mdx` reflects the actual WP-67 monorepo structure (`apps/`+`libs/`,
dependency-cruiser as the real enforcement mechanism, not ESLint).
- [x] `bdd.mdx`'s "one transition per test" citation of `registratie-wizard.machine.spec.ts`
is true again (the cited test was split).
- [x] Every one of the 5 documented `$localize`-copy-assertion violations is fixed or
explicitly justified as the doc's own escape hatch (a `reden` free-text passthrough with
no backing tag — `aanvraag-view.spec.ts`/`beoordeling-view.spec.ts` — left alone with an
inline comment explaining why, rather than forcing a fake enum into production code).
- [x] `npm run ci` green (lint, typecheck, dep:check, format, check:tokens, check:seam, all
four test projects, both localized builds, audit, backend format+test, snippet drift,
behaviour-spec drift, api-client drift). `npm run build-storybook` green (the new/edited
MDX pages build without error).
## Verification
```bash
npm run typecheck && npm run lint && npm run dep:check && npm run check:seam
npm run ci # green (2026-08-18)
npm run build-storybook # green
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" # 230/230
```
## Out of scope
- Universal Given/When/Then sweep across all ~600 tests (staged instead — retrofit covers the
files this WP already touched; the rest adopt it as they're next edited).
- Scholing threshold _enforcement_ — WP-69 owns it.
- Reconciling the phone-format regex divergence in production code (documented, not fixed;
verified not a live bug — the FE normalises `+31``0` before the wire).
- Making `Aanvraag` genuinely immutable (EF refactor, inherited from WP-70).
- E2E test isolation via a dev-only seed endpoint (inherited from WP-70).
- `RegistrationStatus`'s flat-record gap on the backend (inherited from WP-70).
## Risks
- `expectTag`'s runtime throw only fires when a spec actually calls it with the wrong tag —
it does not retroactively audit every state a reducer can reach. A future variant added to
a union still needs its own test coverage; the helper only makes existing coverage honest.
- `check-seam.sh` covers exactly one FE/BE literal pair (the scholing threshold). The other
three documented divergences (phone regex, eligibility fixtures, toelichting) have no
automated guard — a future edit to either side can still silently diverge undetected.
@@ -0,0 +1,56 @@
# WP-72 — Delete the dead legacy submit endpoints
Status: done (6bc00a9)
Phase: 12 — DDD hardening
## Why
`POST /api/v1/intakes` and `POST /api/v1/herregistraties` were dead from the UI — the wizard
submits through `POST /applications/{id}/submit`, and nothing in `apps/` or `libs/` called the
generated `intakes()`/`herregistraties()` client methods. They were also strictly **less
capable** than the endpoint that replaced them: they minted a bare reference and wrote no
`Aanvraag`, made no ZGW/OpenZaak call, and performed no document-ownership check.
WP-69 hardened `/intakes` with a 400 last session. Deleting the surface is the stronger fix;
WP-69's `/applications/{id}/submit` enforcement — the path the wizard actually uses — is
untouched.
## Decisions (pre-made)
1. Delete both routes together. Their two `EndpointTests` are `[Theory]`s parameterised across
_both_ routes, so deleting one would leave an `InlineData` row 404-ing.
2. **Keep** the shared `Submit(...)` helper, `ReferentieResponse`, `SubmissionRules.NewReference`
and the whole `IdempotencyStore` path — `/registrations` and `/change-requests` still use
them, and `IdempotencyTests` covers the latter.
3. This WP owns the wire artifacts; no other track runs `gen:api`.
## Acceptance criteria
- [x] Both routes return **404** against a live backend (verified by curl, not by inference).
- [x] Zero references remain in `libs/shared/src/infrastructure/api-client.ts`.
- [x] `gen:api` diff is **pure deletion** — 124 lines out of `swagger.json`, 109 out of the API
client, zero additions.
- [x] Backend tests 245 → 240, exactly the 5 deleted cases (2 `[Theory]`s × 2 rows + 1 `[Fact]`).
- [x] `Submit(...)` and the idempotency path survive with their live callers intact.
## Verification
```bash
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration"
npm run ci
curl -X POST http://localhost:5000/api/v1/intakes -d '{"uren":500}' # 404
```
## Notes
Committed together with WP-73 (`6bc00a9`): both edit `Program.cs`, and splitting them would
have produced a commit that does not build. The two were run in separate execution waves to
avoid a concurrent `dotnet build` collision — but since neither committed independently, the
file-level entanglement remained at integration time. Worth remembering when planning future
parallel backend tracks: **separate waves do not produce separate commits.**
## Follow-ups
- `docs/reference/fp-tea-atomic-design.md:587` / `ARCHITECTURE.md:464` still teach a
`visibleSteps`-with-a-`'scholing'`-step intake the fixed-3-step wizard no longer matches
(inherited from WP-69).
@@ -0,0 +1,90 @@
# WP-73 — `RegistrationStatus` and `Aanvraag` as closed unions
Status: done (6bc00a9)
Phase: 12 — DDD hardening
## Why
Two backend domain types still allowed illegal states, against `CLAUDE.md`'s non-negotiable #3.
`RegistrationStatus` was a flat record whose **own doc-comment** admitted only `Geregistreerd`
should carry a herregistratie deadline — and noted the frontend modelled it correctly as a
discriminated union while the backend did not. It also made `reden` nullable on all three
variants where the FE requires it on two.
`Aanvraag` was a mutable EF class with 14 public setters. Its `StatusAt` carried **five
`Referentie!` null-forgiving derefs** plus a `SubmittedAt!.Value` — the compiler saying out loud
that "Submitted ⇒ Referentie != null" was convention, not type. WP-68 left it mutable
deliberately; WP-70/71 bought most of the safety with a test-only builder, which was itself a
hand-rolled prototype of the union this WP builds for real.
## Decisions (pre-made)
1. **Full union, not private setters.** The cheaper option (flip 14 setters to `private set`,
3 files, no migration) was rejected in favour of the honest modelling.
2. `RegistrationStatus` → abstract record + three sealed variants behind a private base ctor.
Chosen over WP-68's static-factory shape (`AanvraagStatus`) because with only 4 read sites the
abstract record is affordable and makes **reading** safe too, not just construction.
3. `Aanvraag``Concept | Submitted | Decided` (with `Decided` further split into
`Goedgekeurd | Afgewezen | MeerInfoGevraagd`), the EF row demoted to `AanvraagEntity` behind
a two-way mapper.
4. The `(Owner, Type)` "at most one unsubmitted aanvraag" rule is an **aggregate-set** invariant —
it cannot live on the entity and stays procedural in `CreateConcept` under the lock. Stated in
code so nobody tries to move it.
5. No migration, no schema change, no wire change.
## Acceptance criteria
- [x] **Illegal construction is a compile error, proven not assumed.** Each was attempted, the
compiler error recorded, then reverted:
| Attempted illegal state | Compiler error |
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `Decided` with no referentie | `CS9035: Required member 'Aanvraag.Decided.Referentie' must be set` |
| `Geschorst` with a HerregistratieDatum | `CS1739: The best overload for 'Geschorst' does not have a parameter named 'HerregistratieDatum'` |
| `Afwijzen` with no toelichting | `CS9035: Required member 'Aanvraag.Decided.Afgewezen.Toelichting' must be set` |
- [x] All five `Referentie!` derefs and the `SubmittedAt!.Value` are **gone**, not suppressed.
`IZaakSource.CreateZaak` narrows to `Aanvraag.Submitted`, removing the same class of deref
in both `LocalZaakSource` and `OpenZaakZaakSource`.
- [x] `reden` is now required on `Geschorst`/`Doorgehaald`, matching the FE union.
- [x] `HerregistratieRule.IsStatusConsistent` deleted as dead code — the type now guarantees what
it checked, and its test **could no longer construct the illegal state it existed to
catch**. That failure to compile is the proof the refactor worked.
- [x] Backend 242 → 241, exactly that one deleted test. No other count change.
- [x] `RegistrationStatusDto` and the application DTOs byte-identical — confirmed by diffing a
live backend's `/swagger/v1/swagger.json` against the checked-in copy. No `gen:api`.
## The `Draft` decision (made explicitly)
`ApplicationStore`'s doc-comment claimed `Draft` was "Concept only" (`Draft != null ⇒ !Submitted`),
but `Submit` never cleared it — so the invariant was **violated in production**. Resolved in
favour of the code matching the comment: `Submitted`/`Decided` simply have no `Draft` property,
so submitting drops it. Verified nothing reads a submitted aanvraag's draft — `draft-sync.ts`'s
`applyResume` is the only consumer of `ApplicationDetailDto.Draft` and only ever resumes an
unsubmitted wizard.
## Deviations
- **`Aanvraag` (EF row) renamed to `AanvraagEntity`.** The domain union needed the bare name to
match `RegistrationStatus`/`AanvraagStatus` conventions; keeping both would make every file
importing both namespaces ambiguous (`CS0104`). The table name is unaffected — EF derives it
from the `Applications` `DbSet` property, not the CLR type.
- **Step invariant loosened** from `0 <= StepIndex < StepCount` to `<=`: `CreateConcept` produces
`(0, 0)` before the wizard's first draft sync, which the strict form would reject at creation.
- `AanvraagBuilder.Decided(...)` now delegates to the real union constructors, dropping its own
hand-rolled toelichting guard; a one-line wrapper keeps the `.Decided(...).Build()` chain
source-compatible for existing call sites.
## Verification
```bash
cd backend && dotnet format --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration"
npm run ci
```
## Follow-ups
- Making `Besluit` flow through the generated client as an enum rather than a `string` would
remove that FE/BE seam entirely rather than guarding it (WP-75 added the guard) — but it is a
wire change.
@@ -0,0 +1,86 @@
# WP-74 — E2E isolation without a new backend endpoint
Status: done (42f7bd6)
Phase: 12 — DDD hardening
## Why
The three Playwright specs shared one mutable backend and admitted it in their own comments
("Restart the backend between CI runs — a second run would see a leftover Concept"). A crashed
mid-wizard run poisoned every subsequent run via `CreateConcept`'s 409, and both mutating specs
acted as the same identity (`DocumentStore.DemoOwner`), so any new state-touching spec would
collide immediately.
## Decisions (pre-made)
**WP-70 recorded the fix as a dev-only seed endpoint. That premise was wrong**, and exploration
established why:
- The DB path already routes through `IConfiguration` (`Program.cs`,
`Db.ConnectionString = GetConnectionString("AppDb") ?? …`), so `ConnectionStrings__AppDb` as an
env var gives a throwaway DB with **zero backend change** — the same trick
`TestWebApplicationFactory` already uses per-test.
- `StubIdentityProvider` **already honours** an `X-Subject` header; the only gap was that no FE
interceptor sent one.
- The backend has **no `IsDevelopment()` gate anywhere** (grep: zero hits), so a seed endpoint
would have had to invent the codebase's first environment gate — a new security posture for no
gain.
So: throwaway DB + a dev-only `X-Subject` interceptor. No new endpoint, no environment gate.
## Acceptance criteria
- [x] **`npm run e2e` passes twice back-to-back with no backend restart** — the actual acceptance
test, and the thing that failed before this WP.
- [x] **`X-Subject` observed on a real request** reaching the backend (`X-Subject: 111222333` on
`GET /api/v1/uploads/categories`), not merely wired.
- [x] Each new BSN elfproef-verified by script against the weights `[9,8,7,6,5,4,3,2,-1]`.
- [x] No backend change, no new endpoint, no `IsDevelopment()` gate.
- [x] Committed port config still defaults to 4200 (verification used an override).
## Notes on the two caveats
- **`reuseExistingServer` stays on.** Flipping it to `false` would hard-fail `npm run e2e` for
anyone already running the docker stack on 4200/5000 — a real local-workflow regression. The
consequence (the throwaway DB only applies when Playwright itself spawns the backend; always
true in CI) is documented in a comment on the `webServer` entry.
- **Unique DB filename per invocation**, with `global-setup.ts` sweeping only _prior_ runs'
leftovers. A fixed name unlinked mid-run is only safe if SQLite's pool never reopens by path
afterwards; under `fullyParallel` that risks silently recreating an empty, unmigrated DB.
## Deviation: interceptors alone were not enough
Two hand-written call sites bypass Angular's interceptor chain (as `CLAUDE.md` documents) and
needed `X-Subject` stamped explicitly:
- `libs/shared/src/upload/upload.adapter.ts`'s raw XHR upload — without this every uploaded
document landed under `DemoOwner`, breaking submit for any other identity.
- `apps/ssp/.../letter-preview.adapter.ts`'s preview fetch (plus `cache: 'no-store'`, correct
regardless since the endpoint sends no `Cache-Control`).
## Known gap (a real backend bug, not caused by this WP)
Under any BSN other than `DemoOwner`, `GET /brief/preview` returns a **sent** letter still
carrying the draft watermark — while `curl` against the same backend at the same instant returns
the correct frozen archive. Client caching was ruled out (`no-store`, then cache-busting query
strings), the dev proxy was ruled out, and it reproduced across two BSNs and never for
`DemoOwner`. This points at a staleness/race in `BriefStore`'s SQLite read path.
`brief-v2.spec.ts` therefore keeps the shared `zorgverlener` identity — it still gains
throwaway-DB repeatability, just not per-spec identity isolation. `actors.ts` reserves a
`briefOpsteller` actor for whoever fixes the backend. **Tracked as a follow-up below.**
## Verification
```bash
npm run e2e # twice consecutively, no backend restart
npm run lint && npm run typecheck && npm test && npm run build
```
Note: port 4200 was held by an unrelated container on the dev machine, so verification ran with
`E2E_BASE_URL` pointed at an alternate port. The committed default is unchanged.
## Follow-ups
- **`/brief/preview` staleness for non-`DemoOwner` identities** (above) — the blocker for giving
`brief-v2.spec.ts` its own identity.
@@ -0,0 +1,65 @@
# WP-75 — Close the remaining FE/BE seams
Status: done (6fa27d1)
Phase: 12 — DDD hardening
## Why
WP-71 added `scripts/check-seam.sh` guarding one literal pair (the scholing threshold) and
documented three further FE/BE duplications that nothing tested across the seam. This closes
them — two by deletion, one by a guard, one by an actual fix.
## Decisions (pre-made)
1. **Dead reference impls get deleted, and `CLAUDE.md` is amended.** This overturns the
documented policy that server-owned rules "stay in `domain/*.policy.ts` as reference impl +
unit test". That policy is precisely what kept dead code alive. Blast radius is small:
`registration.policy.ts` is the only `*.policy.ts` in the repo.
2. Guard the `Besluit` tag list by **extending** `check-seam.sh`, not adding a second script.
3. The phone seam gets a **contract test**, not a grep check — see below.
## Acceptance criteria
- [x] `isHerregistratieEligible` deleted (uncalled; dead by its own doc-comment) along with
`isStatusConsistent` (also uncalled — WP-71 had added a spec for it the session before).
The three live exports (`statusLabel`, `statusColor`, `herregistratieDeadline`) stay, and
`herregistratieDeadline` gained direct coverage it previously only had transitively.
- [x] `CLAUDE.md` amended: server-owned rules live **only** on the server; the FE may mirror a
server-supplied _value_ (a threshold, a bound) for instant feedback, but never
reimplements the _algorithm_. ADR-0001's matching claim aligned.
- [x] `check-seam.sh` guards the `Besluit` tag list, **proven to fail** when a fourth member is
added to the C# enum only, naming both files and both lists. Anchored on the full
declaration so it avoids the "greps all matches" trap WP-69 documented.
- [x] Phone contract test added and green; backend stripping fixed.
## The phone divergence was real, not latent
WP-71 recorded this as latent because the Angular app normalises before sending — true of _that_
path. The contract test proved the two sides genuinely disagreed: the backend returned **422**
for `+31612345678` and `(06) 12345678`, both of which the FE's own `parseTelefoonnummer`
accepts. Any non-Angular client, crafted POST, or future FE change would have hit it.
`SubmissionRules.RejectPhoneChange` now strips exactly what the FE strips (`[\s\-()]`, then a
leading `+31``0`) before applying the shared `^0\d{9}$`. The FE value object was not touched —
it is the more permissive and correct side.
**Why a contract test rather than a grep check:** both sides carry the identical `^0\d{9}$`
literal, so a drift check would have compared them, found them equal, and reported all clear.
The divergence was in the _normalisation before_ the regex — invisible to text comparison. Worth
remembering when choosing between the two guard styles: grep checks catch drifting **constants**,
contract tests catch drifting **behaviour**.
## Verification
```bash
npm run check:seam # both checks OK
npm run ci
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration"
```
## Follow-ups
- Making `Besluit` flow through the generated client as an enum rather than a `string` would
remove that seam entirely rather than guarding it — a wire change, so not done here.
- The herregistratie-eligibility seam is closed by deletion; if a FE mirror is ever reintroduced,
the disjoint-fixture problem returns and would need a contract test, not a grep check.