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
+11
View File
@@ -0,0 +1,11 @@
# Archive — finished backlogs
The trees in this directory are historical. Every ticket in `backlog/` and
`refactor-backlog-setup/` carries `Status: done`. `SHOWCASE-ROADMAP.md` was superseded before
either arc started.
Git holds the rest — `git log --follow` on any file here shows its full history, including the
work that produced it.
The live plan is `docs/project/readable-codebase/`. Product reference material is
`docs/project/prd/`.
+8
View File
@@ -0,0 +1,8 @@
# Showcase roadmap — superseded
**This roadmap is superseded by [`docs/project/archive/backlog/`](backlog/README.md)** (2026-07-02).
The backlog absorbs and corrects this document: its Storybook-as-curriculum track became
WP-14/15 (+ per-invariant MDX pages in WP-05/07/08/13/17), its enforcement track became
WP-01…04, and several of its claims had gone stale (value-object specs exist; more than
one component lacked a story). Two backlogs would drift — this one is closed.
+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 0–5 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 10–15 (axe covers story churn);
03/04 before 05–09 (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. 18–22 (phase 5, "productie-volwassenheid")
are independent of each other and of phases 1–4 — pick any order; **18 is the recommended
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the
FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the
current in-memory persistence is a documented, defensible POC choice, not a bug.
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); 33–36 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 49–51 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 49–52) 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 3–4) 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, 10–50) — 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 2–3 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.
@@ -0,0 +1,63 @@
# Refactoring backlog — automated setup
## What's in this package
```
refactor-backlog-setup/
setup.sh ← run this once, from the root of the target repo
agents/ ← source prompts (edit these if you need to tweak
scope/wording before running setup.sh)
_persistence-protocol.md
00-baseline.prompt.md
01-readability.prompt.md
02-testability.prompt.md
03-ddd-hexagonal.prompt.md
04-cqrs-light.prompt.md
05-bdd.prompt.md
06-adr-conformance.prompt.md
07-bio2-compliance.prompt.md
08-consolidation.prompt.md
09-implementation.prompt.md (template — one TICKET-ID per Phase 3 dispatch)
```
## Usage
1. Copy this `refactor-backlog-setup/` folder into the root of the target repo
(or reference it via a relative path).
2. Edit anything in `agents/` if scope/exclusions need repo-specific detail
(e.g. exact module paths, ADR folder location) — the prompts currently use
the defaults agreed in the design conversation.
3. Run:
```
bash refactor-backlog-setup/setup.sh
```
This creates `./refactor-backlog/` with:
- `_status.md` initialized, all agents `not_started`
- `00-baseline.md` through `07-bio2-compliance.md` initialized with headers
- `99-backlog.md` empty, ready for Consolidation
- `implementation/` folder for Phase 3 notes
- `final-prompts/` — every agent prompt with the persistence protocol
already merged in. **These are the exact prompts to dispatch — no manual
copy-paste needed.**
## Dispatch order
1. Dispatch `final-prompts/00-baseline.prompt.md` (Opus). Wait for
`_status.md` → baseline: complete.
2. Dispatch the 7 Phase 1 prompts in parallel (Opus): `01` through `07`.
Each checks its own dependency in `_status.md` before starting.
3. Once all 7 show `complete`, dispatch `final-prompts/08-consolidation.prompt.md`
(Opus). It writes `99-backlog.md` and halts for human approval — check the
file for any `ADR-fix` or BIO2-flagged tickets before proceeding.
4. For each approved ticket, copy `final-prompts/09-implementation.prompt.md`,
fill in `TICKET-ID:`, dispatch (Sonnet). Run tickets in parallel within a CD
batch, sequential across batches, per the `Depends on` column in
`99-backlog.md`.
## Re-running / resuming
Safe to re-run `setup.sh` only on a fresh workspace — it does not check for an
existing `./refactor-backlog/` and will overwrite `_status.md` and the phase
output files. If a run is already in progress, don't re-run `setup.sh`; just
re-dispatch the relevant `final-prompts/*.prompt.md` — each agent reads
`_status.md` and its own output file first and resumes from where it left off.
@@ -0,0 +1,22 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/00-baseline.md
DEPENDS ON: none
---
[Insert contents of _persistence-protocol.md here]
ROLE: Metrics Baseline Agent
Before any refactoring suggestions, establish a baseline for the scoped codebase:
- Test coverage (line/branch) per module, .NET and Angular separately.
- Cyclomatic complexity per method/function (flag >10).
- Duplication percentage (tool-based, e.g. jscpd/SonarQube if configured).
- Dependency graph / layering violations (existing static analysis if present).
- Count and location of existing CQRS-light and hexagonal architecture patterns
already in use (so later agents compare against actual current state, not
assumed absence).
Output: a metrics table per module, plus a short list of modules ranked
worst-to-best on each metric. This file is fixed input to every Phase 1 agent —
no agent may propose a change without citing which baseline metric it improves.
@@ -0,0 +1,15 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/01-readability.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: Readability Agent
Junior = fluency in language constructs, not domain knowledge. Assume familiarity
with generics, async/await, LINQ, DI, RxJS operators, TS type system — do NOT flag
idiomatic use of these as "unreadable". Flag only: unclear naming, methods/components
exceeding [N] lines, nesting >3 levels, magic values, misleading types, missing guard
clauses. Cite baseline complexity score per finding.
@@ -0,0 +1,14 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/02-testability.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: Testability Agent
Flag constructs that block unit testing without excessive mocking: static/singleton
dependencies, hidden I/O, constructors doing work, mixed pure/impure logic. Cite
baseline coverage gap per finding. Propose the minimal seam needed (interface
extraction, pure function split) — not a rewrite.
@@ -0,0 +1,16 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/03-ddd-hexagonal.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: DDD/Hexagonal Agent
Target architecture: hexagonal (ports/adapters), already partially present in the
codebase per baseline.md — treat that as the pattern to extend, not reinvent. Do NOT
introduce hexagonal structure into modules where it is absent; only propose closing
gaps where the pattern is already started. Flag anemic domain models, domain logic
leaked into controllers/services/components, primitive obsession, missing ubiquitous
language.
@@ -0,0 +1,15 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/04-cqrs-light.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: CQRS-light Agent
Target: command/query separation at the application-service level (not event
sourcing or separate read models unless already present per baseline.md). Only
extend existing CQRS-light patterns — do not introduce the pattern into modules
where it's absent. Identify handlers/services mixing reads and writes within
modules that already show the pattern elsewhere.
@@ -0,0 +1,14 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/05-bdd.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: BDD Agent
Check whether existing tests express behavior in domain/business language mapped
to acceptance criteria, or only technical steps. Flag test names/structure gaps.
Do not propose new BDD tooling if none is present — flag as a separate structural
item instead, not a per-module ticket.
@@ -0,0 +1,16 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/06-adr-conformance.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: ADR-Conformance Agent
Read all ADRs/docs in the repo. Compare code against each. Two outcomes per
deviation:
(a) code violates a correct ADR → refactoring ticket, cite ADR.
(b) ADR itself appears outdated/wrong given current code or constraints → propose
an ADR amendment as a separate ticket type ("ADR-fix"), with rationale — not
a code ticket.
@@ -0,0 +1,24 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/07-bio2-compliance.md
DEPENDS ON: 00-baseline.md (complete)
---
[Insert contents of _persistence-protocol.md here]
AGENT: BIO2/Compliance Agent
No explicit control list supplied — using the following BIO2/ISO 27002:2022
controls, selected for privacy and security relevance. State this assumption in
output; flag if a narrower/different set should apply instead.
- Access control (9.1, 9.2, 9.4): authorization checks, RBAC, least privilege.
- Logging & monitoring (8.15, 8.16): audit trails, esp. BIG-register/DUO data access.
- Data classification & handling (5.12, 5.13): BSN, health data, AVG-sensitive fields.
- Cryptography (8.24): encryption at rest/in transit.
- Secure development (8.25, 8.28, 8.29): secure coding, review, security testing gates.
- Change control (8.32): deployment register / change approval exceptions.
- Input validation (8.26): boundary validation on public-facing forms/APIs.
Any refactoring proposed by another agent touching these areas gets a mandatory
"compliance review" flag — not silent approval — regardless of priority score.
@@ -0,0 +1,35 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/99-backlog.md
DEPENDS ON: 01-readability.md through 07-bio2-compliance.md (all complete)
---
[Insert contents of _persistence-protocol.md here — file-level: re-run only if a
Phase 1 file changed since last run]
ROLE: Consolidation & CD-Sequencing Agent
Input: all Phase 1 files (01–07) + 00-baseline.md.
1. Deduplicate overlapping findings across agents — merge into one ticket, list all
contributing reasons/agents.
2. Score priority:
P1 = violates a correct ADR, blocks testability, or is a BIO2 compliance risk.
P2 = significant maintainability cost, moderate effort.
P3 = low urgency.
3. Sequence for continuous delivery: every ticket must be independently deployable
without a big-bang release. Reject/split any ticket that can't ship alone —
decompose into a dependency chain of smaller tickets.
4. Any ticket touched by the BIO2 agent requires compliance sign-off before merge,
regardless of priority score — mark explicitly.
5. Output final table:
| ID | Module | Category | Description | Baseline metric improved | Effort (S/M/L) |
Risk | Priority | CD batch # | Depends on | Status |
6. Separately list "ADR-fix" tickets — require human/architect approval before any
dependent code ticket proceeds.
HALT CONDITION: after writing 99-backlog.md, stop and report to the human for
approval before any Implementation Agent (Phase 3) starts — even if no tickets
carry a compliance or ADR-fix flag.
@@ -0,0 +1,33 @@
MODEL: Sonnet
OUTPUT: status update in /refactor-backlog/99-backlog.md +
/refactor-backlog/implementation/[ticket-id].md
DEPENDS ON: ticket status = not_started, no unresolved compliance/ADR-fix flag,
all tickets in "Depends on" column = implemented or needs_review
---
[Insert contents of _persistence-protocol.md here — scoped to one ticket, not a
module list]
AGENT: Implementation Agent
Input: one ticket from 99-backlog.md (fill in TICKET-ID below), the Phase 1
file(s) that produced it, and 00-baseline.md.
TICKET-ID: [fill in before dispatching this agent]
Scope discipline:
- Implement exactly the change described in the ticket. No scope expansion, no
incidental fixes.
- If the ticket is ambiguous or underspecified for implementation, do not guess —
write a blocker note to the ticket's status and stop.
- Do not modify architecture/pattern decisions (hexagonal boundaries, CQRS-light
structure) — those are Opus-level design calls already made in the ticket. If
implementation reveals the design call was wrong, flag back to Consolidation
rather than deciding unilaterally.
- Tickets touching a BIO2-flagged item are blocked until human compliance
sign-off is recorded in the ticket status — do not implement first and flag
after.
Update ticket status in 99-backlog.md: not_started → in_progress → implemented
→ needs_review. Append implementation notes to implementation/[ticket-id].md.
@@ -0,0 +1,25 @@
PERSISTENCE & RESUME PROTOCOL
Before starting work:
1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run.
2. If "in_progress", read your own output file. Treat modules already listed as done.
Resume from "Last module processed" + 1.
3. If "not_started", confirm your dependencies show "complete" in _status.md. If not,
stop and report a blocking dependency instead of guessing.
While working:
4. Append findings incrementally, one module at a time. After each module, update
_status.md: "Last module processed" and "Last updated".
5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs.
6. If interrupted, the file + status row is the full recovery state.
On completion:
7. Mark your _status.md row "complete" only once every module in scope has a
corresponding section in your output file.
Every output file starts with:
## Scope: [modules covered]
## Status: [not_started | in_progress | complete]
## Last updated: [timestamp]
## Depends on: [file(s)]
## ---
@@ -0,0 +1,547 @@
## Scope: apps/ssp (6 contexts), apps/behandelportal (3 contexts), libs/shared (11 layers), libs/beheer (5 layers), backend/src/BigRegister.Api (6 folders), backend/tests
## Status: complete
## Last updated: 2026-08-26
## Depends on: none
## ---
# 00 — Metrics Baseline
Fixed input to every Phase 1 agent (01–07). **No agent may propose a change without
citing a `BL-###` observation or a metric row from this file.**
---
## 1. Method, and what to trust
Everything was measured with tooling already in the repo. No dependency was added, no
config file edited.
| Metric | Command | Trust |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| FE coverage | `npm run test:coverage` → `coverage/{ssp,behandelportal,shared,beheer}/lcov.info` | exact |
| FE spec reach | derived: source files present in any lcov vs. files on disk | exact |
| BE coverage | `cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" --collect:"XPlat Code Coverage"` | exact |
| TS complexity + fn length | `npx eslint apps libs --rule '{"complexity":["warn",0],"max-lines-per-function":["warn",{"max":0,"skipBlankLines":true,"skipComments":true}]}' -f json` — threshold 0 makes ESLint report _every_ function with its score, giving a full distribution rather than only violations | exact (ESLint core rule, no plugin) |
| C# complexity | `node docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs --complexity` | **file CC exact-ish; per-method CC approximate** |
| Duplication | `node …/tools/baseline-scan.mjs --dup` | **approximate** |
| Layering violations | `npm run dep:check` | exact |
| Coupling / instability | `npx depcruise apps/<app>/src libs --config .dependency-cruiser.<app>.js --metrics --output-type metrics` | exact |
`tools/baseline-scan.mjs` is new and lives in this workspace so the numbers reproduce.
It is a crude text scan: line-window hashing for duplication (not token-based like
jscpd), regex method detection for C# (does not understand expression-bodied members or
nested lambdas). Deterministic, zero-install, good enough to answer "did this move".
Upgrade path if a ticket ever needs more precision: jscpd, and a Roslyn analyzer.
**Excluded as generated** (counted once, then excluded everywhere):
`libs/shared/src/infrastructure/api-client.ts` (2372 lines, NSwag) and
`backend/.../Data/Migrations/**` (17 files, 2690 lines, EF Core).
**LCOV aggregation rule.** The four FE project runs overlap — `libs/shared` files appear
in `ssp`'s lcov as well as `shared`'s. Each file is attributed to its owning module and
the **best** result across the four runs is taken; a shared file exercised by an app's
specs is genuinely covered.
**Protocol deviation, stated for the record.** The measurement runs are repo-global (one
coverage run, one depcruise run) and cannot be executed module-by-module; only the
write-up is per module. The resume contract still holds — the file is the recovery state.
---
## 2. Size inventory
| Unit | src .ts/.cs | src lines | spec files | spec lines | stories |
| ------------------------------ | ----------: | ---------: | ---------: | ---------: | ------: |
| apps/ssp | 92 | 10 415 | 39 | 3 564 | 21 |
| apps/behandelportal | 29 | 1 309 | 7 | 396 | 4 |
| libs/shared (excl. generated) | 86 | 5 194 | 21 | 1 226 | 40 |
| libs/beheer | 13 | 1 146 | 4 | 269 | 1 |
| **Frontend total** | **220** | **18 064** | **71** | **5 455** | **66** |
| backend/src (excl. Migrations) | 54 | 4 989 | — | — | — |
| backend/tests | 39 | — | 39 | 4 253 | — |
| e2e | 5 | 332 | — | — | — |
Backend source by folder: `Program.cs` 940 · `Data` (excl. Migrations) 1 697 · `Domain`
991 · `Zgw` 710 · `Contracts` 329 · `Stamdata` 322. Zero `.html` template files exist
anywhere in `apps/`/`libs/` — every Angular template is inline.
---
## 3. Coverage
### 3a. Frontend — line/branch coverage of files a spec actually reaches
| Module | Files | Lines | Line % | Branches | Branch % |
| -------------------------- | -----: | --------: | --------: | --------: | --------: |
| ssp/auth | 4 | 35 | 42.9% | 26 | 46.2% |
| bhp/auth | 4 | 35 | 42.9% | 26 | 46.2% |
| libs/shared/upload | 2 | 125 | 52.0% | 118 | 50.0% |
| libs/beheer/application | 1 | 70 | 65.7% | 37 | 40.5% |
| libs/beheer/infrastructure | 1 | 37 | 67.6% | 43 | 60.5% |
| ssp/herregistratie | 5 | 302 | 70.9% | 286 | 67.8% |
| libs/shared/layout | 2 | 80 | 72.5% | 36 | 55.6% |
| libs/shared/ui | 13 | 279 | 72.8% | 230 | 75.2% |
| ssp/brief | 11 | 486 | 75.3% | 461 | 68.8% |
| ssp/registratie | 21 | 430 | 80.0% | 471 | 77.3% |
| libs/shared/application | 8 | 76 | 80.3% | 50 | 70.0% |
| bhp/behandeling | 5 | 107 | 91.6% | 146 | 81.5% |
| libs/shared/infrastructure | 9 | 75 | 94.7% | 84 | 81.0% |
| libs/shared/kernel | 5 | 28 | 96.4% | 20 | 90.0% |
| libs/beheer/domain | 2 | 54 | 98.1% | 64 | 75.0% |
| ssp/showcase | 1 | 11 | 100.0% | 4 | 100.0% |
| libs/shared/testing | 3 | 9 | 100.0% | 2 | 50.0% |
| **TOTAL** | **98** | **2 240** | **75.1%** | **2 104** | **70.6%** |
### 3b. Frontend — spec _reach_ (the number that matters)
75.1% is coverage **of the 98 files a spec imports**. It is not coverage of the codebase.
122 of 220 source files are never loaded by any Vitest run at all.
| Module | Source files | Reached | Never reached | % reached |
| ---------------------------------------- | -----------: | ------: | ------------: | --------: |
| ssp/root, ssp/shell, bhp/root, bhp/shell | 12 | 0 | 12 | 0% |
| libs/shared/domain | 3 | 0 | 3 | 0% |
| libs/beheer/ui | 4 | 0 | 4 | 0% |
| libs/beheer/contracts | 1 | 0 | 1 | 0% |
| libs/shared/layout | 11 | 2 | 9 | 18% |
| bhp/behandeling | 16 | 5 | 11 | 31% |
| ssp/showcase | 3 | 1 | 2 | 33% |
| libs/shared/ui | 34 | 13 | 21 | 38% |
| ssp/brief | 26 | 11 | 15 | 42% |
| bhp/auth | 8 | 4 | 4 | 50% |
| libs/shared/upload | 4 | 2 | 2 | 50% |
| ssp/registratie | 41 | 21 | 20 | 51% |
| ssp/herregistratie | 9 | 5 | 4 | 56% |
| ssp/auth | 6 | 4 | 2 | 67% |
| libs/beheer/domain | 3 | 2 | 1 | 67% |
| libs/shared/application | 11 | 8 | 3 | 73% |
| libs/shared/infrastructure | 11 | 9 | 2 | 82% |
| libs/shared/kernel | 5 | 5 | 0 | 100% |
| **TOTAL** | **220** | **98** | **122** | **45%** |
Most of the 122 are `ui/` components, which CLAUDE.md §5 deliberately exercises through
**Storybook + the a11y addon (66 stories)**, not Vitest. That is a house decision, not a
gap — see BL-004 before filing anything against it.
### 3c. Backend
| Module | Files | Lines | Line % | Branches | Branch % |
| ------------------ | -----: | --------: | --------: | --------: | --------: |
| backend/Domain | 17 | 730 | 94.2% | 356 | 82.0% |
| backend/Stamdata | 10 | 252 | 96.8% | 120 | 71.7% |
| backend/Program.cs | 1 | 1 160 | 97.4% | 316 | 84.8% |
| backend/Contracts | 2 | 334 | 97.6% | 40 | 65.0% |
| backend/Zgw | 8 | 624 | 98.1% | 124 | 85.5% |
| backend/Data | 13 | 1 852 | 99.0% | 400 | 75.5% |
| **TOTAL** | **51** | **4 952** | **97.6%** | **1 356** | **79.6%** |
241 tests, all green. Every backend source file is reached. Branch coverage is the weak
axis, not line coverage — `Contracts` 65.0%, `Stamdata` 71.7%, `Data` 75.5%.
---
## 4. Complexity
### 4a. TypeScript (exact — ESLint core `complexity`)
Distribution over 2 085 source functions (specs/stories excluded):
**p50 1 · p75 2 · p90 3 · p95 5 · p99 12 · max 27.** 25 functions exceed CC 10 (1.2%).
Function length over 1 149 functions: **p50 3 · p75 7 · p90 13 · p95 20 · p99 34 ·
max 143.** Only 2 functions exceed 75 lines.
| Module | Fns | max CC | p90 CC | CC>10 | max fn lines | fn>75 |
| -------------------------------------------------------- | --: | -----: | -----: | ----: | -----------: | ----: |
| libs/shared/upload | 90 | 27 | 4 | 1 | 109 | 1 |
| ssp/registratie | 361 | 23 | 4 | 7 | 143 | 1 |
| ssp/herregistratie | 144 | 19 | 3 | 4 | 31 | 0 |
| libs/shared/infrastructure | 32 | 19 | 6 | 1 | 33 | 0 |
| ssp/brief | 608 | 17 | 3 | 6 | 73 | 0 |
| bhp/behandeling | 85 | 16 | 5 | 3 | 28 | 0 |
| libs/beheer/domain | 22 | 11 | 7 | 1 | 24 | 0 |
| libs/beheer/infrastructure | 13 | 11 | 7 | 1 | 25 | 0 |
| libs/shared/ui | 285 | 11 | 3 | 1 | 37 | 0 |
| libs/beheer/ui | 100 | 10 | 1 | 0 | 8 | 0 |
| libs/shared/application | 61 | 7 | 3 | 0 | 30 | 0 |
| bhp/auth · ssp/auth · ssp/shell | 63 | 5 | 3 | 0 | 13 | 0 |
| libs/beheer/application | 42 | 4 | 3 | 0 | 15 | 0 |
| libs/shared/kernel | 18 | 4 | 4 | 0 | 13 | 0 |
| libs/shared/layout | 75 | 4 | 2 | 0 | 29 | 0 |
| ssp/showcase · ssp/root · bhp/root · libs/shared/testing | 86 | 3 | 1 | 0 | 15 | 0 |
**Read BL-001 before filing any complexity ticket.** 23 of the 25 CC>10 functions are
reducers (9), `parse*` trust boundaries (10), or `validate*` (4) — all three are
mandated house idioms.
The 25, in full:
| CC | Function | Location | Kind |
| --: | ------------------------ | -------------------------------------------------------------------------------- | --------- |
| 27 | `reduceUpload` | libs/shared/src/upload/upload.machine.ts:131 | reducer |
| 23 | `validateStep` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:117 | validate |
| 20 | `reduce` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:321 | reducer |
| 19 | `validateStep` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:97 | validate |
| 19 | `parseAanvraagStatus` | apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts:72 | parse |
| 19 | `fetch` | libs/shared/src/infrastructure/api-client.provider.ts:49 | **other** |
| 17 | `parseOrgTemplate` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:331 | parse |
| 17 | `parseDuoLookup` | apps/ssp/src/app/registratie/infrastructure/duo.adapter.ts:46 | parse |
| 16 | `parseBeoordelingStatus` | apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:31 | parse |
| 16 | `parseStatus` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:202 | parse |
| 16 | `validateAll` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:166 | validate |
| 15 | `parseBeoordelingView` | apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:68 | parse |
| 15 | `validateAll` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:135 | validate |
| 14 | `reduce` | apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts:70 | reducer |
| 14 | `reduce` | apps/ssp/src/app/brief/domain/org-template.machine.ts:57 | reducer |
| 14 | `reduce` | apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts:161 | reducer |
| 14 | `reduce` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:222 | reducer |
| 14 | `reduce` | apps/ssp/src/app/registratie/domain/change-request.machine.ts:57 | reducer |
| 13 | `reduce` | apps/ssp/src/app/brief/domain/brief.machine.ts:188 | reducer |
| 12 | `parsePassage` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:235 | parse |
| 12 | `parseBrief` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:277 | parse |
| 12 | `parseDashboardView` | apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts:50 | parse |
| 11 | `reduce` | libs/beheer/src/domain/stamdata-editor.machine.ts:31 | reducer |
| 11 | `parseAuditEntries` | libs/beheer/src/infrastructure/audit.adapter.ts:21 | parse |
| 11 | `collect` | libs/shared/src/ui/rich-text-editor/rich-text-dom.ts:130 | **other** |
Functions over 75 lines — the entire population:
`createDraftSync` 143 lines (apps/ssp/src/app/registratie/application/draft-sync.ts:50),
`reduceUpload` 109 lines (libs/shared/src/upload/upload.machine.ts:131).
### 4b. C# (approximate)
| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 | CC>10 |
| ------------------ | ----: | --------: | ----------: | ------: | ------------: | --: | ----: |
| backend/Program.cs | 1 | **78** | **78** | 13 | 12 | 7 | 1 |
| backend/Data | 23 | 101 | 27 | 77 | 16 | 4 | 1 |
| backend/tests | 39 | 121 | 26 | 230 | 20 | 2 | 1 |
| backend/Domain | 18 | 71 | 21 | 36 | 8 | 6 | 0 |
| backend/Stamdata | 10 | 34 | 21 | 11 | 10 | 6 | 0 |
| backend/Zgw | 8 | 39 | 11 | 20 | 8 | 4 | 0 |
| backend/Contracts | 2 | 4 | 3 | 6 | 3 | 3 | 0 |
Method-length distribution (n=393): p50 9 · p90 22 · p99 90 · max 293.
Highest-CC files: `Program.cs` 78 · `Data/ApplicationStore.cs` 27 ·
`tests/OpenZaakZaakSourceTests.cs` 26 · `Domain/Letters/LetterHtml.cs` 21 ·
`Stamdata/StamdataTable.cs` 21 · `Data/BriefStore.cs` 17.
Methods over CC 10: `ToDto` (Data/BriefStore.cs:34, CC 16),
`LogBrief` (Program.cs:889, CC 12), and one 293-line test method
(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`, CC 20).
---
## 5. Duplication (approximate — 6-line normalized window)
Repo-wide: **1 755 of 24 701 significant lines duplicated = 7.1%.**
| Module | Sig. lines | Duplicated | % |
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------: | ---------: | ---------: |
| ssp/auth | 211 | 211 | **100.0%** |
| bhp/auth | 243 | 211 | **86.8%** |
| bhp/root | 90 | 68 | 75.6% |
| ssp/root | 122 | 68 | 55.7% |
| bhp/shell | 21 | 8 | 38.1% |
| ssp/herregistratie | 1 536 | 121 | 7.9% |
| ssp/brief | 5 015 | 393 | 7.8% |
| backend/tests | 2 538 | 197 | 7.8% |
| backend/Data | 1 068 | 82 | 7.7% |
| libs/shared/infrastructure | 510 | 36 | 7.1% |
| libs/beheer/domain | 250 | 17 | 6.8% |
| libs/beheer/ui | 509 | 34 | 6.7% |
| libs/beheer/application | 185 | 12 | 6.5% |
| bhp/behandeling | 954 | 55 | 5.8% |
| ssp/shell | 194 | 8 | 4.1% |
| ssp/registratie | 3 798 | 144 | 3.8% |
| libs/shared/ui | 2 604 | 58 | 2.2% |
| backend/Zgw | 327 | 6 | 1.8% |
| ssp/showcase | 487 | 8 | 1.6% |
| backend/Program.cs | 510 | 6 | 1.2% |
| libs/shared/application | 566 | 6 | 1.1% |
| libs/shared/layout | 869 | 6 | 0.7% |
| backend/Contracts, backend/Domain, backend/Stamdata, libs/shared/{domain,kernel,upload,testing,environments}, libs/beheer/{contracts,infrastructure}, e2e | 1 894 | 0 | 0.0% |
Top clone pairs:
| Windows | Pair |
| ------: | ------------------------------------------------------------------------------------------------ |
| 39 | `bhp/auth/application/session.store.ts` ↔ `ssp/auth/application/session.store.ts` |
| 36 | `bhp/auth/auth.guard.spec.ts` ↔ `ssp/auth/auth.guard.spec.ts` |
| 35 | `bhp/auth/ui/login-form/…` ↔ `ssp/auth/ui/login-form/…` |
| 25 | `bhp/app.config.ts` ↔ `ssp/app.config.ts` |
| 23 | `bhp/auth/ui/login.page.ts` ↔ `ssp/auth/ui/login.page.ts` |
| 21 | `bhp/auth/auth.guard.ts` ↔ `ssp/auth/auth.guard.ts` |
| 13 | `libs/shared/…/role.interceptor.spec.ts` ↔ `…/subject.interceptor.spec.ts` |
| 11 | `brief/ui/letter-canvas.stories.ts` ↔ `brief/ui/letter-composer.stories.ts` |
| 9 | `ssp/brief/ui/org-template.page.ts` ↔ `libs/beheer/src/ui/stamdata.page.ts` |
| 8 | `bhp/behandeling/domain/besluit.machine.ts` ↔ `ssp/registratie/domain/change-request.machine.ts` |
| 8 | `tests/OpenZaakZaakSourceTests.cs` ↔ `tests/ZgwDivergenceTests.cs` |
| 7 | `bhp/…/besluit-form.component.ts` ↔ `ssp/…/change-request-form.component.ts` |
| 7 | `ssp/…/intake-wizard.component.ts` ↔ `ssp/…/registratie-wizard.component.ts` |
**The auth duplication is a deliberate decision, not an accident** — see BL-002 before
proposing to merge it.
---
## 6. Layering and coupling
`npm run dep:check`: **0 violations** (223 modules, 584 dependencies cruised) across 11
`severity: error` rules — `shared-no-features`, `beheer-no-features`, `shared-no-beheer`,
`<app>-no-other-app`, per-context `<app>-<context>-scope`, `domain-is-pure`,
`contracts-import-nothing`, `ui-not-infrastructure`, `apiclient-infrastructure-only`,
`no-testing-in-production`, `no-circular`.
Instability I = Ce/(Ca+Ce). Low I = stable base, high I = volatile leaf. Measured in the
ssp cruise (behandelportal's figures for `libs/*` differ only in Ca, same shape):
| Folder | N | Ca | Ce | I |
| --------------------------------------- | --: | --: | --: | -----: |
| apps/ssp/src/app/brief | 46 | 2 | 143 | 99% |
| apps/ssp/src/app/herregistratie | 18 | 4 | 80 | 95% |
| apps/ssp/src/app/showcase | 4 | 1 | 17 | 94% |
| apps/ssp/src/app/registratie | 67 | 19 | 181 | 91% |
| apps/behandelportal/src/app/behandeling | 24 | 2 | 62 | 97% |
| apps/behandelportal/src/app/auth | 11 | 4 | 25 | 86% |
| apps/ssp/src/app/auth | 9 | 5 | 24 | 83% |
| libs/beheer/src/ui | 5 | 3 | 27 | 90% |
| libs/beheer/src/application | 3 | 2 | 16 | 89% |
| libs/beheer/src/infrastructure | 3 | 3 | 11 | 79% |
| libs/shared/src/layout | 19 | 22 | 52 | 70% |
| libs/shared/src/ui | 68 | 128 | 111 | 46% |
| libs/shared/src/infrastructure | 19 | 43 | 25 | 37% |
| libs/shared/src/application | 18 | 55 | 30 | 35% |
| libs/shared/src/upload | 5 | 30 | 13 | 30% |
| libs/beheer/src/domain | 5 | 8 | 3 | 27% |
| libs/shared/src/testing | 4 | 17 | 2 | 11% |
| libs/shared/src/kernel | 9 | 71 | 4 | **5%** |
| libs/shared/src/domain | 3 | 11 | 0 | 0% |
| libs/beheer/src/contracts | 1 | 0 | 0 | 0% |
This is textbook: `kernel`/`domain` are the stable base (I ≤ 5%), feature contexts are
volatile leaves (I ≥ 83%), nothing depends on them. **The frontend dependency structure
is not a problem area** — do not spend tickets here.
**The backend has no equivalent enforcement at all.** `Domain/` purity (verified: zero
`Microsoft.EntityFrameworkCore` / `Microsoft.AspNetCore` imports) holds by convention and
code review only. See BL-006.
---
## 7. Pattern inventory — what already exists
Agents 03 (DDD/hexagonal) and 04 (CQRS-light) may only **extend** what is listed here.
They may not introduce either pattern into a module where it is absent.
### Backend
| Pattern | State |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Ports with 2+ implementations | `IZaakSource` (`Data/LocalZaakSource.cs` ↔ `Zgw/OpenZaakZaakSource.cs`), `IDocumentSource` (`Data/LocalDocumentSource.cs` ↔ `Zgw/OpenZaakDocumentSource.cs`) — config-switched on `Zgw:Enabled` at `Program.cs:57-84`, ADR-0005 |
| Single-impl interface | `IIdentityProvider` → `StubIdentityProvider` |
| **Not behind any port** | 7 static, non-DI stores: `ApplicationStore`, `DocumentStore`, `BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`. No `AddDbContext`; each opens a short-lived context via `Db.Create()` under its own lock. Deliberate, documented in `Data/Db.cs` and `Program.cs:40-45` |
| Domain purity | `Domain/` is EF-free and ASP-free (verified). Rules are static classes of pure functions with co-located tests in `tests/Domain/`: `SubmissionRules`, `DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`, `HerregistratieRule`, `LetterHtml`, `OrgTemplateRules`, `Authz`, `FeatureFlags`. `Domain/Applications/Aanvraag.cs` is a C# tagged union (`Concept`/`Submitted`/`Decided`) |
| CQRS-light | **Partial.** `Contracts/Dtos.cs` holds 65 records split by direction (`*Request` in, `*Dto`/`*Response` out). Read/write split exists as _comment banners_ inside a single **940-line `Program.cs`** carrying all 48 endpoint mappings. No handler types, no mediator, no `Features/` folders. Cross-cutting behaviour is factored into local helpers (`Submit`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) — authorization/idempotency wrappers, not handlers |
| Mapping | `Contracts/Mappers.cs` (`.ToDto()`, `.ToDetailDto()`), `Data/AanvraagMapper.cs` |
| ZGW anti-corruption layer | Fully built: `Zgw/{OpenZaakZaakSource,OpenZaakDocumentSource,ZgwHttpClient,ZgwTokenProvider,ZgwZaakMapper,ZgwOptions,ZgwDiagnosticHandler}.cs`, 5 test files. ADR-0005 |
### Frontend
| Pattern | Count | Notes |
| ------------------------------------- | ----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Command factories (write side) | 3 | `registratie/application/submit-change-request.ts`, `behandeling/application/submit-besluit.ts`, `registratie/application/draft-sync.ts` |
| Mutations living _inline in adapters_ | ~13 | `brief.adapter.ts` (save/submit/approve/reject/send/reset), `org-template.adapter.ts` (save/publish/rollback), `stamdata.adapter.ts` — they call `runSubmit` directly instead of going through a command factory |
| Infrastructure adapters (read side) | 20 | +1 outside an `infrastructure/` folder: `libs/shared/src/upload/upload.adapter.ts` |
| `parse*` trust boundaries | 30 | 24 in adapters, 6 in value objects / kernel |
| Application stores | 15 | all `providedIn: 'root'` |
| Elm-style machines | 9 | 8 under a `domain/` folder; outlier `libs/shared/src/upload/upload.machine.ts` |
| Explicit port | 1 | `SessionPort` + `SESSION_PORT` token (`libs/shared/src/application/session.port.ts`) |
| Config-seam tokens | 3 | `DEBUG_PANEL`, `HEADER_NAV_ITEMS`, `HEADER_ADMIN_LINKS` |
| `contracts/` DTO files | 4 | most adapters consume NSwag-generated types directly instead |
Shared application kit (`libs/shared/src/application/`): `remote-data.ts`, `store.ts`,
`submit.ts`, `action-state.ts`, `debounced-save.ts`, `history.ts`,
`machine-remote-data.ts`, `pending-saves.ts`.
### ADRs on record
`0001` BFF-lite + decision DTOs (Accepted) · `0002` user groups as actors, not bounded
contexts (was **Proposed** when measured; promoted to **Accepted** 2026-08-26 by ADR-C-005,
with §3's `Principal` omission recorded in the ADR as known debt) · `0003` CIBG Huisstijl
(Accepted) · `0004`
stamdata as code (Accepted) · `0005` OpenZaak behind the BFF — the explicit
ports-and-adapters ADR (Accepted) · `0006` test data through the production door
(Accepted).
---
## 8. Rankings, worst to best
**Spec reach (FE):** ssp/root · ssp/shell · bhp/root · bhp/shell · libs/shared/domain ·
libs/beheer/ui · libs/beheer/contracts (all 0%) → libs/shared/layout 18% →
bhp/behandeling 31% → libs/shared/ui 38% → ssp/brief 42% → … → libs/shared/kernel 100%.
**Line coverage (FE, of reached files):** ssp/auth · bhp/auth 42.9% →
libs/shared/upload 52.0% → libs/beheer/application 65.7% → … → ssp/showcase 100%.
**Branch coverage (BE):** Contracts 65.0% → Stamdata 71.7% → Data 75.5% → Domain 82.0% →
Program.cs 84.8% → Zgw 85.5%.
**Duplication:** ssp/auth 100% → bhp/auth 86.8% → bhp/root 75.6% → ssp/root 55.7% →
bhp/shell 38.1% → ssp/herregistratie 7.9% → … → 12 modules at 0%.
**Complexity (TS, CC>10 count):** ssp/registratie 7 → ssp/brief 6 → ssp/herregistratie 4
→ bhp/behandeling 3 → 5 modules with 1 → the rest 0.
**Complexity (C#, file CC):** Program.cs 78 → Data 101 across 23 files (max 27) →
Domain 71 (max 21) → Zgw 39 → Stamdata 34 → Contracts 4.
**Coupling:** nothing to rank — 0 violations, healthy instability gradient (§6).
---
## 9. Thresholds Phase 1 must use
Derived from the measured distributions above, not invented.
| Threshold | Value | Basis |
| ---------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------- |
| Cyclomatic complexity | **> 10** | fixed by the agent prompt; TS p99 is 12, so this catches the top ~1% |
| **Agent 01's `[N] lines` — TS function** | **> 40** | TS fn-length p99 is 34; only 4 functions exceed 50 |
| Agent 01's `[N] lines` — C# method | **> 60** | C# method p99 is 90, p90 is 22 |
| Agent 01's `[N] lines` — component/file | **> 400** | 6 TS files and 1 C# file exceed it |
| Nesting depth | **> 3** | fixed by the agent prompt |
| Duplication worth a ticket | **> 10% of a module's significant lines** | repo mean is 7.1% |
Largest files (the >400 population): `backend/Program.cs` 940 ·
`registratie/ui/registratie-wizard.component.ts` 645 · `showcase/concepts.page.ts` 496 ·
`brief/ui/letter-canvas.component.ts` 463 · `brief/infrastructure/brief.adapter.ts` 437 ·
`herregistratie/ui/intake-wizard.component.ts` 406.
---
## 10. Baseline observations (cite these)
Stable IDs. A Phase 1 finding must reference one of these or a metric row above.
**BL-001 — 23 of 25 TS functions over CC 10 are mandated house idioms.**
Reducers (9), `parse*` trust boundaries (10) and `validate*` (4) are switch/guard-dense
by construction: CLAUDE.md §3 requires a tagged-union reducer over booleans, and ADR-0001
requires a hand-written `parse*` at every wire boundary. High CC there is the design
working, not decaying. Only two CC>10 functions are outside the idiom:
`api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11).
_Any complexity ticket against a `reduce*`/`parse*`/`validate*` must argue why this case
differs — a bare CC number is not sufficient grounds._
**BL-002 — `ssp/auth` is 100% duplicated against `bhp/auth`, deliberately.**
211 of 211 significant lines; `session.store.ts`, `login-form.component.ts`, `login.page.ts`
and `auth.guard.ts` are near-identical. CLAUDE.md §1 and ADR-0002 state auth is _not_
shared because Zorgverlener and Medewerker are different `Principal` variants expected to
diverge. **The divergence has not happened yet.** This is legitimately in scope for
agent 06 (ADR conformance) as either an `ADR-fix` — the prediction has not held over two
phases of work — or a "still waiting" note. It is _not_ a straightforward
extract-to-shared refactor; that would contradict an accepted ADR.
> **Sharpened 2026-08-26 by agent 06 (ADR-C-004/005) — verified.** "The prediction has
> not held" is the wrong diagnosis. ADR-0002 §3's concrete deliverable, `Session →
Principal`, was **never built**: `grep -rn "Principal" apps libs` returns exactly one
> hit, a comment in `libs/shared/src/infrastructure/role.ts:8`, and no type. `diff -rq`
> over the two auth folders shows **zero** content differences — 9 of 11 files identical,
> the only delta being two extra files in behandelportal. `behandelportal`'s Behandelaar
> still carries a `bsn` and `login.page.ts:31` logs a backoffice user in through DigiD.
> The rule was not falsified, it was untested; the divergence that did occur went through
> an orthogonal side door (`medewerker.interceptor.ts`) that never touches `Session`.
> Amending ADR-0002 would ratify the omission. Use agent 06's sequencing instead.
**BL-003 — `Program.cs` is the single largest complexity concentration in the repo.**
940 lines, 48 endpoint mappings, file CC 78 (next-highest file: 27), read/write separated
only by comment banner. 97.4% line / 84.8% branch covered, so it is well-tested, not
fragile — this is a structure finding, not a correctness one. It is the one place where
agent 04's CQRS-light and agent 03's vertical-slice thinking both have real purchase,
_and_ the one place where "extend the existing pattern, don't introduce one" is hardest to
honour: there is no `Features/` folder to extend.
**BL-004 — 122 of 220 FE source files (55%) are never loaded by any Vitest run.**
Overwhelmingly `ui/` components, which CLAUDE.md §5 covers via 66 Storybook stories with
the a11y addon instead. Agent 02 must distinguish _"untested"_ from _"tested through
Storybook"_ before filing; the genuine gaps are non-`ui/` files with no spec —
`libs/shared/domain` (3 files, 0%), `libs/beheer/contracts`, and the app root/shell files.
> **Corrected 2026-08-26 by agent 02 — verified.** The two named "genuine gaps" are false
> positives. `libs/shared/src/domain` is 30 lines across 3 files — interfaces and type
> aliases plus a single string const — and `libs/beheer/src/contracts/stamdata.dto.ts` is
> 30 lines of DTO shapes. Neither contains an executable statement, so 0% is correct and
> unimprovable. Close both rows rather than ticketing them. Only the app root/shell files
> remain as candidates from this observation.
**BL-005 — backend branch coverage lags line coverage by 18 points** (97.6% vs 79.6%).
Weakest: `Contracts` 65.0%, `Stamdata` 71.7%, `Data` 75.5%. Line coverage is near-total,
so the missing tests are edge-case branches, not whole units.
**BL-006 — the backend has zero automated architecture enforcement.**
The frontend has 11 dependency-cruiser rules at `severity: error`, 0 violations, running
in CI. The backend has none: one assembly, no `Domain.csproj` boundary, no
NetArchTest/ArchUnitNET. `Domain/` purity currently holds by convention. Any agent-03
proposal that depends on the backend's layering staying clean should note that nothing
enforces it.
**BL-007 — the FE write side is inconsistently placed.**
3 command factories vs ~13 call sites invoking `runSubmit` directly inside adapters
(`brief.adapter.ts`, `org-template.adapter.ts`, `stamdata.adapter.ts`). CLAUDE.md §3 makes
the command factory the idiom. This is agent 04's clearest extend-an-existing-pattern
target — the pattern exists, it is just not applied uniformly.
> **Corrected 2026-08-26 by agent 04 (CQ-003/CQ-005) — verified.** The "~13 mutations"
> count was derived from the `runSubmit` helper name and is wrong: 5 of those call sites
> are **reads**, not writes. `libs/beheer/src/infrastructure/stamdata.adapter.ts` exposes
> only `list()` and `load()` and its own docstring says "Both endpoints are reads … There
> is no write method", yet both call `runSubmit`. Same at `brief.adapter.ts:56` and
> `org-template.adapter.ts:39,51`. `runSubmit` is the write fold — it mints the
> Idempotency-Key — so the name, not the code, produced the miscount. Conversely agent 04
> found **3 mutations this baseline missed entirely**: `ApplicationsStore.cancel`,
> `AdminCasesStore.delete`, `FeatureFlagStore.set` reach the raw `ApiClient` and never
> return a `Result`. Use agent 04's inventory, not this count.
**BL-008 — `coverageExclude` does not exclude the generated API client.**
`libs/shared/src/infrastructure/api-client.ts` is listed in `coverageExclude` in all four
`angular.json` test targets, yet appears in all four `lcov.info` files (987 lines at ~7%),
dragging the reported `libs/shared/infrastructure` figure from 94.7% down to 6.9%. This
file excludes it manually. Small, real, and cheap to fix.
**BL-009 — no coverage threshold is enforced anywhere.**
Neither `angular.json` nor CI sets a minimum; `npm run test:coverage` runs in CI and the
output is discarded. There is no ratchet, so no ticket can be verified as "improved
coverage" by CI alone — verify against the numbers in this file.
**BL-010 — `libs/shared/src/upload/` sits outside the layer convention.**
`upload.machine.ts` (CC 27, 109 lines — the highest in the repo) lives in its own
top-level folder rather than under `domain/`, and `upload.adapter.ts` outside
`infrastructure/`. It is carved out by name in the `apiclient-infrastructure-only`
dependency-cruiser rule, i.e. the exception is already encoded rather than resolved.
**BL-011 — the FE test suite is flaky under parallel load.**
`npm run test:coverage` failed on the first run with two `[vitest-pool] Timeout waiting
for worker to respond` errors in `libs/shared`, then passed 21/21 when re-run serially.
Not a code defect; relevant to any ticket whose acceptance is "CI green".
---
## 11. Reproducing this file
```bash
npm run test:coverage # if flaky, re-run the failing project alone
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" \
--collect:"XPlat Code Coverage"
npx eslint apps libs --rule '{"complexity":["warn",0],"max-lines-per-function":["warn",{"max":0,"skipBlankLines":true,"skipComments":true}]}' -f json
node docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs
npm run dep:check
npx depcruise apps/ssp/src libs --config .dependency-cruiser.ssp.js --metrics --output-type metrics
npx depcruise apps/behandelportal/src libs --config .dependency-cruiser.behandelportal.js --metrics --output-type metrics
```
The `baseline-scan.mjs` figures are deterministic and must reproduce exactly. Coverage
figures move with the tests.
@@ -0,0 +1,9 @@
## Scope: [to be filled by agent]
## Status: not_started
## Last updated: -
## Depends on: [see agent prompt]
## ---
@@ -0,0 +1,641 @@
## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (domain, application, infrastructure, ui, layout, kernel, upload, testing), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata, tests)
## Status: complete
## Last updated: 2026-08-26
## Depends on: 00-baseline.md
## ---
# 02 — Testability
What blocks a **unit** test — static/singleton dependencies, hidden I/O, work in
constructors/field initializers, pure logic entangled with impure. Every finding cites a
`BL-###` or a metric row from `00-baseline.md`. Seams proposed are extractions, never
rewrites.
## How this file reads the baseline
Three filters were applied before anything was written down, and they killed more
candidates than they kept:
1. **BL-004's carve-out is honoured.** No finding is filed against a `ui/` (or
`layout/` component) file for lacking a Vitest spec. 66 Storybook stories + the a11y
addon are the house strategy (CLAUDE.md §5), not a gap.
2. **"Untested" ≠ "untestable".** Several of the worst-covered non-`ui/` files are
perfectly injectable and simply have no spec (`submit-besluit.ts`,
`Contracts/Mappers.cs`, `breadcrumb-trail.ts`). Those are noted in their module
section but not filed as testability findings — there is no seam to add. Whoever
owns coverage should pick them up.
3. **Already-covered code needs a positive argument.** Backend line coverage is 97.6%
(BL-005); a "this is untestable" claim there has to point at a branch the current
test shape genuinely cannot reach. Two do (TE-007, TE-008); one points at the cost
of how it is reached (TE-009).
**Two baseline items are closed as false gaps** — see `libs/shared/domain` and
`libs/beheer/contracts` below. BL-004 names both as "genuine gaps"; on inspection
neither contains an executable statement.
**Deliberate decisions engaged with, not overridden:** the 7 static backend stores
(documented in `Data/Db.cs`) are left alone — TE-009 extracts rules _out_ of one of
them without touching its shape. BL-002's auth duplication is respected — TE-004 lands
the same seam twice rather than proposing a shared extraction.
---
## apps/ssp — auth
**TE-001 — `SessionStore.restore()` reads `localStorage` inline, so its shape guard cannot be unit-tested**
- Module / file:line — `apps/ssp/src/app/auth/application/session.store.ts:12-21`
- **What blocks unit testing.** `restore()` is module-private and calls
`localStorage.getItem(STORAGE_KEY)` itself, then does the parse + shape validation in
the same function. It is invoked from a field initializer
(`private _session = signal<Session | null>(restore())`, L37), so the storage read
happens the instant the singleton is constructed. A spec cannot feed it a raw string;
it must stub the `localStorage` global before the injector builds the store. The
logic being guarded is not incidental — the comments mark it G1 (never persist the
BSN) and G2 (validate the shape before trusting it), i.e. a trust boundary, and
CLAUDE.md §5 mandates a spec for boundary `parse*` adapters.
- **Baseline citation.** §3a: `ssp/auth` 42.9% line / 46.2% branch — **jointly the worst
line coverage in the frontend table** (§8 ranking). Per-file lcov for this file:
**LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13 (23.1% branch)** — 4 of the module's 6
files are spec-reached (§3b, 67%), yet this one barely executes.
- **Minimal seam.** Split the pure half out and move it next to the type it produces:
`export function parseStoredSession(raw: string | null): Session | null` in
`auth/domain/session.ts` — which **already has a spec file**
(`auth/domain/session.spec.ts`) and is pure TS, so no new test scaffolding is needed.
`restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))`. Three
test cases (absent, non-JSON, wrong shape) cover the guard.
- **Effort S.** Independently shippable in one deploy — pure move, no call-site change
outside the file.
- **Note on BL-002.** `bhp/auth` carries the identical function; the seam lands **twice**,
once per app. That is correct, not duplication to fix — ADR-0002 / CLAUDE.md §1 make
`auth` deliberately unshared, and BL-002 flags any extract-to-shared here as
contradicting an accepted ADR. Agent 06 owns whether that prediction still holds.
## apps/ssp — registratie
**No findings.**
The module's shape is the reason. Every `parse*` in its six adapters is exported and
directly spec'd (`applications`, `big-register`, `brp`, `dashboard-view`, `duo` all have
`.spec.ts` files); the machines are pure `domain/` units with specs; the five value
objects each have one.
`createDraftSync` deserves an explicit acquittal: at 143 lines it is the longest function
in the repo (§4a, "Functions over 75 lines — the entire population") and it owns a
`setTimeout` debounce, a `Router` navigation and an in-flight-create race guard. It is
nevertheless **the best-seamed effectful unit in the frontend** — deps arrive through an
explicit `DraftSyncDeps` object (`draft-sync.ts:24-33`), `Router`/`ActivatedRoute` are
`inject(..., { optional: true })` so it is inert without them, and `enabled()` exists
specifically so stories and tests can neutralize it (L31-32). It has a spec. Its length
is agent 01's call, not a testability defect.
`BigProfileStore` creates two `resource()`s in field initializers (constructor-time I/O),
which is normally a blocker — but the store is pure glue over `parseDashboardView`
(exported, spec'd) and `map`/`fromResource` (spec'd), so there is no untested decision
hiding behind the construction. §3a: 80.0% line / 77.3% branch, §3b 51% reach with the
20 unreached files being 11 `ui/` components (BL-004) and 3 pure-type `contracts/` files.
## apps/ssp — herregistratie
**No findings.** §3a 70.9% / 67.8%, §3b 56% reach. The four unreached files are the
`ui/` pages and wizard organisms (BL-004) plus `intake-policy.store.ts`, a thin
`resource()` wrapper over the exported-and-spec'd `parseIntakePolicy`. Both machines are
pure, Angular-free and carry four spec files between them, including an acceptance spec.
`intake.testing.ts` gives the wizard specs a fixture builder — the seam already exists.
## apps/ssp — brief
**TE-002 — `RevealBigNummerAdapter` hides a trust boundary inside a global-`fetch` method**
- Module / file:line — `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:32-41`
- **What blocks unit testing.** The shape validation of the response body — the code's
own comment calls it a "Trust boundary" — is written inline inside `async reveal()`,
after an `await fetch(...)` on the **global** `fetch` (L24). There is no injected transport. To assert that a
`{ bigNummer: 42 }` response is rejected, a spec must stub `globalThis.fetch`; the
boundary itself is not callable. Every other `parse*` in the repo is exported (30 of
them, §7) — this one is the outlier, and it guards a PII reveal (PRD-0002 §5c). The
same shape recurs in the sibling hand-written-`fetch` adapters:
`letter-preview.adapter.ts:56-62` (`errorMessage`) and
`org-template.adapter.ts:82-89` (the proefbrief error mapping) — both un-exported,
both unreachable without a `fetch` stub.
- **Baseline citation.** §3b: `ssp/brief` **42% spec reach** (11 of 26 files) — the
lowest of any non-zero context outside `bhp/behandeling`; §3a 75.3% line / **68.8%
branch**. All three `fetch` adapters are among the 15 unreached files, and none of
them is a `ui/` component, so BL-004's Storybook carve-out does not cover them.
- **Minimal seam.** Export the pure half as a named boundary, matching the file's 30
siblings: `export function parseRevealed(body: unknown): Result<string, string>` —
five lines moved verbatim out of the method, which becomes
`return res.ok ? parseRevealed(await res.json().catch(() => null)) : err(...)`. Same
move for `errorMessage` in the other two adapters (already separate functions; they
only need `export` + a spec). No transport abstraction, no `HttpClient` migration —
the hand-written `fetch` stays, and the documented reasons for it
(`.ExcludeFromDescription()`, per-request headers) are untouched.
- **Effort S.** Independently shippable.
## apps/ssp — showcase, shell, root
**No findings.**
§3b lists `ssp/root` + `ssp/shell` (with the behandelportal equivalents) at **0% reach,
12 files**. That is the correct number for what these files are: `main.ts`,
`app.config.ts`, `app.routes.ts`, `app.ts` and `shell/nav.config.ts` are composition
roots and static data — a spec asserting a provider array restates it. `debug-state`
is a dev-only devtool with a Storybook story.
One honest note, no ticket: `shell/debug-state/mask.ts::redactProfile` is a pure,
Angular-free PII-redaction function (it maps a `BigProfile` to a redacted shape) with no
spec and **no blocker** — it is directly callable today. Its dependencies
(`maskTail`, `REDACTED`) are in `libs/shared/kernel/pii.ts`, which is spec'd at 96.4%.
Missing test, not blocked test.
`showcase` is 100% line coverage on its one reached file (§3a); `snippets.generated.ts`
is generated and `concepts.page.ts` is a teaching page.
## apps/behandelportal — auth
**TE-001 applies here identically** — `apps/behandelportal/src/app/auth/application/session.store.ts:12-21`,
same `restore()`, same `localStorage` read in a field initializer, same §3a row
(`bhp/auth` 42.9% / 46.2%). Fix it in `apps/behandelportal/src/app/auth/domain/session.ts`,
which also already has a spec. Counted once as TE-001; it is two commits, or one commit
touching two apps.
No additional findings. `medewerker.ts:17-22` reads `window.location.search` +
`sessionStorage` directly, but it is a five-line dev-only role stand-in with the same
shape as `libs/shared/infrastructure/role.ts` — whose twin `subject.ts` is spec'd, so
the pattern is demonstrably testable as written.
## apps/behandelportal — behandeling
**No findings.**
§3b 31% reach (5 of 16) looks alarming and is not: the 11 unreached are 5 `ui/` files
(BL-004), the three `resource()`-wrapper stores, and the two command factories. §3a
records **91.6% line / 81.5% branch** on what is reached — the highest line coverage of
any frontend module in the table.
Explicitly not a testability finding: `application/submit-besluit.ts` is
**structurally identical** to `apps/ssp/src/app/registratie/application/submit-change-request.ts`,
which has a spec (`submit-change-request.spec.ts`). Same `inject()` + `runSubmit`
factory, same signature shape. It is not blocked by anything; it is a missing spec whose
template already exists in the repo. Both adapters' `parse*` functions are exported and
spec'd (`beoordeling.adapter.spec.ts`, `werkvoorraad.adapter.spec.ts`).
## apps/behandelportal — shell, root
**No findings.** Same composition-root reasoning as `ssp/shell + root` above.
## libs/shared — domain
**No findings — BL-004's "genuine gap" is a false positive here, and can be closed.**
§3b lists `libs/shared/domain` at **0% reached, 3 files**, and BL-004 names it first
among "the genuine gaps are non-`ui/` files with no spec". Reading all three files
(30 lines total): `capability.ts` is a 9-member string-literal union, `role.ts` is a
3-member union, `feature-flag.ts` is one `interface` plus one exported string constant.
**There is no executable statement in the folder.** 0% is the correct and unimprovable
number; the types are checked by `tsc` and their runtime counterparts are validated in
`parseMe` (spec'd, 94.7% infrastructure coverage). No ticket should be written against
this row.
## libs/shared — application
**No findings.**
§3a 80.3% / 70.0%, §3b 73% reach (8 of 11). The three unreached are `session.port.ts`
(an `InjectionToken` + interface — a declaration, nothing to run), `feature-flags.store.ts`
and `access.store.ts`. The seam kit itself (`remote-data`, `store`, `submit`,
`history`, `pending-saves`, `machine-remote-data`, `debounced-save`) is fully spec'd —
this is the folder that makes the rest of the frontend testable.
Noted without a ticket: `AccessStore.can()` (`access.store.ts:34-37`) is a
deny-by-default security gate whose decision reduces to
`rd.tag === 'Success' && rd.value.includes(capability)` over a `resource()` created in a
field initializer. The decision is two lines; the substance it guards
(`parseMe`, where a real silent-deny bug shipped — see the WP-66 regression test in
`me.adapter.spec.ts:26-31`) is already exported and thoroughly spec'd. Extracting a pure
`canFrom(rd, cap)` would be honest but buys close to nothing. Filing it would be volume,
not quality.
## libs/shared — infrastructure
**No findings.**
§3a 94.7% line / 81.0% branch, §3b 82% reach — the second-best module in the repo.
BL-001 singles out `api-client.provider.ts:49 fetch` (CC 19) as one of only two CC>10
functions outside the mandated idioms, so it is worth stating why it is _not_ a
testability finding: `httpClientFetch(http: HttpClient)` takes its dependency as an
ordinary function parameter (L47) rather than injecting it, and it has a spec
(`api-client.provider.spec.ts`). Its complexity is agent 01's call. The module-level
mutable `pendingIdempotencyKey` (L21) is self-clearing in a `finally` (L25), so it does
not leak between tests.
## libs/shared — ui
**No findings — BL-004 governs.** 34 files, 13 reached; the unreached 21 are components
covered by the Storybook + a11y strategy CLAUDE.md §5 mandates. The one non-component
module in the folder, `rich-text-editor/rich-text-dom.ts` (home of `collect`, CC 11 —
the other non-idiom CC>10 function per BL-001), **is** spec'd
(`rich-text-dom.spec.ts`). The layer is doing what the house rules ask.
## libs/shared — layout
**No findings.**
§3b 18% reach (2 of 11) is the lowest non-zero row, but 8 of the 9 unreached are
components (`shell`, `page-shell`, `site-header`, `site-footer`, `breadcrumb`,
`language-switcher`, `wizard-shell`) — BL-004 applies to `layout/` exactly as to `ui/`,
since CLAUDE.md §5 titles both under `Design System/`.
Two non-component files, neither ticketed:
- `breadcrumb/breadcrumb-trail.ts::trailFor` is a pure exported function with a subtle
parent-walk and a `delete trail[last].link` mutation, and has no spec. **No blocker** —
it is directly callable, and its sibling `language-switcher/locale-links.ts` is the
spec'd proof. Missing test, not blocked test.
- `route-focus.ts` is a 20-line `ENVIRONMENT_INITIALIZER` wrapping a `Router` subscription
and `afterNextRender`. Genuinely awkward to unit-test, but it is a11y wiring with no
branch worth asserting; a seam here would cost more than it returns.
## libs/shared — kernel
**No findings.** §3a 96.4% line / 90.0% branch, §3b **100% reach** — the best module in
the repo, and (§6) the most-depended-on at I = 5% with Ca 71. Pure functions, all spec'd.
This is the reference standard the other findings point back at.
## libs/shared — upload
Three findings. This module carries the frontend's weakest testability profile, and
BL-010 already flags it as sitting outside the layer convention.
**TE-003 — `UploadShellService` declares a port, then injects the concrete class instead**
- Module / file:line — `libs/shared/src/upload/upload-shell.service.ts:12-24` and `:35`
- **What blocks unit testing.** The file defines `export interface UploadTransport` and
documents it as _the_ swap seam ("swapping it in touches only this interface", L10-11).
It then binds it as
`private transport: UploadTransport = inject(KeepaliveTransport)` (L35) — the
**concrete class**, which is `@Injectable` but **not exported** (L18). A spec that
wants a fake transport cannot reference the class to override its provider, and cannot
provide against the interface (interfaces are not DI tokens). Result: every one of
`upload()`, `delete()`, `cancel()` and `pollReturning()` — the code that translates
transport outcomes into `UploadMsg`s — is reachable only through a real
`XMLHttpRequest`. The port exists on paper and does nothing.
- **Baseline citation.** §3a: `libs/shared/upload` 52.0% line / 50.0% branch — the
worst line coverage of any module except the two `auth` rows. §3b: 50% reach, and
per the lcov file list the **two unreached files are `upload-shell.service.ts` and
`upload-controller.ts`** — neither is a `ui/` component, so this is exactly the
non-`ui/` gap BL-004 says is genuine.
- **Minimal seam.** Add the token the repo already uses elsewhere:
`export const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT',
{ providedIn: 'root', factory: () => inject(KeepaliveTransport) })`, then
`inject(UPLOAD_TRANSPORT)` on L35. This **extends an existing pattern** — §7 records
exactly one explicit port in the frontend, `SessionPort` + `SESSION_PORT`
(`libs/shared/src/application/session.port.ts`), with the same interface-plus-token
shape. Runtime behaviour is byte-identical; the default factory returns the same
instance.
- **Effort S.** Independently shippable in one deploy.
**TE-004 — `createUploadController` performs injection, DOM subscription and an `effect()` at call time**
- Module / file:line — `libs/shared/src/upload/upload-controller.ts:23-46`, policy at `:62-74`
- **What blocks unit testing.** Calling the factory does four irreversible things before
returning: three `inject()` calls (L24-25, L46), an `effect()` registration (L31), and
`window.addEventListener('focus', onFocus)` (L45). It must therefore run inside a
`TestBed` injection context with `UploadAdapter`, `UploadShellService` and `DestroyRef`
all satisfied — and `UploadShellService` is itself un-fakeable per TE-003, so the
mocking cost compounds. What is trapped behind that cost is real policy:
`onFileSelected` (L62-74) decides per file whether to emit `FileRejected` with reason
`'multiple'`, `FileRejected` with a `rejectReason` result, or to start an upload — a
decision over `(categories, categoryId, files)` with no I/O in it.
- **Baseline citation.** §3a `libs/shared/upload` 52.0% / 50.0%; §3b 50% reach with this
file among the two unreached. §4a additionally records the module's `max CC 27` and the
repo's only two >75-line functions include `reduceUpload` (109 lines) — the reducer this
controller feeds. The reducer is spec'd (`upload.machine.spec.ts`); the code choosing
_which_ messages reach it is not.
- **Minimal seam.** Pure-function split into the file that is already the tested unit:
add `export function planFileSelection(state: UploadState, categoryId: string, files:
{ name: string; type: string; size: number }[]): UploadMsg[]` to `upload.machine.ts`,
moving L62-73 verbatim. `rejectReason` — the predicate it calls — is already exported
from that file and already spec'd, so the move is downhill. The controller keeps the
`crypto.randomUUID()` + `files.set()` + `shell.upload()` side effects and just executes
the plan. No change to the controller's public surface or to the organism that calls it.
- **Effort S.** Independently shippable.
**TE-005 — `UploadAdapter.xhrUpload` buries response interpretation inside an `XMLHttpRequest` closure**
- Module / file:line — `libs/shared/src/upload/upload.adapter.ts:113-157`, helpers at `:169-199`
- **What blocks unit testing.** The method constructs `new XMLHttpRequest()` directly
(L118) — no transport parameter, no injected factory — and attaches four listeners
whose bodies contain the actual decisions: 2xx-vs-not (L131), `JSON.parse` of the body
with a fallback (L132-136), ProblemDetails mapping via the un-exported `parseError`
(L193-199), and abort-vs-error disambiguation (L142-144). None of it can be reached
without stubbing the XHR global. Compounding it, the method also branches on
`currentScenario()` at L115 and returns a `setTimeout`-driven dev simulator
(`simulateUpload`, L169-192), so a dev-only fake and the production transport share
one entry point.
- **Baseline citation.** Per-file lcov: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57
(5.3% branch)**. The file is counted as "reached" in §3b only because another spec
imports it — essentially nothing in it executes. It is the single largest contributor
to the module's 52.0% / 50.0% row in §3a.
- **Minimal seam.** Extract the interpretation, not the transport:
`export function uploadOutcome(status: number, responseText: string): Result<string,
{ documentId: string }>` containing L131-139's logic plus `parseError`. The listener
becomes a two-line dispatch into it. Optionally (same ticket, still small) move the
`currentScenario()` branch from L115 up into `KeepaliveTransport.send()` — the seam
TE-003 makes usable — so `xhrUpload` is transport only. Do **not** abstract
`XMLHttpRequest`: the file documents why XHR is required (progress events +
cancellation, which `fetch` cannot give) and that reason still holds.
- **Effort S** for `uploadOutcome` alone, **M** if the scenario branch moves too.
Independently shippable; sequence it after TE-003 if both are taken.
## libs/shared — testing
**No findings.** §3a 100% line coverage. `given()` (`machine.ts`) and the `RemoteData`
constructors (`remote-data.ts`) are the DSL the domain specs are built on, and the
`no-testing-in-production` dependency-cruiser rule (§6) keeps them out of shipped code.
The gap this folder does _not_ yet cover is a `resource()`-shaped fake — which is why
`AccessStore`/`BigProfileStore` stay unreached — but adding one is a test-infrastructure
task, not a source-code seam, and no metric row demands it.
## libs/beheer
**TE-006 — blob-to-browser handoff is inlined in three application-layer commands**
- Module / file:line — `libs/beheer/src/application/stamdata.store.ts:137-147`;
also `apps/ssp/src/app/brief/application/brief.store.ts:230` and
`apps/ssp/src/app/brief/application/org-template.store.ts:217`
- **What blocks unit testing.** Each of the three commands ends in raw DOM/browser API
calls that jsdom cannot meaningfully execute: `StamdataStore.download()` does
`URL.createObjectURL` → `document.createElement('a')` → `a.click()` →
`URL.revokeObjectURL`; `BriefStore.previewLetter()` and
`OrgTemplateStore.proefbrief()` both do `window.open(URL.createObjectURL(blob),
'_blank')`. Because the call is the **last statement**, the entire success path of each
command is unassertable — a spec can only exercise the early-return/failure branches.
`brief.store.spec.ts` demonstrates this exactly: it tests `previewLetter`'s failure
case (which returns at the `!r.ok` guard) and cannot test the success case. In
`download()` the untestable tail sits directly behind a two-clause guard
(`if (!s || !this.canDownload()) return;`, L139), so the guard's true-branch is
permanently dark.
- **Baseline citation.** §3a: `libs/beheer/application` **40.5% branch — the worst
branch coverage of any frontend module in the table**, and its 65.7% line figure is
third-worst. Per-file lcov confirms `stamdata.store.ts` _is_ that row: LH 46 / LF 70,
**BRH 15 / BRF 37**. On the brief side, §3a `ssp/brief` is 68.8% branch and
`brief.store.ts` measures **BRH 32 / BRF 64 — exactly 50%**.
- **Minimal seam.** One small injectable in `libs/shared/src/application`, mirroring the
`SESSION_PORT` token shape already in that folder:
`export const BLOB_PRESENTER = new InjectionToken<{ open(b: Blob): void; download(b:
Blob, filename: string): void }>('BLOB_PRESENTER', { providedIn: 'root', factory: () =>
realBlobPresenter })`. The three commands each lose 1-4 lines of DOM code and gain one
method call; specs provide a recording fake and finally assert the success paths
(including `toJson(...)`'s output actually reaching the file, which today is only
tested one level down in `beheer/domain`). The content-producing logic stays exactly
where it is.
- **Effort S** (one token + three one-line edits) — **M** including the specs the seam
unlocks. Independently shippable; the three call sites can also land separately.
**Other beheer layers — no findings.**
- `libs/beheer/contracts` — §3b lists it at **0% reached, 1 file**, and BL-004 names it
as a genuine gap. It is not: `stamdata.dto.ts` is 30 lines of `interface` and `type`
declarations with **zero executable statements** and, by design, zero imports (it is
the wire seam). Like `libs/shared/domain`, this row should be closed rather than
ticketed.
- `libs/beheer/ui` — 0% reach, 4 files, all components → BL-004 / Storybook.
- `libs/beheer/domain` — 98.1% line, spec'd machine and rules. Nothing blocked.
- `libs/beheer/infrastructure` — `parseStamdataTable`/`parseColumn`/`parseRows` all
exported and spec'd; the 60.5% branch figure is unexercised defensive arms in an
otherwise open unit.
---
## backend/Program.cs
**No findings.**
§3c: 97.4% line / 84.8% branch, the second-best branch figure on the backend. Ten
endpoint bodies read the wall clock inline (`DateTimeOffset.UtcNow` at L282, L286, L301,
L390, L394, L426, L435, L449, L482, L931; `DateOnly.FromDateTime(DateTime.Today)` at
L138), which would normally be a finding — but **every rule and mapper they hand it to
already takes the instant as a parameter**: `HerregistratieRule.Evaluate(reg, today)`,
`ToDetailDto(a, now)`, `ToStatusDto(a, now)`, `ListCases(now)`,
`ApplicationStore.RecordBesluit(..., now)`. The clock-dependent _decisions_ are all
testable at any date; only the endpoint wiring is pinned to now, and that wiring is what
`EndpointTests`/`AdminCasesTests` legitimately cover through the host. Injecting
`TimeProvider` into 48 minimal-API lambdas would be a rewrite, not a seam, and no metric
row asks for it.
BL-003 (940 lines, file CC 78, read/write split by comment banner) is a **structure**
finding, explicitly, and belongs to agents 03/04.
## backend/Domain
**TE-007 — `LetterHtml.ResolveAuto` reads the wall clock although `Render` is already given the instant**
- Module / file:line — `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs:138`
(resolver) vs. `:27` (signature) and `:49` (the correct usage)
- **What blocks unit testing.** `Render(BriefEntity brief, OrgTemplateDto template,
string at, bool watermark)` already accepts the letter's instant, and uses it properly
for the letterhead: `sb.Append(Enc(FormatDatumNl(at)))` at L49. But the body's
`datum` placeholder resolves through `ResolveAuto`, which ignores `at` and calls
`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` (L138). `ResolveAuto` is
`private static`, reached only via `RenderNode` ← `RenderParagraphs` ← `Render`, so a
test has no way to pin the value: it can only assert "whatever today is". The gap is
visible in the existing test file — `LetterHtmlTests.cs:26` declares a
`new PlaceholderDefDto("datum", "Datum", true)` in the fixture and **no assertion
anywhere in the file checks what it renders to**. This is a pure `Domain/` rule class
reaching for ambient state, which is precisely the purity §7 credits the folder with.
- **Baseline citation.** §3c: `backend/Domain` **82.0% branch** — the third-weakest
branch axis (BL-005). §4b: `Domain/Letters/LetterHtml.cs` file **CC 21**, the
third-highest-CC file in the backend after `Program.cs` (78) and
`Data/ApplicationStore.cs` (27).
- **Minimal seam.** Thread the parameter that already exists: `ResolveAuto(string key,
string label, string at)` → `"datum" => FormatDatumNl(at)`, passing `at` down through
`RenderParagraphs`/`RenderNode` (both private, both already in `Render`'s call chain
with `at` in scope). Two signature changes, one expression change, zero public API
change, zero call-site change. Then assert the rendered `datum` against a fixed
expected string in `LetterHtmlTests`.
- **Secondary benefit, stated conservatively.** This is not a shipped bug today — every
caller (`Program.cs:697`, `:708`, `BriefStore.cs:120`) passes `Now()` at render time,
so the two dates coincide. It becomes one the moment `Render` is called with a
historical `at` (re-rendering an archive, back-dating a letter), at which point the
letterhead and the body would disagree within a single document.
- **Effort S.** Independently shippable.
No other Domain findings. §7's claim holds under inspection: `SubmissionRules`,
`DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`,
`HerregistratieRule`, `OrgTemplateRules`, `Authz` and `FeatureFlags` are static classes of
pure functions with a matching file in `tests/Domain/`, and the clock-dependent ones take
their instant as an argument. That is the correct shape.
## backend/Data
**TE-008 — brief state-transition and authorization rules live inside DB-opening, lock-held store methods**
- Module / file:line — `backend/src/BigRegister.Api/Data/BriefStore.cs`, five guard
clusters: `:72-76` (Save), `:88-90` (Submit), `:111-112` (Send), `:162-164`
(Approve/Reject shared path), plus the `RequiredFilled(e)` predicate
- **What blocks unit testing.** Each guard is a pure decision over
`(status tag, actor role, entity completeness)` — e.g. `Save` returns `Forbidden` if
`!isDrafter`, `Conflict` unless the status is `draft` or `rejected`, and reopens a
`rejected` letter to `draft`; `Submit` additionally requires `RequiredFilled`. But each
sits **inside** a method that has already done `lock (_gate) { using var db =
Db.Create(); ... }`, so exercising any of them requires a booted host and a real SQLite
file. There is no `BriefRules` class: `Domain/Letters/` contains only `LetterHtml.cs`
and `OrgTemplateRules.cs`. The pattern is visibly **half-applied** — `Authz.CanActOn`
at L163 _is_ a pure `Domain/` call, sitting one line away from three guards that are not.
The `Save` guard's own comment says it "mirrors the FE reducer", i.e. it is business
logic with a known pure counterpart on the other side of the wire.
- **Baseline citation.** §3c: `backend/Data` **75.5% branch** — named in BL-005 as one
of the three weak branch axes, against 99.0% line coverage (the exact signature of
"every unit is entered, edge branches are not"). §4b: `Data/BriefStore.cs` file
**CC 17**, and its `ToDto` at **CC 16** is the highest-CC non-`Program.cs` method in
the backend. §5: `backend/Data` 7.7% duplication.
- **Minimal seam.** Add `Domain/Letters/BriefRules.cs` with pure statics —
`CanSave(BriefStatusDto status, bool isDrafter) → Outcome`,
`StatusAfterSave(BriefStatusDto) → BriefStatusDto`,
`CanSubmit(status, isDrafter, bool requiredFilled) → Outcome`, `CanSend(status)`,
`CanDecide(status, Principal, drafterId)` — and have each store method call one.
The store keeps its lock, its `Db.Create()`, its static shape and its signature; only
the `if` cascade moves. This **extends the pattern §7 already records** for
`SubmissionRules` / `BeoordelingRules` / `OrgTemplateRules` / `DocumentRules`, and adds
a `tests/Domain/BriefRuleTests.cs` alongside the seven that exist.
- **Explicitly NOT proposed: changing the static-store shape.** `Data/Db.cs:6-12`
documents the static, non-DI store decision, and
`tests/TestWebApplicationFactory.cs:1-12` states the position outright — "Serializing
test classes is the fix, not a redesign of the stores for a test-only concern."
TE-008 respects that completely: it is orthogonal, and works _because_ the rules never
needed the DbContext in the first place.
- **The cost this seam actually pays down.** Because `Db.ConnectionString` is one static
field, that same file carries
`[assembly: CollectionBehavior(DisableTestParallelization = true)]` — **all 241 backend
tests run serially, process-wide**, and every brief-rule assertion currently pays a
host boot + SQLite file for a decision that is a pure function of two enums. Each rule
moved out of `BriefStore` moves a test out of the serialized integration lane into the
free-running unit lane. That is the argument for the seam; it is not an argument for
touching the stores.
- **Effort M** (five extractions + one new test file). Independently shippable, and
splittable one method at a time if preferred.
Two smaller Data notes, neither ticketed: `IdempotencyStore` is the only store that is
purely in-memory with no `Reset()` and no TTL, so its dictionary survives
`TestWebApplicationFactory` disposal and is shared by every test class in the process —
harmless today only because `IdempotencyTests.cs:24` keys on `Guid.NewGuid()`. And
`DocumentStore.cs:54` / `AuthzAuditStore.cs:35` stamp `DateTimeOffset.UtcNow` inline
while `ApplicationStore.RecordBesluit` correctly takes `now` — an inconsistency, but
neither audit timestamp is asserted on, so no metric supports a ticket.
## backend/Zgw
**No findings.** §3c 98.1% line / **85.5% branch — the strongest branch figure on the
backend**, and §5 records 1.8% duplication. This is the module that was built as
ports-and-adapters from the start (ADR-0005): `IZaakSource`/`IDocumentSource` each have
two implementations (§7), `ZgwHttpClient` takes an injected `HttpClient`, and
`tests/ZgwStubHandler.cs` provides the transport fake — five test files ride on it. It
is the backend's worked example of the seam TE-003 asks the upload module for.
## backend/Contracts
**No testability findings — but state the gap accurately.**
§3c records `backend/Contracts` at **65.0% branch, the worst branch figure in the repo**
(BL-005 names it first). It is nonetheless not a testability finding: `Mappers.cs` is 79
lines of pure `static` extension methods over records, with the clock already injected
where it matters (`ToStatusDto(this Aanvraag a, DateTimeOffset now)` at `:52`,
`ToSummaryDto(..., now)` at `:68`, `ToDetailDto(..., now)` at `:76`), and `Dtos.cs` is
250 lines of `record` declarations. §4b confirms the shape: file CC 4, max method CC 3,
the lowest complexity of any backend folder. **Nothing blocks a unit test here.**
What is missing is a test _file_: `backend/tests/` has `Domain/`, `Acceptance/` and
`Builders/` folders but no `Contracts/`, so all 65% is incidental coverage picked up
through endpoint tests. That is a coverage ticket for whoever owns coverage, requiring
zero source change — and per BL-009 there is no ratchet, so it would have to be verified
against §3c's numbers by hand.
## backend/Stamdata
**TE-009 — `Professions.ByProgram` freezes its valid-time filter at type-load from `DateTime.Today`**
- Module / file:line — `backend/src/BigRegister.Api/Stamdata/Professions.cs:25-27`
- **What blocks unit testing.** `ByProgram` is a `static readonly IReadOnlyDictionary`
whose initializer runs `Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan,
m.GeldigTot, DateOnly.FromDateTime(DateTime.Today)))`. Two compounding problems: the
peildatum is the ambient wall clock, **and** the result is computed once per process at
type-load and then immutable. A test cannot ask "which mappings are active on
2030-01-01" — not by arranging state, not by ordering, not at all. The temporal
behaviour of the one business-tunable table that has a validity window is therefore
unreachable. The file's own comment concedes the consequence: it "preserves the
pre-valid-time behaviour exactly **while the file's rows are all current**" — i.e. the
`ActiveOn` call is presently a constant-true filter, so both of its interesting
branches (not-yet-valid, expired) are dead in every run.
- **Baseline citation.** §3c: `backend/Stamdata` 96.8% line but **71.7% branch** —
named in BL-005 as the second-weakest branch axis, an exact 25-point line/branch split.
§4b: `Stamdata/StamdataTable.cs` file CC 21, joint-third-highest in the backend.
This is the rare backend case where "untestable" is defensible against 97.6% line
coverage: the lines run, the branches provably cannot.
- **Minimal seam.** Add the parameterized overload and define the existing field in terms
of it:
`public static IReadOnlyDictionary<string,string> ByProgramOn(DateOnly on) => Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)).ToDictionary(...);`
then `public static readonly IReadOnlyDictionary<string,string> ByProgram = ByProgramOn(DateOnly.FromDateTime(DateTime.Today));`.
**Zero call-site changes** — `DiplomaRules.ProfessionFor` and `All()` keep using
`ByProgram`. `StamdataValidationTests` gains the ability to assert both validity-window
branches against authored future/expired rows.
- **This extends an existing pattern in the same folder.** `StamdataTable.cs:63` already
does exactly this — `Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows()`,
with `on` as a parameter — and `StamdataFile.ActiveOn(van, tot, on)` (`:36`) is already
clock-free. `Professions.cs` is the one caller that swallows the parameter.
- **Effort S.** Independently shippable; additive only.
## backend/tests
**No findings.** 39 files, 4 253 lines, 241 green tests, every backend source file
reached (§3c). The suite already carries the fixtures a unit lane needs
(`Builders/AanvraagBuilder.cs`, `ZgwStubHandler.cs`, `TestWebApplicationFactory` with
per-class throwaway SQLite files).
The one structural observation is not a defect to fix here:
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
(`TestWebApplicationFactory.cs:12`) serializes the entire suite because
`Db.ConnectionString` is a process-global. The repo reached that decision deliberately
and documented the race it prevents. Rather than reopen it, TE-008 and TE-009 reduce how
much _needs_ to run in that serialized lane. Note also §4b's outlier in this folder: a
**293-line test method at CC 20**
(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`,
`OpenZaakZaakSourceTests.cs`) — a test-readability item for agent 01, not a testability
seam.
---
## Summary
| ID | Title | Module | Blocker | Baseline | Effort | 1 deploy |
| ------ | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ------ | -------- |
| TE-001 | `SessionStore.restore()` reads `localStorage` inline | ssp/auth + bhp/auth | hidden I/O in a field initializer; guard is module-private | §3a 42.9%/46.2% (worst line); file LH 2/20, BRH 3/13 | S ×2 | yes |
| TE-002 | Trust boundary hidden inside a global-`fetch` method | ssp/brief | un-exported shape validation behind `await fetch` | §3b 42% reach; §3a 68.8% branch | S | yes |
| TE-003 | `UploadTransport` port declared, concrete class injected | libs/shared/upload | `inject(KeepaliveTransport)`; class not exported | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes |
| TE-004 | `createUploadController` injects + binds `window` at call time | libs/shared/upload | 3× `inject()`, `effect()`, `addEventListener` before returning | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes |
| TE-005 | `xhrUpload` interprets responses inside an XHR closure | libs/shared/upload | `new XMLHttpRequest()` hard-coded; dev simulator shares the method | file LH 5/64 (7.8%), BRH 3/57 (5.3%) | S–M | yes |
| TE-006 | Blob-to-browser handoff inlined in 3 commands | libs/beheer + ssp/brief | `window.open` / `a.click()` as the last statement of each command | §3a beheer/application 40.5% branch (worst); brief.store 50% | S–M | yes |
| TE-007 | `LetterHtml` resolves `datum` from `UtcNow`, not from `at` | backend/Domain | ambient clock in a private resolver inside a pure rule class | §3c Domain 82.0% branch; §4b file CC 21 | S | yes |
| TE-008 | Brief transition rules live inside DB-opening store methods | backend/Data | 5 pure guards behind `lock` + `Db.Create()` | §3c Data 75.5% branch (BL-005); §4b CC 17, `ToDto` CC 16 | M | yes |
| TE-009 | `Professions.ByProgram` freezes valid-time at type-load | backend/Stamdata | `static readonly` + `DateTime.Today`; both branches unreachable | §3c Stamdata 71.7% branch (BL-005) | S | yes |
**Modules with no findings:** ssp/registratie · ssp/herregistratie · ssp/showcase+shell+root ·
bhp/behandeling · bhp/shell+root · libs/shared/{domain, application, infrastructure, ui,
layout, kernel, testing} · libs/beheer/{domain, infrastructure, ui, contracts} ·
backend/Program.cs · backend/Zgw · backend/Contracts · backend/tests.
**Baseline rows recommended for closure as false gaps:** `libs/shared/domain` (0% reach,
3 files) and `libs/beheer/contracts` (0% reach, 1 file) — both named in BL-004 as genuine
gaps; both contain only type declarations and no executable statement.
**Cross-references, not owned here:** BL-001 complexity (agent 01) · BL-002 auth
duplication (agent 06) · BL-003 `Program.cs` structure (agents 03/04) · BL-006 backend
architecture enforcement (agent 03) · BL-007 write-side placement (agent 04) · BL-008
`coverageExclude` · BL-009 no coverage ratchet — which means none of the findings above
can be verified as "improved" by CI alone; verify against `00-baseline.md`'s numbers ·
BL-010 `libs/shared/upload` layer placement (TE-003/004/005 all land inside that
carve-out and do not resolve it) · BL-011 suite flakiness under parallel load.
@@ -0,0 +1,9 @@
## Scope: [to be filled by agent]
## Status: not_started
## Last updated: -
## Depends on: [see agent prompt]
## ---
@@ -0,0 +1,543 @@
## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata)
## Status: complete
## Last updated: 2026-08-26
## Depends on: 00-baseline.md
## ---
# 04 — CQRS-light: command/query separation at the application-service level
**Mandate reminder, applied literally.** This agent may only _extend_ CQRS-light where
baseline §7 records it already exists. It may not introduce it. Every finding below names
the concrete existing artifact it extends. Three things the baseline flagged as tempting
are therefore **not** filed as tickets — they are in "Out of mandate (pattern absent)" at
the end.
**What "the pattern" concretely is in this repo** (from §7 + CLAUDE.md §3, so later
sections can just point at it):
| Side | The existing artifact |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| FE query | `resource({ loader })` in an `infrastructure/*.adapter.ts` + a `parse*` boundary + a `providedIn:'root'` read store exposing `RemoteData` |
| FE command | `application/submit-*.ts` command factory → `runSubmit(fn, fallback)` → `Result<string,T>` → the caller dispatches a Msg |
| FE fold | `libs/shared/src/application/submit.ts` — the single try/catch + ProblemDetails → error-string fold, **and** the Idempotency-Key mint point |
| BE | `Contracts/Dtos.cs` direction split (`*Request` in / `*Dto`+`*Response` out); read/write comment banners in `Program.cs` |
| BE read-mdl | `ToDetailDto(now)` / `ToDto(now)` — the read side _projects_ status from timestamps rather than the write side storing it |
**The cleanest module in the repo is `bhp/behandeling`**, and it is worth naming up front
because three findings below propose making another module look like it: it splits its
query adapter (`beoordeling.adapter.ts` `get`, `werkvoorraad.adapter.ts` `list`) from its
command adapter (`besluit.adapter.ts` `besluit`) into **separate files**, wraps only the
command in a command factory (`application/submit-besluit.ts`), and keeps the read store
(`beoordeling.store.ts`, `werkvoorraad.store.ts`) write-free. Its backend counterpart does
the same: `Program.cs:441` "read side only" banner, `Program.cs:464` the write banner. That
is the target shape, and it is already in the tree.
---
## apps/ssp — auth
**No findings.**
`SessionStore.login` (`apps/ssp/src/app/auth/application/session.store.ts:58`) is a write
and `session`/`isAuthenticated` are reads, but they share one three-field aggregate with no
network read path at all — `DigidAdapter.authenticate` is the only I/O and it is a command.
There is nothing to separate. §7 lists no command factory or read adapter in this context to
extend. BL-002 is agent 06's, not this agent's.
---
## apps/ssp — registratie
### CQ-001 — `createDraftSync` is registered as a command factory but owns three query paths
- **Module / file:line** — `apps/ssp/src/app/registratie/application/draft-sync.ts:50-236`
(queries at `:141 load`, `:160 findConcept`, `:179 resume`; commands at `:63 ensureId`,
`:100 flush`, `:213 submit`, `:221 reset`)
- **Extends** — the command-factory idiom itself. §7 counts `draft-sync.ts` as one of the
repo's **3 command factories**, alongside `submit-change-request.ts` (18 lines, one
command, zero reads) and `submit-besluit.ts` (16 lines, one command, zero reads). This
finding asks the third member of that set to look like the other two.
- **Baseline citation** — §7 Frontend, "Command factories (write side) | 3"; §4a metric
row **`createDraftSync` 143 lines** (the largest function in the codebase, tied to
`reduceUpload`'s 109 only in the two-member `fn>75` population); §9 threshold "TS
function > 40 lines".
- **The mixing, concretely** — the factory returns four members. `resume()` is pure query
orchestration: read `?aanvraag`, `adapter.detail(linked)`, else `findConcept()` →
`adapter.list()` → `parseApplications`. `submit()`/`reset()`/the debounce `effect` are
writes. They are entangled through three pieces of shared mutable closure state — `id`,
`ensuring`, and `resumeGate` (`:56-61`) — where `resumeGate` exists _only_ so the write
path (`ensureId`) can wait for the read path (`resume`) to finish. That coupling is
genuine and load-bearing, which is exactly why it is worth naming rather than leaving as
an unexplained 143-line function.
- **Proposed change, minimal** — extract the read half into
`application/find-concept.ts`: `findConcept(adapter, type)` and `loadConcept(adapter, id)`
as free functions taking the adapter (no `inject`, so they get a direct spec —
`draft-sync.spec.ts` already exists and would shrink). `createDraftSync` keeps `resumeGate`
and the write path and calls them. This is a move, not a redesign; the closure state stays
where it is.
- **Effort** — M. Independently shippable in one deploy (no wire change, no DTO change).
### CQ-002 — two read stores perform writes that bypass the `runSubmit` fold
- **Module / file:line** — `apps/ssp/src/app/registratie/application/applications.store.ts:54-64`
(`cancel`) and `apps/ssp/src/app/registratie/application/admin-cases.store.ts:46-56`
(`delete`); the adapter methods are
`infrastructure/applications.adapter.ts:60 cancel` and `:41 deleteAny`.
- **Extends** — `runSubmit` + `SUBMIT_FAILED` (`libs/shared/src/application/submit.ts:15,28`),
the fold that all 16 other mutations in the repo pass through, and the
`createSubmitChangeRequest` command factory that lives _in this same folder_
(`registratie/application/submit-change-request.ts`) and does exactly this for the other
registratie write.
- **Baseline citation** — **BL-007** ("the FE write side is inconsistently placed");
§7 Frontend, "Command factories | 3" vs "Mutations living inline in adapters | ~13".
Note these two are a _fourteenth and fifteenth_ case BL-007 did not enumerate: they are
worse than the ~13, because those at least reach `runSubmit` inside the adapter — these
reach the raw `ApiClient` and never produce a `Result` at all.
- **The mixing, concretely** — both stores own a `RemoteData` read signal _and_ a write, and
the write's failure path is `catch { this.state.set(before); }` — a bare rollback with no
error channel. A failed cancel makes the row silently reappear with no message, no
`ActionState`, no ProblemDetails `detail`. `BriefStore`/`OrgTemplateStore` in the sibling
context both hold an `ActionState` + `lastError` for exactly this. The bare
`adapter.cancel()` also means the `Idempotency-Key` on the wire is a fresh UUID minted per
HTTP attempt by `api-client.provider.ts:58`, not the per-logical-submit key `runSubmit`
promises at `submit.ts:11-13` — that invariant's docstring is currently false for these
two calls (harmless today: `Program.cs` only honours the header inside the `Submit` helper,
see CQ-005's note).
- **Proposed change, minimal** — route both through `runSubmit` and surface the error.
Two options, pick one and apply to both stores identically:
(a) smallest — `const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
if (!r.ok) { this.state.set(before); this.error.set(r.error); }` plus one `error` signal;
(b) fuller — a `application/cancel-application.ts` command factory mirroring
`submit-change-request.ts`, which the store injects. (a) is enough to close the finding.
- **Effort** — S. Independently shippable; (a) touches 2 files plus a UI line each to render
the error.
### Not filed — `applications.adapter.ts` mixes 3 reads and 5 writes in one file
`infrastructure/applications.adapter.ts:31-66` holds `list`/`listAll`/`detail` next to
`create`/`syncDraft`/`cancel`/`deleteAny`/`submit`, where `bhp/behandeling` splits the
equivalent into `beoordeling.adapter.ts` + `besluit.adapter.ts`. The split would be the
structural enabler for CQ-002(b). On its own, though, it moves 8 thin one-line
`this.client.x()` wrappers between files and changes nothing observable — file placement is
agent 03's axis, not a read/write-mixing defect. Noted here so it is a deliberate omission
rather than a miss; fold it into CQ-002 if that ticket takes option (b).
### Not filed — `BigProfileStore`
`application/big-profile.store.ts` is the reference implementation of the split and needs no
change: reads are two resources projected through `parseDashboardView`, and the only
write-adjacent members are the `beginHerregistratie`/`confirmHerregistratie`/
`rollbackHerregistratie` invalidation hooks (`:65-74`) — the store never performs the write
itself, `createDraftSync.submit` does. ADR-0001's "Out of scope" section already records the
optimistic-flag race; that is a correctness note, not a CQRS-light one.
---
## apps/ssp — herregistratie
**No findings.**
`IntakePolicyStore` (`application/intake-policy.store.ts`) is a pure query facade over one
`resource()`. The context has no write of its own — its submit is `createDraftSync.submit`,
owned by `registratie` and covered by CQ-001. `intake.machine.ts` / `herregistratie.machine.ts`
are reducers; §3's "side effects stay out of the reducer" is honoured (verified: no `inject`,
no adapter import in either).
---
## apps/ssp — brief
### CQ-003 — `runSubmit` (the write-side fold, incl. the Idempotency-Key mint) is used for reads
- **Module / file:line** — `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:56`
(`load` → `briefGET`), `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:39`
(`list` → `orgTemplates`) and `:51` (`load` → `orgTemplateGET`). Same defect in
libs/beheer — see that section; one ticket should fix all five call sites.
- **Extends** — the two halves already present in `libs/shared/src/application/submit.ts`:
the ProblemDetails→`Result` **fold** (which reads legitimately want) and the
**`withIdempotencyKey` wrapper** (which is write-only by construction). The read idiom it
should join is the one the other 6 read adapters use — `resource({ loader })` +
`parse*` — or, for these imperative reads, the fold alone.
- **Baseline citation** — **BL-007**; §7 Frontend "Infrastructure adapters (read side) | 20"
and "Mutations living inline in adapters | ~13" — these three GETs are counted in the
wrong column of that inventory, because the code cannot tell them apart from the writes
beside them.
- **The mixing, concretely** — `submit.ts:11-13` states `runSubmit` is "the one place a
logical submit's Idempotency-Key is minted — once per `runSubmit` call". Each of these
reads therefore mints a UUID and assigns the module-level `pendingIdempotencyKey`
(`api-client.provider.ts:21-26`) for the duration of a GET. It is inert today: the header
is attached only when `method !== 'GET'` (`api-client.provider.ts:58`). But
`api-client.provider.ts:15-19` explicitly documents that the module-level variable "holds
up because every submit command calls its adapter synchronously (no await before reaching
this file)". Reads have no such discipline — `BriefStore.load()` and
`OrgTemplateStore.load()` are awaited across `await`s — so routing reads through the write
helper quietly widens the assumption that comment relies on. Reading `brief.adapter.ts:55-92`
it is also simply impossible to see which of the seven methods are commands: all seven are
`await runSubmit(...) → parseBriefView`.
- **Proposed change, minimal** — split `submit.ts` in place, no new concept:
`runResult(fn, fallback)` = the existing try/catch + `problemDetail` fold;
`runSubmit(fn, fallback)` = `runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback)`.
Point the 5 reads at `runResult`. Zero behaviour change, and afterwards the keyword at each
call site states the side. `submit.spec.ts` already exists and covers the fold.
- **Effort** — S. Independently shippable in one deploy; 1 shared file + 3 adapters (+2 in
libs/beheer).
### Not filed — `BriefStore` and `OrgTemplateStore` own both the read path and the write commands
`application/brief.store.ts` holds `load()` alongside `save`/`submit`/`approve`/`reject`/
`send`/`resetDemo`/`revealBigNummer`; `application/org-template.store.ts` holds
`load`/`selectSubOrg` alongside `flushSave`/`confirmPublish`/`rollback`. BL-007 points at
these as "mutations living inline in adapters", and it is tempting to file them.
They are deliberately not filed, and the reason matters for whoever reads this next.
CLAUDE.md §3 defines a command as "does the HTTP, then dispatches a message describing the
outcome" — and that is precisely what `BriefStore.transition()` (`:248-259`) and
`OrgTemplateStore.confirmPublish()` (`:175-189`) do: `ActionState → Busy`, cancel the
debounce, call the adapter, then `store.dispatch(...)` or `actionState.set(Failed)`. The
reducer stays pure. These stores _are_ the command layer; they are not a store that
accidentally grew writes. Both also implement the write→invalidate-read handoff correctly
(`confirmPublish` reloads via `selectSubOrg`, mirroring `BigProfileStore.confirmHerregistratie`'s
`viewRes.reload()`). Extracting six `createSubmitX()` factories out of `BriefStore` would move
code without changing which layer performs which effect. **The real defect in these two files
is CQ-003, and that is filed.**
---
## apps/ssp — showcase, shell, root
**No findings.** `showcase/concepts.page.ts`, `app.ts`, `app.config.ts`, `app.routes.ts`
contain no application services, no adapter calls and no state writes — routing, providers
and a teaching page. §7 lists no pattern here to extend. (Their 0% spec reach in §3b is
agent 02's; their `bhp/root` duplication in §5 is agent 01's.)
---
## apps/behandelportal — auth
**No findings.** Identical to ssp/auth by BL-002; the same reasoning applies.
---
## apps/behandelportal — behandeling
**No findings — this module is the reference implementation.**
Stated positively so later phases do not "clean it up" into something worse:
`werkvoorraad.adapter.ts` (`list`) and `beoordeling.adapter.ts` (`get`) are query-only files;
`besluit.adapter.ts` (`besluit`) is a command-only file; `submit-besluit.ts` is the command
factory; `werkvoorraad.store.ts` and `beoordeling.store.ts` contain no writes at all — not
even a rollback. `besluit-form.component.ts:88` holds the command
(`private submit = createSubmitBesluit()`), never the adapter. §3a records the highest FE
line coverage of any feature context here (91.6%), which is consistent with the split: the
read stores are trivially testable because nothing writes through them.
---
## apps/behandelportal — shell, root
**No findings.** Same as ssp/shell+root.
---
## libs/shared — application
### CQ-004 — `FeatureFlagStore.set` writes without the fold and drops the error entirely
- **Module / file:line** — `libs/shared/src/application/feature-flags.store.ts:53-59`;
adapter at `libs/shared/src/infrastructure/feature-flags.adapter.ts:18`; caller at
`libs/beheer/src/ui/feature-flags.page.ts:93`.
- **Extends** — `runSubmit`/`SUBMIT_FAILED` (`libs/shared/src/application/submit.ts`), which
lives in this very folder — the store sits three files away from the fold it skips.
- **Baseline citation** — **BL-007**; §7 Frontend "Mutations living inline in adapters | ~13"
(this is another case not in BL-007's enumeration, which named only `brief.adapter.ts`,
`org-template.adapter.ts` and `stamdata.adapter.ts`).
- **The mixing, concretely** — the store owns the read (`load`, `flags`, `all`, `enabled`)
and the admin write. The write is `try { await this.adapter.set(...) } finally { await this.load() }`
— **no `catch`**. The rejection propagates out of `set()`; the caller is
`void this.store.set(key, enabled)` (`feature-flags.page.ts:93`), so a failed toggle
becomes an unhandled promise rejection. The admin sees the switch flick back after the
`finally`'s reload with no explanation and no ProblemDetails `detail`, on a write gated by
`flags:manage` that is exactly the kind an operator needs confirmation of. Every other
write in the repo that goes through `runSubmit` gets `problemDetail(e, fallback)`.
- **Proposed change, minimal** — `const r = await runSubmit(() => this.adapter.set(key, enabled), SUBMIT_FAILED);
await this.load(); if (!r.ok) this.error.set(r.error);` with one `error` signal rendered by
`feature-flags.page.ts`. Keep the reload unconditional (it is the read-side invalidation and
is correct).
- **Effort** — S. Independently shippable; 2 files.
### Not filed — the rest of the layer
`access.store.ts` (query-only over `/me`), `remote-data.ts`, `store.ts`, `action-state.ts`,
`debounced-save.ts`, `history.ts`, `machine-remote-data.ts`, `pending-saves.ts`,
`session.port.ts` are the read/state kit itself, not services. `submit.ts` is the subject of
CQ-003 rather than a finding of its own.
---
## libs/shared — infrastructure
**No findings.** `me.adapter.ts` and `feature-flags.adapter.ts` are correctly
direction-labelled (`list` vs `set`); `api-client.provider.ts` is the single HTTP seam and
already gates the Idempotency-Key on `method !== 'GET'`, i.e. the _transport_ layer honours
the command/query split that CQ-003 shows the _application_ layer blurring. The CC-19 `fetch`
there is BL-001's "outside the idiom" case and belongs to agent 01.
---
## libs/shared — upload
**No findings.**
`upload.adapter.ts` mixes reads (`categoriesResource`, `status`) and writes (`xhrUpload`,
`deleteDocument`), and `upload-shell.service.ts` mixes `upload`/`delete`/`cancel` (commands)
with `pollReturning` (a query). It is tempting to file, and it is deliberately not: **BL-010**
records that this whole folder sits outside the layer convention by design and is carved out
by name in the `apiclient-infrastructure-only` dependency-cruiser rule. §7 lists no adapter
or command factory here to extend — `upload.adapter.ts` is explicitly footnoted as the
adapter that is _not_ in an `infrastructure/` folder. Resolving BL-010 (agent 03's call) has
to come first; a read/write split layered on top of an already-exceptional layout would
entrench the exception. Worth noting for whoever takes BL-010: `UploadShellService` is
otherwise the FE's most complete command implementation — every method takes a `Dispatch` and
reports its outcome as a Msg, which is the CLAUDE.md §3 shape done exactly right.
---
## libs/shared — domain, contracts, kernel, ui, layout, testing, environments
**No findings.** No application services, no adapters, no writes. `kernel/fp.ts`'s `Result`
is the return type the command side is built on, not a service.
---
## libs/beheer
### CQ-005 — both stamdata reads run through `runSubmit`, in a file that documents itself as write-free
- **Module / file:line** — `libs/beheer/src/infrastructure/stamdata.adapter.ts:27` (`list`)
and `:42` (`load`).
- **Extends** — the same `submit.ts` split proposed in CQ-003. **Fix them in one ticket**;
they are listed separately only because the module scope requires it.
- **Baseline citation** — **BL-007** (which names `stamdata.adapter.ts` explicitly among the
~13 "mutations living inline in adapters"); §7 Frontend "Infrastructure adapters (read
side) | 20".
- **The mixing, concretely** — sharper here than anywhere else in the repo, because the
file's own docstring (`:16-20`) says: _"Both endpoints are reads … There is no write method
— the edit is downloaded and lands as a PR."_ Both nevertheless call `runSubmit`. BL-007
counts this adapter on the write side of the inventory on the strength of that call, when
the module is in fact the repo's only genuinely CQRS-clean context: `StamdataStore` has no
write command at all (`download()` at `:137` is a local `Blob` + anchor click, zero
network), and `AuditStore` is read-only. The tooling and the baseline both mis-classify
this module purely because of the helper name.
- **Proposed change, minimal** — point both at `runResult` per CQ-003. Nothing else in this
library changes.
- **Effort** — S. Ships with CQ-003 in the same deploy.
### Not filed — `libs/beheer` application/domain/ui/contracts otherwise
`stamdata.store.ts` and `audit.store.ts` are query-only (see above). `stamdata-editor.machine.ts`
is a pure reducer. The `ui/` layer holds no adapter calls.
---
## backend — Program.cs
### CQ-006 — the read/write banner split is established, honoured once, then abandoned for 5 of 7 feature sections
- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs`. Banners at `:133`
("GET: screen-shaped reads"), `:185` ("POST: submits"), `:441` ("read side only") and
`:464` ("record a behandelaar's decision"). Mixed sections: `:197` Document upload,
`:275` Applications, `:598` Brief, `:721` Organization templates, and admin-cases split
non-contiguously across `:424`, `:554`, `:566`.
- **Extends** — the banner convention _inside this file_, specifically the WP-65 pair at
`:441`/`:464`, which already splits one feature's query endpoints from its command endpoint
under two banners. This proposal applies the `:441`/`:464` treatment to the five sections
that predate it. **No handler types, no mediator, no `Features/` folders** — see the
out-of-mandate section for why that larger move is not proposed here.
- **Baseline citation** — **BL-003** (940 lines, 48 endpoints, file CC 78 vs next-highest 27,
"read/write separated only by comment banner"); §7 Backend CQRS-light row ("Read/write
split exists as _comment banners_"); §3c `Program.cs` 97.4% line / 84.8% branch.
- **The mixing, concretely** — the file opens by declaring direction as its organising
principle (`:133` reads, `:185` writes), then from `:197` switches to feature grouping
without saying so, and every subsequent section interleaves:
| Section | Line | Reads | Writes |
| -------------------------------------------------------------------------------------------- | ------------: | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Document upload | 197 | `GET /uploads/categories`, `GET /uploads/{id}/content`, `GET /uploads/status` | `POST /uploads`, `DELETE /uploads/{id}`, `DELETE /admin/uploads/{id}` |
| Applications | 275 | `GET /applications`, `GET /applications/{id}` | `POST`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/submit` |
| Admin cases | 424, 554, 566 | `GET /admin/cases`, `GET /admin/audit` | `DELETE /admin/cases/{id}` — **129 lines away** from its list, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in between |
| Brief | 598 | `GET /brief`, `GET /brief/preview` | `PUT /brief`, `POST submit/approve/reject/send/reveal-bignummer/reset` |
| Org templates | 721 | `GET /admin/org-templates`, `GET /admin/org-template/{id}` | `PUT /{id}`, `POST /{id}/publish`, `POST /{id}/rollback` |
| `GET /admin/org-template/{subOrgId}/preview` (`:703`) is additionally filed under the Brief |
| banner rather than the Org-templates one. The consequence is not a bug — §3c confirms the |
| file is well tested, and this is a **structure finding, not a correctness one** — it is that |
| a reader cannot answer "what can mutate state here?" without reading all 940 lines, and that |
| the two cross-cutting write wrappers (`Submit`'s idempotency replay, `RecordZgwDivergence`) |
| have no visible scope. |
- **Proposed change, minimal** — within each existing feature section, order reads first then
writes and insert the `:441`/`:464`-style sub-banners; move `DELETE /admin/cases/{id}`
(`:554`) and `GET /admin/audit` (`:566`) up beside `GET /admin/cases` (`:424`); move the
org-template preview from the Brief section to the org-template one. **Pure reordering and
comments** — no signature, route, DTO or behaviour change, so §3c's 97.4%/84.8% coverage is
the regression net and the diff is reviewable line-for-line.
- **Effort** — S. Independently shippable in one deploy. One caveat for whoever schedules it:
it is a large-diff/zero-semantic-change commit, so land it alone, never mixed with a
behaviour change.
### CQ-007 — `GET /brief` creates a brief, though the explicit create command already exists
- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs:603`
(`api.MapGet("/brief", …)`) → `backend/src/BigRegister.Api/Data/BriefStore.cs:50`
(`GetOrCreate` — `db.Briefs.Add(created); db.SaveChanges();`).
- **Extends** — the command/query direction split that `Contracts/Dtos.cs` encodes
(`*Request` in / `*Dto` out) and that the banners at `:133`/`:185` state as the file's
premise; and concretely, **`POST /brief/reset` (`:712` → `BriefStore.ResetAndCreate`)** —
the create-a-fresh-brief command already exists as a POST. The write half of `GetOrCreate`
has a command counterpart; the query does not need to duplicate it.
- **Baseline citation** — **BL-003**; §7 Backend CQRS-light row.
- **The mixing, concretely** — this is the only endpoint in the backend where a GET performs
a persisted write. Everything else respects the direction, and notably the read side goes
out of its way to _avoid_ writing: `ToDetailDto(DateTimeOffset.UtcNow)` /
`ToDto(now)` project Concept → InBehandeling → Goedgekeurd from stored timestamps on every
read rather than mutating a status column (`Program.cs:284`, `Data/AanvraagMapper.cs`), which
is a textbook CQRS read-model projection and the strongest evidence the convention is
intended. `GET /brief` breaks it: a plain read is non-idempotent on first call, allocates a
row, and — since the FE retries GETs automatically (`api-client.provider.ts:66`,
`retry({ count: 2, delay: 500 })`, GET-only, precisely because GETs are assumed safe) —
a transient failure can enter the create path more than once. `BriefStore.GetOrCreate` is
`lock`-guarded so no duplicate row results today; the objection is that the safety depends
on the lock rather than on the endpoint being a query.
- **Proposed change, minimal** — `GET /brief` returns 404 when no brief exists for the owner;
`BriefStore.GetOrCreate` splits into `Get` (query) and the existing `ResetAndCreate` (already
there). `BriefStore.load()` on the FE (`brief.adapter.ts:55`) treats 404 by calling the
existing `reset()` command once. **This is the least certain finding in this file** and the
only one with a behaviour change: it costs one extra round-trip on a first visit and touches
the brief tests. If the demo-seeding convenience is judged to outweigh the principle, the
acceptable alternative is to leave the code alone and add one line at `:603` saying the GET
seeds on first call — the defect is as much that it is undocumented as that it exists.
- **Effort** — M. Independently shippable, but FE and BE must land together (the 404 contract),
so it is the one finding here that is not a single-side deploy.
---
## backend — Domain
**No findings.** `Domain/` is static classes of pure functions (`SubmissionRules`,
`IntakePolicy`, `BeoordelingRules`, `HerregistratieRule`, `OrgTemplateRules`, `Authz`,
`FeatureFlags`, `LetterHtml`, `DiplomaRules`, `DocumentRules`) with no persistence and no I/O —
verified EF-free and ASP-free per §7. A pure decision function has no read/write axis to
separate. `Domain/Applications/Aanvraag.cs`'s `Concept`/`Submitted`/`Decided` tagged union is
the write model; §3c records 94.2% line coverage.
---
## backend — Data
**No findings within mandate.** The seven static stores (`ApplicationStore`, `DocumentStore`,
`BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`)
each expose reads and writes on one type — `ApplicationStore` alone has 6 reads
(`Get`/`List`/`GetAny`/`GetByReferentie`/`ListAll` + `ToDetailDto`) and 7 writes. That is
ordinary repository design, and §7 records these as "Not behind any port … Deliberate,
documented in `Data/Db.cs`". Splitting them into read/write repositories would be
_introducing_ the pattern into a module where §7 records it absent — out of mandate. See the
out-of-mandate section. One observation to hand on rather than file: `AanvraagMapper` /
`ToDetailDto(now)` is a real read-model projection and is cited approvingly in CQ-007; do not
let a future ticket "simplify" it into a stored status column.
---
## backend — Zgw
**No findings.** `OpenZaakZaakSource` / `OpenZaakDocumentSource` implement ports whose
interfaces (`IZaakSource`, `IDocumentSource`) already separate by operation
(`ListMyCases`/`ListCases` vs `CreateZaak`), and §7 records the ACL as "Fully built" under
ADR-0005. §3c gives it the second-highest branch coverage in the backend (85.5%). Nothing to
extend.
---
## backend — Contracts
**No findings.** `Contracts/Dtos.cs`'s 65 records split by direction is the backend's
strongest CQRS-light artifact and is cited as the pattern several findings above extend.
§3c's 65.0% branch coverage (the backend's weakest, per **BL-005**) is agent 02's axis, not
this one — a DTO record has no read/write mixing to fix.
---
## backend — Stamdata
**No findings.** Config-as-code tables (ADR-0004), validated at build by
`StamdataValidationTests`, never runtime-editable — read-only by definition. Its only
endpoints (`Program.cs:164`, `:173`) are both GETs behind the `StamdataAdmin` gate, correctly
placed under the reads banner at `:158`. Together with `libs/beheer` (CQ-005) this is the
cleanest end-to-end query slice in the repo.
---
## Out of mandate (pattern absent)
Filed here rather than as tickets, per the "name the pattern you extend or don't file it"
rule. Agent 08 or a human decides whether any of these becomes a ticket.
**OOM-A — extracting `Program.cs` into `Features/` folders with handler types.**
This is the change BL-003 most obviously invites: 940 lines, 48 endpoints, file CC 78 against
a next-highest of 27. It is out of mandate because §7 is explicit that the backend has
**"No handler types, no mediator, no `Features/` folders"**, and the local helpers (`Submit`,
`StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) are authorization and
idempotency wrappers, not handlers — verified by reading them at `Program.cs:772-940`. There
is no CQRS-light structure here to extend, only one to introduce. CQ-006 is the largest step
available _within_ the mandate, and it deliberately stops at ordering and comments. Note also
that CQ-006 is a strict prerequisite for OOM-A should it ever be taken: you cannot cut a
940-line file into vertical slices while five of its seven sections interleave directions.
**OOM-B — read/write repository split in `backend/Data`.**
`ApplicationStore` (file CC 27, the highest in `Data`) and the six sibling stores each mix
reads and writes. Splitting them into query and command repositories would introduce the
pattern where §7 records it absent. It would also collide with the documented static/no-DI/
`Db.Create()`-per-call design (§7: "Deliberate, documented in `Data/Db.cs` and
`Program.cs:40-45`"), which agent 06 may have views on.
**OOM-C — no read model, no event sourcing, and none proposed.**
Stated explicitly so a later phase does not read this file as a step toward one. Baseline §7
records no separate read model or event store, so per the role definition neither is in scope.
The `ToDetailDto(now)` status projection is a read-side _derivation_, not a materialised read
model, and CQ-007 argues it should stay that way.
**OOM-D — BL-011 affects every acceptance criterion here.**
Not a finding, a scheduling note: the FE suite is flaky under parallel load, so "CI green"
alone does not verify CQ-001..005. Verify against §3a/§4a numbers, per **BL-009** (no
coverage threshold is enforced anywhere, so nothing ratchets).
---
## Summary
| ID | Module | Title | Extends | Baseline | Effort | One deploy? |
| ------ | ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | -------------- | ------ | ----------------------- |
| CQ-001 | ssp/registratie | `createDraftSync` is a command factory owning 3 query paths | the command-factory idiom (`submit-change-request.ts`) | §7, §4a, §9 | M | yes |
| CQ-002 | ssp/registratie | 2 read stores write without the `runSubmit` fold; errors dropped | `runSubmit` + `createSubmitChangeRequest` | BL-007, §7 | S | yes |
| CQ-003 | ssp/brief | `runSubmit` (write fold + idempotency mint) used for 3 reads | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes |
| CQ-004 | libs/shared/application | `FeatureFlagStore.set` skips the fold, drops the error entirely | `runSubmit`/`SUBMIT_FAILED` (same folder) | BL-007, §7 | S | yes |
| CQ-005 | libs/beheer | 2 stamdata reads run through `runSubmit`; ship with CQ-003 | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes (with CQ-003) |
| CQ-006 | backend/Program.cs | read/write banner split abandoned in 5 of 7 feature sections | the `:441`/`:464` WP-65 banner pair, in-file | BL-003, §7,§3c | S | yes — land it alone |
| CQ-007 | backend/Program.cs | `GET /brief` creates; `POST /brief/reset` already exists | direction split in `Contracts/Dtos.cs`; `POST /brief/reset` | BL-003, §7 | M | **no** — FE+BE together |
**Modules with no findings:** ssp/auth · ssp/herregistratie · ssp/showcase+shell+root ·
bhp/auth · bhp/behandeling (the reference implementation) · bhp/shell+root ·
libs/shared/{infrastructure, upload, domain, contracts, kernel, ui, layout, testing,
environments} · backend/{Domain, Data, Zgw, Contracts, Stamdata}.
**Suggested sequencing.** CQ-003 + CQ-005 are one ticket (one shared-file split, five call
sites) and should go first — they make the direction legible at every call site, which is
what CQ-002 and CQ-004 then apply consistently. CQ-006 is independent and can run in
parallel on the backend. CQ-001 is the only FE ticket with real design content. CQ-007 is
the only one needing a coordinated deploy and the only one whose premise is arguable —
schedule it last, or take its documentation-only alternative.
**Honest scale.** Six of seven findings are S/M and none is a correctness defect except
CQ-004's dropped error and CQ-002's silent rollback. Baseline §6 (0 dependency violations,
textbook instability gradient) and §3c (97.6% backend line coverage) are accurate: this is a
well-structured codebase, and the CQRS-light work available is consistency work, not repair.
@@ -0,0 +1,9 @@
## Scope: [to be filled by agent]
## Status: not_started
## Last updated: -
## Depends on: [see agent prompt]
## ---
@@ -0,0 +1,582 @@
## Scope: ADR-0001…0006 (all six), checked against apps/ssp, apps/behandelportal, libs/shared, libs/beheer, backend/src/BigRegister.Api, backend/tests, e2e
## Status: complete
## Last updated: 2026-08-26
## Depends on: 00-baseline.md
## ---
# 06 — ADR conformance
Every claim below was verified by reading the code, not by trusting the ADR text or
CLAUDE.md. Where the two disagree with each other **and** with reality, that is flagged
as an ADR-fix, per CLAUDE.md's own "the docs win — update this file" rule.
**Headline.** The architecture is in good shape. All six ADRs are substantially
conformed to in their **decisions**; there is no case where the codebase has quietly
adopted a different architecture. What has decayed is the ADRs' **descriptive prose** —
four of the six describe a repo layout, a set of files, or an out-of-scope list that
WP-49…WP-75 has since overtaken. Five of the eleven findings are therefore ADR-fixes.
**On BL-002 (the auth duplication), my verdict up front, because the brief asked for an
argument rather than an assertion:** ADR-0002's non-sharing rule **needs no amendment and
should not be relaxed**. The prediction has not _failed_; it is _unexecuted_. See
ADR-C-004/ADR-C-006 for the reasoning and for what should actually be filed instead.
---
## ADR-0001 — BFF-lite endpoints + decision DTOs (Accepted)
**Core decision: conformed.** The two policy shapes the ADR prescribes are both live and
correctly applied.
- _Decision flag_ — verified server-computed and rendered, never recomputed:
`apps/ssp/src/app/brief/application/brief.store.ts:103-108` derives `canEdit`/
`canApprove`/`canReject`/`canSend`/`canRevealBigNummer` purely from
`BriefState.loaded.decisions`, with an explicit comment at line 31 ("this store never
computes them itself"). Same shape in
`apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:87-97`
(`decisions.canBesluiten`, rejected at the parse boundary if absent).
- _Config value_ — `apps/ssp/src/app/herregistratie/domain/intake.machine.ts:52` takes
`scholingThreshold` as a parameter; `SCHOLING_THRESHOLD_DEFAULT` (line 43) survives only
as the offline fallback the ADR sanctions, wired through
`application/intake-policy.store.ts:18-19`. No hardcoded `1000` is used as authority.
- _Parse boundary_ — 30 `parse*` functions (baseline §7). ADR-0001's own "out of scope"
item _"Runtime DTO validation on every endpoint (only the dashboard view has it)"_ is
substantially discharged.
- `libs/shared/src/application/remote-data.ts` + `<app-async>`, and `dep:check`'s
`ui-not-infrastructure` rule at 0 violations (baseline §6), keep the "infrastructure is
the only network layer" clause enforced — with one carve-out, ADR-C-002.
### ADR-C-001 — ADR-0001's worked example describes a POC that no longer exists
- **Type: ADR-fix**
- **ADR cited:** ADR-0001, §"Worked example in this POC" — opening sentence _"This POC has
no real backend (static mock JSON + fake submit timers), so the 'BFF output' is a static
file"_; §"Out of scope here", bullets 1 and 4.
- **Evidence (verified):**
- There is a real backend: `backend/src/BigRegister.Api/Program.cs` (940 lines, 48
endpoint mappings — baseline §2/BL-003).
- Every artifact the worked example names by path is gone. `find` returns nothing for
`public/mock/dashboard-view.json`, `public/mock/intake-policy.json`, or
`src/app/herregistratie/contracts/intake-policy.dto.ts`. `apps/ssp/public/mock/` does
not exist. The surviving contract file moved to
`apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` (WP-67's `src/` →
`apps/ssp/src/` move), so §A's three cited paths are all wrong.
- Out-of-scope bullet 1 ("runtime DTO validation on **every** endpoint — only the
dashboard view has it") is stale: 30 `parse*` boundaries exist. Out-of-scope bullet 4
("Real OpenAPI/TypeSpec codegen toolchain") is stale: `npm run gen:api` generates
`libs/shared/src/infrastructure/api-client.ts` (2372 lines, NSwag) and CI drift-checks
it.
- **Baseline citation:** baseline §2 (`Program.cs` 940 lines), §7 pattern inventory
(`parse*` trust boundaries = 30; excluded-as-generated api-client 2372 lines), BL-003.
- **Proposed resolution:** rewrite §"Worked example" against the shipped system and prune
the discharged out-of-scope bullets. No code changes.
- **What the amended ADR should say:** the worked example should read as _"implemented
against `backend/src/BigRegister.Api`"_, cite
`apps/ssp/src/app/registratie/{contracts/dashboard-view.dto.ts,infrastructure/dashboard-view.adapter.ts}`
and `GET /api/v1/dashboard-view` / `GET /api/v1/intake/policy` as the endpoints, and
reduce §"Out of scope" to the two items still genuinely open (the `BigProfileStore`
optimistic-update race, and session persistence / multi-tab sync).
- **Blocked code tickets:** none. This is purely descriptive drift; the decision is intact.
- **Effort: S**
### ADR-C-002 — `libs/shared/src/upload/` does network outside `infrastructure/`
- **Type: code-violates-ADR**
- **ADR cited:** ADR-0001, §Decision — the DTO/adapter seam; operationalised in CLAUDE.md
§4 as _"`infrastructure/` is the **only** layer that touches the network — the
anti-corruption boundary"_.
- **Evidence (verified):** `libs/shared/src/upload/upload.adapter.ts` injects `ApiClient`
(line 3 import, line 56 `inject(ApiClient)`) and opens a raw `XMLHttpRequest` at line
118 — i.e. it is a genuine network adapter — yet sits in a top-level `upload/` folder,
not under `libs/shared/src/infrastructure/`. Its sibling `upload.machine.ts` is an
Elm-style reducer sitting outside any `domain/` folder (the only one of 9 machines to do
so). The exception is **hard-coded into the enforcement itself**:
`.dependency-cruiser.base.js:105` reads
`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — the rule is written
around the violation rather than the violation being fixed, which is why baseline §6
reports 0 violations despite this.
- **Baseline citation:** BL-010 (verbatim: the exception "is already encoded rather than
resolved"); baseline §7 FE pattern inventory ("Infrastructure adapters (read side): 20
**+1 outside an `infrastructure/` folder**"; "Elm-style machines: 9 — 8 under a `domain/`
folder; outlier `libs/shared/src/upload/upload.machine.ts`").
- **Proposed resolution:** move `upload.adapter.ts` → `libs/shared/src/infrastructure/`,
`upload.machine.ts` (+ its spec) → `libs/shared/src/domain/`, and
`upload-controller.ts`/`upload-shell.service.ts` → `libs/shared/src/application/`. Then
**delete the `^libs/shared/src/upload/` carve-out** from
`.dependency-cruiser.base.js:105` — that deletion is the acceptance criterion, since it
is what proves the exception is resolved rather than relocated. Note the side benefit:
`libs/shared/src/domain` currently has 0% spec reach across 3 files (baseline §3b), and
this moves a well-specced machine into it.
- **Effort: M** (mechanical move + import updates across 30 dependents — `libs/shared/upload`
has Ca 30, baseline §6 — plus the depcruise rule edit)
### ADR-C-003 — `contracts/` vs the generated client: ADR-0001 and CLAUDE.md §4 no longer agree with the code
- **Type: ADR-fix**
- **ADR cited:** ADR-0001, §"Why DTOs _decouple_ rather than couple" — _"Manage it with
**one source of truth** (OpenAPI or TypeSpec) that **generates types for both sides**"_ —
read against CLAUDE.md §4's flat rule _"DTO lives in `contracts/`"_.
- **Evidence (verified):** only 4 hand-written `contracts/` DTO files exist
(`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`,
`libs/beheer/src/contracts/stamdata.dto.ts`), against 20 infrastructure adapters, 19 of
which import types from `@shared/infrastructure/api-client` directly. Crucially, the
surviving hand-written contract **documents its own obsolescence**:
`dashboard-view.dto.ts:10-11` says _"In production these types are GENERATED from the
OpenAPI/TypeSpec spec (one source of truth for both sides)"_. So the code has reached
ADR-0001's stated target state, and the hand-written `contracts/` files are the
pre-codegen scaffolding — but CLAUDE.md §4 still states the pre-codegen rule as the
standing law, which will mislead the next feature author.
- **Baseline citation:** baseline §7 FE pattern inventory — _"`contracts/` DTO files: 4 —
most adapters consume NSwag-generated types directly instead"_; agent-brief note "Only 4
`contracts/` DTO files exist".
- **Proposed resolution:** amend ADR-0001, then correct CLAUDE.md §4 to match (CLAUDE.md's
own precedence rule requires this direction).
- **What the amended ADR should say:** add a short §"Where the contract lives, after
codegen": the generated client (`libs/shared/src/infrastructure/api-client.ts`,
regenerated by `npm run gen:api`, drift-checked in CI) **is** the wire contract and the
single source of truth; a hand-written `contracts/*.dto.ts` is warranted only where
codegen does not reach — a hand-rolled `fetch`/XHR endpoint, or a shape the generator
types too loosely — and in either case the hand-written file must still import nothing.
The `parse*` trust boundary in `infrastructure/` is **unchanged and still mandatory**
regardless of where the type came from: a generated type is a compile-time claim about
the wire, not a runtime guarantee. Then decide explicitly whether the 4 survivors stay
(they are more precise than the generated shapes) or are retired — and record which.
- **Blocked code tickets:** any ticket that would either (a) delete the 4 remaining
`contracts/` files or (b) add new hand-written DTOs for already-generated endpoints must
wait for this amendment; today CLAUDE.md §4 can be cited to justify both directions.
- **Effort: S** (ADR + CLAUDE.md edit; the follow-on code decision is separately sized)
---
## ADR-0002 — User groups as actors, not bounded contexts (**Proposed**, amended WP-67)
This is the ADR with the most divergence, and the one the baseline pointed me at. Three
findings. The core modelling decision — contexts drawn by capability, not by who logs in —
**is** conformed to: there is no `zorgverlener/` or `behandelaar/` folder anywhere, the
contexts are `registratie`/`herregistratie`/`brief`/`behandeling`/`beheer` (capability
names), and both apps integrate through one backend aggregate via decision DTOs.
### ADR-C-004 — the `Principal` union never landed, although actor #2 did
- **Type: code-violates-ADR**
- **ADR cited:** ADR-0002 §3 ("Separate identity from authorization") — _"Model the
principal as a **discriminated union** … This replaces the flat `Session` the day a second
actor arrives"_; §Consequences — _"The one concrete FE change when actor #2 lands is
`Session → Principal` in the `auth` context"_; §"Out of scope" — deferred _"until a second
actor is actually introduced"_. Actor #2 was introduced in WP-61 and consolidated in
WP-67. **The deferral condition has been met and the change was not made.**
- **Evidence (verified):**
- `grep -rn "Principal" apps libs --include=*.ts` returns exactly **one** hit, and it is
a comment: `libs/shared/src/infrastructure/role.ts:8`. The type does not exist in the
frontend.
- `apps/behandelportal/src/app/auth/domain/session.ts` is byte-identical to the SSP's and
still reads `interface Session { readonly bsn: string; readonly naam: string }` — a
Behandelaar carrying a BSN, which §3 names as the precise thing the union exists to make
unrepresentable.
- The backoffice login is literally the citizen login. `apps/behandelportal/src/app/auth/ui/login.page.ts:31`
is `async login(bsn: string)`, renders `intro="Log in op uw persoonlijke BIG-register
omgeving."`, and calls `SessionStore.login(bsn)` →
`auth/infrastructure/digid.adapter.ts:14`, which returns
`ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' })`. A behandelaar logs into the
backoffice as a zorgverlener, by DigiD, with a citizen's name in the header.
- The medewerker identity that _does_ exist bypasses the auth model entirely:
`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts:14-19` stamps
`X-Medewerker`/`X-Rollen` onto every `/api/v1/` request. It never touches `Session`.
- **Baseline citation:** BL-002 (`ssp/auth` 211/211 significant lines duplicated;
`session.store.ts` 39 windows, `login-form.component.ts` 35, `login.page.ts` 23) — the
100% figure is the _measurement_ of this finding: the files are identical **because** the
modelling change that would differentiate them was skipped.
- **Proposed resolution:** land `Session → Principal` as ADR-0002 §3 specifies. In
`apps/behandelportal`: replace `Session` with the `medewerker` variant, replace
`DigidAdapter` with a `MedewerkerAdapter` that resolves `MEDEWERKER_ID` + `currentRollen()`
(the values `medewerker.ts` already holds) into a `Principal`, and make `login.page.ts`
an SSO-stand-in entry rather than a BSN form. In `apps/ssp`: the `zorgverlener` variant.
This makes the two `auth` contexts genuinely differ — which is what ADR-0002 §3 asserted
would happen and is the honest resolution of BL-002.
- **Effort: M**
### ADR-C-005 — ADR-0002 is still `Proposed` after two apps shipped against it
- **Type: ADR-fix**
- **ADR cited:** ADR-0002 header, `Status: Proposed · Date: 2026-07-01`.
- **Evidence (verified):** `apps/behandelportal` exists with 29 source files and 3 contexts
(baseline §2); the ADR has been amended once in-document (§"Amendment (WP-67,
2026-08-01)"); its structural rulings are enforced in CI today —
`.dependency-cruiser.<app>.js`'s `<app>-no-other-app` and `shared-no-beheer` rules run at
`severity: error` with 0 violations. An architectural decision that CI enforces is not
"Proposed". The other five ADRs are all `Accepted`, so this is an inconsistency in the
ADR set itself, not a deliberate signal.
- **Baseline citation:** baseline §7 "ADRs on record" — _"`0002` … (**Proposed**, amended
WP-67)"_; baseline §6 (11 `severity: error` rules, 0 violations, 223 modules cruised);
baseline §2 (apps/behandelportal: 29 src files, 1 309 lines).
- **Proposed resolution:** promote to `Accepted`, dated to WP-67.
- **What the amended ADR should say:** `Status: Accepted · Date: 2026-07-01 · Amended
2026-08-01 (WP-67)`. Also update §"Out of scope here", which still lists _"Building the
Behandeling backoffice application"_ and _"The backend aanvraag status lifecycle +
authorization endpoints/DTOs"_ as unbuilt — both shipped (WP-61…67; `AanvraagStatusTag`,
`GET /me` capabilities, `Domain/Authz.cs`). The one bullet that stays is real
authentication. Leave the `Session → Principal` bullet in scope but re-word it from
"deferred until a second actor is introduced" to a stated debt — it is ADR-C-004.
- **Blocked code tickets:** none strictly, but agent 08 should surface this **before**
ADR-C-004, because ADR-C-004's justification is "the ADR says to do this" and a `Proposed`
ADR is weak grounds for a refactor ticket.
- **Effort: S**
### ADR-C-006 — extract the actor-agnostic route guards to `libs/shared` (the part of BL-002 that will never diverge)
- **Type: code-violates-ADR** — but note carefully: it violates CLAUDE.md §2's
"composition over duplication" and the DRY intent, **not** ADR-0002 §3. ADR-0002 §3 is
about _identity and login flow_. A route guard is neither.
- **ADR cited:** ADR-0002 §3 — the scope of the non-sharing decision is `Principal` and the
login flow ("the two groups **authenticate differently**"); §Consequences names
`auth.guard.ts` and `session.store.ts` only as the _seams that localise_ the change, not
as things that must be duplicated. Read with CLAUDE.md §1's rule that a genuinely
cross-app concern belongs in `libs/shared`.
- **Evidence (verified):** `diff -ru apps/ssp/src/app/auth apps/behandelportal/src/app/auth`
reports **no content differences at all** — 9 of 11 files are byte-identical; the only
delta is two _additional_ files in behandelportal (`medewerker.ts`,
`medewerker.interceptor.ts`). Within those 9, `auth.guard.ts` is entirely actor-agnostic:
`authGuard` (lines 8-12) reads only `SessionStore.isAuthenticated()` and `Router`;
`capabilityGuard` (lines 25-33) adds only `AccessStore.can(capability)` and
`whenReady()` — and `AccessStore` already lives in `libs/shared/src/application`.
Both apps' routes redirect to the same `/login` and `/dashboard` paths
(`apps/behandelportal/src/app/app.routes.ts:10,17`). Both app configs already register
`{ provide: SESSION_PORT, useExisting: SessionStore }`
(`apps/behandelportal/src/app/app.config.ts:65`), so the seam for a shared guard exists
today.
- **Baseline citation:** BL-002 top clone pairs — `auth.guard.spec.ts` **36 windows** and
`auth.guard.ts` **21 windows**, i.e. 57 of the 211 duplicated lines, the single largest
block after `session.store.ts`.
- **Proposed resolution:** move `authGuard`/`capabilityGuard` + `auth.guard.spec.ts` to
`libs/shared/src/application/` (or a `libs/shared/src/routing/`), injecting `SESSION_PORT`
instead of the app-local `SessionStore`. One small widening is needed:
`libs/shared/src/application/session.port.ts:9-12` currently exposes only
`session: Signal<{naam: string} | null>` and `logout()` — add
`readonly isAuthenticated: Signal<boolean>` (or have the guard derive it from
`session() !== null`, which both `SessionStore`s already do at
`session.store.ts:40`). Each app keeps a two-line re-export at `@auth/auth.guard` so its
`app.routes.ts` is untouched.
- **Explicitly NOT proposed, and why.** No ticket to merge `session.store.ts`,
`session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and **no
ADR-fix relaxing ADR-0002 §3.** The brief invited me to treat BL-002 as a prediction the
code failed to bear out. It is not. Those five files are identical because ADR-C-004 was
never executed — the divergence the ADR predicted has in fact already arrived, it just
arrived through an orthogonal side door (`medewerkerInterceptor`, a dev-only HTTP header
stamp) instead of through the `Principal` seam the ADR designated. Merging them now would
cement a citizen DigiD/BSN login as the backoffice's shared login, which is the one
outcome ADR-0002 §3 was written to prevent. The correct sequencing is ADR-C-005 (accept
the ADR) → ADR-C-004 (land `Principal`) → **re-measure**. My expectation is that
post-ADR-C-004 the residual `ssp/auth` ↔ `bhp/auth` duplication drops from 211 lines to
under 40 on its own. If ADR-C-004 is still unstarted at the **next backlog cycle**, that
is the point at which the ADR-fix conversation becomes legitimate — not now.
- **Effort: S**
### Observation for agent 07 (BIO2), not a ticket here
`medewerkerInterceptor` is registered **only** inside `isDevMode()`
(`apps/behandelportal/src/app/app.config.ts:57-63`). In a production build the backoffice
therefore sends no `X-Medewerker`/`X-Rollen` at all, and `StubIdentityProvider` falls
through to its zorgverlener default. ADR-0002 §3's "authorization enforced at the backend
boundary" holds structurally, but the behandelportal's _identity_ has no non-dev path. The
ADR lists real employee SSO as out of scope, so this is not an ADR conformance defect —
flagging it because it is the kind of thing a compliance pass should see stated, and
because ADR-C-004 is the natural place to close it.
---
## ADR-0003 — CIBG Huisstijl (Bootstrap 5.2) as the design system (Accepted)
**Core decision: conformed.** All five decision points hold. The package is vendored at
`public/cibg-huisstijl/` (`css`, `fonts`, `icons`, `images` present, licensed RO/Rijks text
fonts absent per point 5); the token bridge is intact at `libs/shared/styles.scss`; atoms
emit Bootstrap classes with their `input()` APIs preserved; `check:tokens`
(`scripts/check-tokens.sh`) runs in `npm run ci`; the gap register exists at
`libs/shared/docs/cibg-gaps.mdx` with 9 `// CIBG-GAP EXTENSION:` markers in code.
### ADR-C-007 — ADR-0003's file paths and its `app-alert` example are both stale
- **Type: ADR-fix**
- **ADR cited:** ADR-0003 §Decision point 1 (`src/index.html`), point 2 (`src/styles.scss`),
point 4 (_"CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert` is a small
token-styled surface"_), §Consequences (`.storybook/`, `src/docs/cibg-gaps.mdx`).
- **Evidence (verified):**
- Every path moved in WP-67: `src/styles.scss` → `libs/shared/styles.scss`;
`src/index.html` → `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html`
(two now, not one); `.storybook/` → `.storybook-ssp/` and `.storybook-behandelportal/`;
`src/docs/cibg-gaps.mdx` → `libs/shared/docs/cibg-gaps.mdx`.
- Point 4's `app-alert` claim is **factually wrong about the current code**.
`libs/shared/src/ui/alert/alert.component.ts:13-16` documents itself as a _"Thin wrapper
over the vendored `.feedback feedback-*` classes: the design system owns surface + icon"_,
and the template (lines 31-37) binds `.feedback-info`/`.feedback-success`/
`.feedback-warning`/`.feedback-error`. Its only local CSS is a 3-line flex fix. It is not
hand-rolled and carries no `CIBG-GAP` marker — correctly, since it is not a gap.
- **CLAUDE.md §2 repeats the same stale claim** verbatim: _"(Where CIBG lacks a class —
e.g. `alert` — the atom is a small hand-rolled surface built from the token bridge; see
ADR-0003.)"_ Both documents must be corrected, ADR first.
- **Baseline citation:** baseline §2 (the monorepo layout the paths must be rewritten
against: apps/ssp, apps/behandelportal, libs/shared, libs/beheer); baseline §7 config-seam
tokens (`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`/`DEBUG_PANEL`) — the WP-67 two-app split
these paths belong to.
- **Proposed resolution:** repoint all five paths; replace the `.alert` example in point 4.
- **What the amended ADR should say:** point 4 should keep the principle ("hand-roll what
CIBG's build drops, and mark it") but pick a live example — `skeleton` and `spinner` are
the cleanest (both in the register, both genuinely absent from the vendored build) — and
note that `.alert` was subsequently resolved onto the vendored `.feedback` classes, so
it is no longer a gap. CLAUDE.md §2's parenthetical must be corrected in the same diff.
- **Blocked code tickets:** none.
- **Effort: S**
### ADR-C-008 — the CIBG gap register is one row behind the markers in code
- **Type: code-violates-ADR** (the artifact the ADR mandates is incomplete; the fix is a
one-row doc edit, not a code change)
- **ADR cited:** ADR-0003 §Consequences, final bullet — _"Hand-rolled components (point 4)
are tracked in the **CIBG gap register** … every deviation from the design system carries
a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently drifting."_
- **Evidence (verified):** 9 files carry a `CIBG-GAP` marker
(`debug-state`, `language-switcher`, `wizard-shell`, `application-link`,
`placeholder-chip`, `rich-text-editor`, `skeleton`, `spinner`, `status-badge`); the
register table at `libs/shared/docs/cibg-gaps.mdx:26-33` has **8** rows.
`libs/shared/src/layout/language-switcher/language-switcher.component.ts:7-9` carries a
full, well-formed marker (`"Taal instellen" … no vendored Huisstijl class ships for it …
See cibg-gaps.mdx`) and has no corresponding row. The register's own §"Keeping this
register honest" concedes there is no automated diff and instructs _"If markers and this
table drift, trust the code and fix the table"_ — so this is exactly the drift it
predicted, caught by review as designed.
- **Baseline citation:** baseline §2 (libs/shared 86 src files / 5 194 lines — the surface
the register must cover); baseline §6 (`libs/shared/src/layout` is a cruised module with
Ca 22).
- **Proposed resolution:** add the `language-switcher` row (CIBG concept: "Taal instellen";
reason: no vendored class in this build). Optionally add the CI script the register
declines — a ~10-line `grep -l CIBG-GAP | diff` in `scripts/` folded into `check:tokens`
would make the drift impossible to reintroduce. I would file the row as the ticket and
the script as an explicitly optional second step, matching the register's own
proportionality argument.
- **Effort: S**
---
## ADR-0004 — Stamdata as code (Accepted)
**Core decision: conformed.** `backend/src/BigRegister.Api/Stamdata/` holds 15 files —
typed C# (`Beroep.cs`, `PolicyQuestions.cs`, `Professions.cs`, `StamdataCatalog.cs`,
`StamdataTable.cs`, `StamdataFile.cs`) plus the checked-in JSON data-files
(`beroepen.json`, `documentconfidentialiteit.json`, `opleidingen.json`,
`professions.json`, `specialismen.json`) the WP-29 follow-on introduced. Coverage is 96.8%
line (baseline §3c). The `beheer/stamdata` editor is read-only-plus-download, not a write
path, exactly as the ADR's own WP-29 note states. There is **no** runtime write endpoint
for any stamdata table.
### ADR-C-009 — feature flags are a second runtime-editable exception the ADR does not acknowledge
- **Type: ADR-fix**
- **ADR cited:** ADR-0004 §Decision — _"Never a production database, never runtime-editable"_
— and §"The deliberate exception: org-templates", which names **one** exception in the
singular and justifies it narrowly ("specific to one sub-organization's identity").
- **Evidence (verified):** `backend/src/BigRegister.Api/Data/FeatureFlagStore.cs` is a
second admin-writable SQLite surface, added by WP-47 — after ADR-0004 (2026-07-20) — and
**its own doc-comment states the equivalence the ADR does not**: _"Runtime feature-flag
state (WP-47). SQLite-backed like `OrgTemplateStore`, same single-gate idiom."_ It exposes
`Set(key, enabled)` writing `db.FeatureFlags`, surfaced through the admin page
`/beheer/functies` (`apps/behandelportal/src/app/app.routes.ts:44-50`, gated by
`capabilityGuard('flags:manage')`).
- **Assessment — and why this is an ADR-fix, not a violation.** The design is genuinely
ADR-0004-shaped, not a breach of it: the _catalog_ (which flags exist, their descriptions
and defaults) is compiled-in code (`Domain/Features/FeatureFlags.Catalog`), only the
boolean override persists, and `IsEnabled` fails closed for an unknown key
(`FeatureFlagStore.cs:45-47`), so a bad DB row cannot invent a flag. That is the ADR's
actual principle — schema and values gated at compile time — applied correctly. What is
wrong is the ADR's _text_: it states the rule as a closed list of one exception, which
means the next operational-config surface has no principle to test itself against and
will either be waved through or blocked on a technicality.
- **Baseline citation:** baseline §7 backend pattern inventory — the 7 static stores listed
as "Not behind any port", which includes both `OrgTemplateStore` **and**
`FeatureFlagStore`; baseline §3c (`backend/Stamdata` 96.8% line / 71.7% branch;
`backend/Data` 99.0% / 75.5%).
- **Proposed resolution:** amend ADR-0004. No code change; the code is right.
- **What the amended ADR should say:** replace §"The deliberate exception: org-templates"
with §"The deliberate exception: operational configuration", stating the **test** rather
than a list — runtime-editable persistence is permitted only when (1) the catalog/schema
of what may be set lives in code, (2) an unknown or unlisted key is rejected/fails closed,
(3) the value is operational (per-organisation identity, an on/off rollout switch) and not
a shared business rule whose wrong value breaks the register for everyone, and (4) writes
are admin-capability-gated and audited. Then list the two surfaces that pass it today —
`OrgTemplateStore` (WP-23/26) and `FeatureFlagStore` (WP-47) — and note that both are
admin-gated. Add a matching sentence to CLAUDE.md §4, which currently repeats the
singular framing ("Org-templates are the deliberate exception").
- **Blocked code tickets:** any ticket proposing a third runtime-editable config surface
should wait for this test to be written down, rather than arguing by analogy to
org-templates.
- **Effort: S**
---
## ADR-0005 — OpenZaak (ZGW APIs) behind the BFF (Accepted)
**Conformed, with no findings.** This is the cleanest ADR in the set and I am recording
that plainly rather than manufacturing a ticket.
Verified point by point:
- The anti-corruption layer is in the .NET BFF and nowhere else. Every ZGW type lives under
`backend/src/BigRegister.Api/Zgw/` (8 files: `OpenZaakZaakSource`,
`OpenZaakDocumentSource`, `ZgwHttpClient`, `ZgwTokenProvider`, `ZgwZaakMapper`,
`ZgwOptions`, `ZgwDiagnosticHandler`, `NotificatieDto`). `grep` for
`OpenZaakZaakSource|OpenZaakDocumentSource` outside `Zgw/` returns only the two DI
registrations in `Program.cs:67,69` and eight explanatory comments — no ZGW shape reaches
a consumer.
- The config-switched port pair is exactly as decided: `Program.cs:59-84` reads
`Zgw:Enabled` and binds either `OpenZaakZaakSource`/`OpenZaakDocumentSource` (via
`AddHttpClient`, with the WP-60 15s timeouts) or `LocalZaakSource`/`LocalDocumentSource`.
Default is local, so the POC still runs fully offline as the ADR's second constraint
requires.
- The FE is untouched by the switch: consumers inject the interface only
(`Program.cs:206,281,340,425,434,446,473`), and the same `ApplicationSummaryDto` is
returned either way — zero DTO/api-client drift, matching the ADR's first `+`.
- The ADR's stated **minus** is still accurate and honestly scoped: `Program.cs:465`
confirms _"The local write runs against ApplicationStore directly (not the IZaakSource
seam)"_, which is precisely the "only some endpoints have a source interface; each future
slice introduces its own seam" consequence the ADR wrote down. An ADR that predicted its
own remaining gap and the gap stayed where predicted is conformance, not drift.
- `backend/Zgw` is the best-covered backend module at 98.1% line / 85.5% branch (baseline
§3c) and the second-lowest duplication (1.8%, baseline §5) — the seam the ADR claimed
would be "unit-testable without a live server" measurably is.
Note for agent 03: BL-006 (no NetArchTest/ArchUnitNET, one assembly) means nothing
_enforces_ the "ZGW shapes never leave `Zgw/`" property that this ADR depends on. It holds
today by convention. That is agent 03's ticket to size, not mine — I record only that
ADR-0005's conformance is currently review-maintained, not CI-maintained.
---
## ADR-0006 — Test data through the production door (Accepted)
**Core decision: partially conformed.** Three of the five mechanisms are properly in place;
the frontend replay idiom — the ADR's own flagship — is adopted in one spec out of nine.
Conformed:
- §1 backend type-state builder — `backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs`
exists and is used in 5 test files (`Acceptance/BesluitLifecycleTests.cs`,
`Acceptance/IntakeSubmissionTests.cs`, `ApplicationTests.cs`,
`Domain/BeoordelingRuleTests.cs`, `OpenZaakZaakSourceTests.cs`) — the exact two files the
ADR's Context named as the problem, plus three more.
- §4 `RemoteData` named constructors — `libs/shared/src/testing/remote-data.ts` exists.
- §5 e2e actors/seed-refs — `e2e/support/actors.ts` exists; no page-object layer was added.
- No `'x' as BrandedType` cast appears in any spec (`grep` for
`as Postcode|as Uren|as BigNummer|as Bsn` in `*.spec.ts`: zero hits) — the §3 illegal
route is genuinely closed.
### ADR-C-010 — four machine specs hand-roll the exact state literal ADR-0006 §2 forbids
- **Type: code-violates-ADR**
- **ADR cited:** ADR-0006 §2 (_"No object is built directly. A fixture is the result of
running real `Msg`s through the real `reduce`"_) and the Decision table's "Pure reducer /
state machine (frontend)" row: **Do not** — _"A literal returning `{ tag: 'Editing', ... }`
by hand"_; plus §Consequences (_"a hardcoded `errors: {}` fixture literal can't drift from
what validation actually produces"_).
- **Evidence (verified) — each of these returns a state literal, and three of the four
hardcode the `errors: {}` the ADR calls out by name:**
- `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts:19-25` —
`const answering = (...): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold })`.
This is the worst instance: `apps/ssp/src/app/herregistratie/domain/intake.testing.ts`
exists **in the same folder**, exports `givenIntake`, and is the ADR's own quoted example
(§2's code block is `export const givenIntake = given(reduce, initial)`) — yet
`givenIntake` is imported only by `intake.acceptance.spec.ts`, never by the machine spec
it was written for.
- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts:25-31` —
`const invullen = (...): RegistratieState => ({ tag: 'Invullen', draft: {...}, cursor, errors: {}, upload: initialUpload })`.
- `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts:5-9` —
`const editingWith = (...): BesluitState => ({ tag: 'Editing', draft: {...}, errors: {} })`.
- `apps/ssp/src/app/brief/domain/brief.machine.spec.ts:67-74` —
`const loaded = (...): BriefState => ({ tag: 'loaded', brief, availablePassages, decisions })`.
- For contrast, two specs **do** conform and should be the pattern to copy:
`libs/beheer/src/domain/stamdata-editor.machine.spec.ts:17-23` (`seedLoaded()` =
`reduce(initial, {tag:'Loaded', …})`) and
`apps/ssp/src/app/brief/domain/org-template.machine.spec.ts:29-30`
(`reduce({tag:'loading'}, {tag:'DraftLoaded', …})`). Only 1 of 9 machines has a
`*.testing.ts`; `change-request.machine.spec.ts:6` honours the idiom but declares
`given(reduce, initial)` inline in the spec rather than in a `*.testing.ts`.
- **Baseline citation:** baseline §7 FE pattern inventory (Elm-style machines: 9);
baseline §3a — `ssp/herregistratie` 70.9% line / 67.8% branch and `ssp/brief` 75.3% /
68.8%, i.e. the two modules whose specs hand-roll states are also two of the three
weakest-covered non-auth FE modules, which is consistent with fixtures asserting shapes
the reducer may not actually produce.
- **Proposed resolution:** add `*.testing.ts` next to each of the four machines
(`registratie-wizard`, `brief`, `besluit`, plus adopt the existing `intake.testing.ts`),
each a one-liner `export const givenX = given(reduce, initial)` per §2, and rewrite the
four literal helpers as message replays. Where a state genuinely is not reachable by
replay, that is a finding in its own right and should be recorded in the ticket rather
than worked around — it means the reducer cannot produce a state the spec asserts.
- **Effort: M** (four specs; mechanical but each needs the right message sequence worked out,
and `brief.machine.spec.ts` is the largest)
### ADR-C-011 — `unwrapOk` has zero adopters; its one call site reimplements it inline
- **Type: code-violates-ADR**
- **ADR cited:** ADR-0006 §3 (_"Value objects → `unwrapOk`, never a cast"_) and the Decision
table's "Value object / parser" row.
- **Evidence (verified):** `unwrapOk` is defined in
`libs/shared/src/testing/value-object.ts` and referenced nowhere else in `apps/` or
`libs/` except `libs/shared/docs/testing.mdx` — zero spec consumers. The one place that
needs it hand-rolls the same three lines:
`apps/ssp/src/app/registratie/application/submit-change-request.spec.ts:8-9` reads
`const telefoon = parseTelefoonnummer('0612345678'); if (!telefoon.ok) throw new Error('fixture phone should parse');`
— semantically identical to `unwrapOk(parseTelefoonnummer('0612345678'))`, which is
exactly the duplication the ADR shipped the helper to remove.
- **Baseline citation:** BL-004 (122 of 220 FE source files never loaded by any Vitest run
— `libs/shared/src/testing` is one of the few modules at 100% reach, so a helper here that
nothing imports is measurably dead weight, not merely unexercised); baseline §3a
`libs/shared/testing` 3 files, 100% line.
- **Proposed resolution:** one-line change in `submit-change-request.spec.ts` to call
`unwrapOk`. Then judge honestly whether one call site justifies keeping the helper — if a
future ticket finds it still has one consumer, deleting it and keeping the inline guard is
the equally valid answer, and ADR-0006 §3's real requirement (never a cast) is satisfied
either way. File it as "adopt or delete", not "adopt".
- **Effort: S**
---
## ADR-fix tickets (require architect approval)
Agent 08 must surface these five for human sign-off. None of them are code changes; all
five change what the repo's architecture documents _claim_, and two of them gate code
tickets.
| ID | ADR | What the amendment does | Gates | Effort |
| ------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------ |
| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the two discharged out-of-scope bullets | nothing | S |
| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps; correct CLAUDE.md §4 | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint | S |
| **ADR-C-005** | 0002 | `Proposed` → `Accepted`; refresh §"Out of scope" (backoffice + status lifecycle + authz DTOs all shipped) | **ADR-C-004** — a `Proposed` ADR is weak grounds for a refactor ticket, so this must land first | S |
| **ADR-C-007** | 0003 | Repoint five WP-67-stale paths; replace the false `app-alert` hand-rolled example; correct CLAUDE.md §2 | nothing | S |
| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces; correct CLAUDE.md §4 | any future third runtime-editable config surface | S |
**Ordering dependency, called out explicitly as the brief requires:** ADR-C-005 → ADR-C-004
→ (re-measure BL-002) → possibly-nothing. ADR-C-006 is deliberately **not** in this chain:
extracting the actor-agnostic route guards is compatible with ADR-0002 §3 as written and
needs no approval. **No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** I
considered it, as instructed, and rejected it: the rule's prediction has not been falsified,
it has not been tested, because the change that would test it (ADR-C-004) was never made.
Amending an ADR to match code that never executed its decision would ratify the omission
rather than the evidence.
---
## Summary
| ADR | Verdict | Findings |
| -------- | --------------------------------------------------- | --------------------------------------- |
| **0001** | Decision conformed; prose stale | ADR-C-001 (fix), 002 (code), 003 (fix) |
| **0002** | Decision conformed; §3 unexecuted; still `Proposed` | ADR-C-004 (code), 005 (fix), 006 (code) |
| **0003** | Conformed; two documentation defects | ADR-C-007 (fix), 008 (code/doc) |
| **0004** | Conformed; exception clause under-general | ADR-C-009 (fix) |
| **0005** | **Fully conformed — no findings** | — |
| **0006** | Partially conformed; §2 adopted 1 of 9 | ADR-C-010 (code), 011 (code) |
Six code tickets (1×M, 1×M, 1×M, 3×S) and five ADR-fixes (all S). No finding proposes
introducing a pattern that does not already exist in the repo, and every one cites a
baseline observation or metric row.
@@ -0,0 +1,437 @@
## Scope: all findings from 00-baseline, 02-testability, 04-cqrs-light, 06-adr-conformance, 07-bio2-compliance — deduplicated, scored, CD-sequenced
## Status: complete
## Last updated: 2026-08-27
## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md, 07-bio2-compliance.md
## ---
# 99 — Consolidated refactoring backlog
**47 findings in, 33 open tickets + 5 ADR-fixes + 1 shipped set out.** Everything below
traces to at least one `TE-`/`CQ-`/`ADR-C-`/`BIO-` finding and cites a baseline metric.
**HALT lifted 2026-08-27** — the operator approved the backlog and Phase 3 started.
**CD batch 1 (RB-01..RB-06) is implemented**, one commit per ticket on branch
`refactor/adr-c-006-shared-route-guards`, each with a note in `implementation/rb-0N.md`.
Batches 2–6 are untouched. The `Status` column below is the source of truth.
Two batch-1 findings had knock-on effects a later ticket must not re-derive:
- **RB-01's residual is RB-09's problem.** Both callers of the document-content endpoint
reach it as a plain browser navigation (`<a href>` / `previewUrl`), carrying no identity
header and passing through no interceptor, so `StubIdentityProvider` answers with the
seeded citizen. The links keep working only because one citizen owns every document in
the POC. That is BIO-002; RB-09 needs this endpoint to receive a real credential.
- **RB-06 also deleted `SubmissionRules.RejectRegistratie`**, which the row did not ask for.
It was reachable only from the deleted endpoint and contradicted by the live submit path.
Recorded as the ticket's one judgement call in `implementation/rb-06.md`.
`Pii.MaskTail` now lives in `Domain/People/Pii.cs` (moved out of `Program.cs` by RB-03) —
**RB-11 and any later redaction work should use it rather than hand-rolling a second copy.**
---
## Coverage of this backlog — read this before treating it as complete
Three of the seven Phase 1 agents were **deliberately skipped** by the operator
(reasons recorded in `_status.md`). This backlog therefore contains **no findings of the
following kinds**, and their absence is not evidence that none exist:
| Agent not run | Category of finding that is absent |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **01 — readability** | Function/file length, naming, nesting depth, comment quality, dead code, test readability. No ticket below is a "this is too long/unclear" ticket. |
| **03 — DDD/hexagonal** | Backend layering, vertical-slice structure, port extraction, module boundaries. The backend's structure is untouched except where CQRS-light reached it. |
| **05 — BDD** | Nothing material — the agent self-reduced to a structural note; `gen:behaviour-spec` already covers the intent. |
Concrete consequences, so nobody assumes these were considered and dismissed:
- **`createDraftSync` (143 lines, the longest function in the repo, §4a) is only partly
addressed.** RB-21 splits its read half out on CQRS grounds. Whether the remainder is
still too long was never assessed.
- **The other named length/complexity candidates have no owner:**
`api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11) —
the only two CC>10 functions outside the mandated idioms per **BL-001**; the 293-line
CC-20 test method in `OpenZaakZaakSourceTests.cs`; and the six files over 400 lines
(§9). RB-19 reorders `Program.cs` but does not shorten it.
- **Backend structure was assessed only through the CQRS-light lens.** **BL-003**'s
invitation (940 lines → `Features/`) is filed as out-of-mandate **OOM-A**, not a ticket.
**BL-010** (`libs/shared/upload/` outside the layer convention) is resolved only
incidentally, by RB-24, which came from the ADR agent rather than the structure agent.
- **Two baseline observations remain unowned by any agent:** **BL-005** (backend branch
coverage 18 points behind line coverage; `Contracts` 65.0%, `Stamdata` 71.7%, `Data`
75.5% — `backend/tests/` has no `Contracts/` folder at all) and **BL-009** (no coverage
ratchet anywhere). Neither is a testability _blocker_, so agent 02 correctly declined
both; they are coverage work with no seam to add, and no ticket below covers them.
---
## Already done — implemented and committed, do not re-file
Branch `refactor/adr-c-006-shared-route-guards`, five commits.
| Finding | Commit subject | Status | Residual |
| ------------- | ----------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **ADR-C-005** | `docs(adr-0002): accept, and record the unbuilt Principal union as debt` | **implemented** | ADR-0002 is now `Accepted`, so **RB-13 (ADR-C-004) now stands on a correct ADR** — that was the whole point of the gate. |
| **ADR-C-006** | `refactor(auth): share the actor-agnostic route guards (ADR-C-006)` | **implemented** | Auth duplication **211 → 151 lines**. §5's `ssp/auth 100% / bhp/auth 86.8%` rows and the `auth.guard*` clone pairs in the baseline are now **stale** — re-measure before citing them. Standing compliance criterion from agent 07: any future change to `authGuard`/`capabilityGuard` is an access-control change and must re-run the guard spec for both apps. |
| **CQ-004** | `fix(flags): surface a failed admin toggle instead of swallowing it` | **implemented** | **Half of its compliance criterion is unmet.** Agent 07 required "fix the FE error **and** the BE audit row together". The FE error shipped; `PUT /admin/flags/{key}` still writes **no** audit row. That half is carried by **RB-07**, and it is why **ADR-C-009** must not be signed off before RB-07 lands. |
| **TE-009** | `fix(stamdata): evaluate the profession validity window per call, not at type-load` | **implemented** | Also closed the latent dead-`ActiveOn`-branch bug. Not compliance-flagged. |
| **BL-008** | `build: make coverageExclude actually exclude the generated API client` | **implemented** | The reported `libs/shared/infrastructure` figure should now read ≈94.7%, not 6.9%. §3a is stale on that row. |
**Correction to the hand-off.** The brief listed "CQ-002/004 (`FeatureFlagStore.set`)" as
fixed. Only **CQ-004** was — `FeatureFlagStore.set` is the CQ-004 subject. **CQ-002**
(`ApplicationsStore.cancel`, `AdminCasesStore.delete`) is **verified still open**: both
still do `try { await this.adapter.x(id) } catch { this.state.set(before) }` with no
`runSubmit`, no `Result`, and no error channel. It is filed below as **RB-20**.
---
# The backlog
**How to read the CD batch column.** A batch is a _suggested ordering wave_, not a release
train. Every ticket in the table ships **alone**, on its own merge, without any other
ticket in its batch. Where a ticket genuinely cannot ship alone it was split into a chain
(RB-22/RB-23) — see "Tickets that were rejected and split". `Depends on` means _must be
deployed first_, not _must ship together_.
**Compliance column.** `SIGN-OFF` = requires compliance sign-off before merge, per rule 4.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** |
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** |
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** |
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** |
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** |
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | **done** |
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | **done** |
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | **done** |
---
## Notes on the table
**Why P1 is not simply "everything".** Rule 2's P1 definition ("violates a correct ADR,
blocks testability, or is a BIO2 compliance risk") would catch nearly every finding, which
would make the score useless. It is applied as: **P1 = a control is broken, an accepted
ADR's decision is unexecuted, or a security-relevant guard has no test today.** A ticket
that is merely _flagged because it touches a control_ (TE-003/4/5/6/8, CQ-006, ADR-C-002)
is **P2 with mandatory sign-off** — the compliance risk is one the ticket could introduce,
not one that exists. That distinction is the whole reason rule 4 is orthogonal to rule 2.
**RB-01 and RB-02 sort above every structural ticket** regardless of effort. Both are live
production-shaped defects, independently verified: a BSN concatenated into the persisted
authz audit `Resource` (`Program.cs:674`) and an unauthorized document-content endpoint
(`GET /uploads/{documentId}/content`). Four documents claim the audit trail holds no PII
and the test cited as enforcing it (`AuthzAuditTests.cs:51-53`) asserts on **column
names**, so the BSN travels in a column called `Resource` that the regex cannot see — the
value-asserting test is part of RB-02's definition of done, not a follow-up.
**RB-11 ships the doc correction in the same diff as the code.** `?role=` and `?subject=`
are _not_ stripped from production builds on three hand-written `fetch` adapters, while
`docs/reference/roles-and-access.md:23` says "they do not exist in a production build".
Correcting the doc without the code, or the code without the doc, both leave the repo
lying about itself. `?subject=` additionally writes a **BSN into `sessionStorage`** in any
build, which is the specific thing `SessionStore`'s G1 comment promises never happens.
**RB-12 before RB-19, deliberately.** Agent 07 flags CQ-006 as needing the authz suites as
its safety net; agent 04 flags it as the prerequisite for OOM-A. RB-12's route-table test
is the check that "each moved endpoint kept its gate" is verified by CI rather than by a
reviewer's eye across a 900-line diff. RB-19 carries the only **High** risk in the table
for exactly that reason and must land alone, never mixed with a behaviour change.
**RB-07 gates ADR-C-009, not the other way round.** Agent 06's proposed four-part test for
runtime-editable config includes "writes are admin-capability-gated **and audited**".
Today they are gated and not audited. Signing the ADR amendment first would ratify a
control the code does not implement.
**RB-13's dependency on RB-09 is real, not stylistic.** Landing `Principal` on the
frontend alone closes ADR-C-004 and leaves BIO-002 wide open: a production behandelportal
build still resolves to the seeded **zorgverlener** — failing closed on backoffice
capabilities (correctly) but **open on every citizen-scoped endpoint** and holding
`CanRevealBigNummer`, because `drafter` is the no-header default. RB-09 makes "no
identity" representable at the interface; RB-13 is the FE half.
---
## Merges — what was deduplicated, and how confident each merge is
| Merged ticket | Findings folded in | Confidence | Reasoning |
| --------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **RB-10** | TE-001 + BIO-017 | **Certain** | Agent 07 says outright: "this is TE-001 plus one assertion; it does not need its own ticket if TE-001 is scheduled". BIO-017's second half (`redactProfile` spec) is a five-line spec in the same PII-guard category, so it rides along. |
| **RB-11** | BIO-012 + TE-002 + BIO-006(a) + BIO-006(b) | **Certain** | Agent 07 instructs: "Fix all three in one touch of the file, or the next reviewer will re-open it." All four land in the same three `fetch` adapters plus `role.ts`/`subject.ts` plus one doc line. BIO-006(b) is the same doc edit as BIO-012's. |
| **RB-09** | BIO-001(a) + BIO-001(b) + BIO-002 | **Certain** | BIO-001's own remediation _is_ (a) fail-fast + (b) "give `Resolve` a way to say no identity (see BIO-002)". BIO-002's root cause is the same non-nullable `Resolve`. One change, one file pair. |
| **RB-17** | CQ-003 + CQ-005 | **Certain** | Agent 04: "Fix them in one ticket; they are listed separately only because the module scope requires it." One shared-file split, five call sites. |
| **RB-14/12** | BIO-016 split into (a) and (b) | **Certain** | Two unrelated CI changes of different size and different value; the rest of BIO-016's "Absent" list is genuinely a production gate and stays on the checklist. |
| **RB-08** | BIO-003, sequenced behind RB-07 | High | Routing through `CasesAdmin` gives BIO-003's missing audit row for free **once** RB-07 has moved auditing to the allow path. Shipping BIO-003 first would mean writing the audit call twice. It can ship standalone if RB-07 slips. |
| **RB-18** | BIO-018, sequenced behind RB-17 | High | Agent 07: "Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set." Not a merge, an ordering constraint. |
| **RB-25/26/27** | TE-003/004/005, sequenced behind RB-24 | **Judgement call** | Agent 04 argued BL-010 must be resolved before anything is layered onto the upload folder, and RB-24 (ADR-C-002) is the ticket that resolves it. But the three seams are each independently shippable **today**, against the current paths. If RB-24 is deferred or rejected, unblock all three — the dependency is hygiene, not correctness. |
**Merges considered and rejected:**
- **BIO-008 / BIO-009 / BIO-010 kept as three tickets (RB-02/04/05).** They share a theme
("no BSN in any audit row, log line or persisted error field") and a shared acceptance
criterion (assert on **values**, e.g. no stored string matching `\d{9}`). They were not
merged because they sit in three modules with three different test suites, and BIO-010
is conditional on `Zgw:Enabled` (off by default) which gives it a different risk profile.
Three one-line fixes that each ship alone beat one cross-module sweep. **If a reviewer
prefers one ticket, merging them is defensible** — this is the least settled call here.
- **CQ-002 not merged into BIO-007 (RB-07).** They are the two halves of the same
admin-mutation-observability gap, but one is FE error surfacing and the other is BE
auditing. Agent 07 asked only that they "ship aware of each other". Cross-referenced,
not merged.
- **`SessionStore` not merged across the TE-001 / residual-auth-duplication overlap.**
Both touch `session.store.ts`, but agent 06 is explicit that merging the two apps'
session stores now would cement a citizen DigiD/BSN login as the backoffice's login —
the exact outcome ADR-0002 §3 exists to prevent. RB-10 lands the same seam **twice**, on
purpose. The duplication question reopens only after RB-13, on re-measurement.
- **ADR-C-004 not merged into BIO-002.** Split into RB-09 (BE, S) → RB-13 (FE, M) instead,
because a single ticket spanning both would not be independently deployable.
---
## Tickets that were rejected and split (rule 3)
**CQ-007 → RB-22 then RB-23.** As filed, CQ-007 is the one finding agent 04 marked
"**no** — FE+BE together": the FE must handle a 404 that the BE does not yet return.
Shipping it as one ticket is a coordinated release. Split into the standard
expand/contract pair:
1. **RB-22 (expand, FE).** `BriefStore.load()` tolerates a 404 by calling the existing
`reset()` command once. Deploys against today's backend as a **no-op** — the BE never
404s, so the branch is dead on arrival and provably safe.
2. **RB-23 (contract, BE).** `GET /brief` returns 404 when no brief exists;
`BriefStore.GetOrCreate` splits into `Get` + the already-existing `ResetAndCreate`.
Deploys only once RB-22 is live.
Agent 07 rejected CQ-007's documentation-only alternative outright: "a non-idempotent GET
must be visible in the code, not only in a ticket". That alternative is therefore **not**
on the table.
**No other ticket failed the single-deploy test.** TE-001 lands in two apps but in one
merge; RB-24 touches 30 dependents but is one atomic move; RB-19 is a 900-line diff but
zero-semantic-change.
---
# ADR-fix tickets — architect approval required before any dependent code ticket
None of these five is a code change. All five change what the repo's architecture
documents _claim_. **Three of them require a matching CLAUDE.md correction in the same
diff** (CLAUDE.md's own precedence rule: "the docs win — update this file").
| ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status |
| ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- |
| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | **done** |
| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | **done** |
| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | **done** |
| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | **done** |
| **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** |
**No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it
as instructed and rejected it with evidence: `grep -rn "Principal" apps libs` returns one
comment and no type, so the rule was never _tested_, only _unexecuted_. Amending it now
would ratify the omission rather than the evidence. The correct sequence is
ADR-C-005 (done) → **RB-13** → **re-measure BL-002**; agent 06's expectation is that the
residual duplication drops from 151 lines to under 40 on its own. If RB-13 is still
unstarted at the next backlog cycle, _that_ is when the ADR-fix conversation becomes
legitimate.
---
# Production gates — a release checklist, not tickets
These are **correct for a POC** and must be true before the system holds real BSNs. They
are deliberately kept out of the ticket table: they are acceptance criteria for a release
that does not exist yet (there is no production build artifact at all — **BIO-020**), not
work that can be merged and deployed this week. Where a _part_ of a production-gate
finding was shippable now, that part was pulled out as a ticket and is named below.
**Identity and access (9.1, 9.2, 9.4)**
- [ ] Replace `StubIdentityProvider` with verified DigiD / employee-SSO claims. `X-Role`,
`X-Subject`, `X-Medewerker`, `X-Rollen`, `X-Admin` removed as **inputs**, not ignored. — BIO-001
- [ ] Verify by building both apps `--configuration production` that the backoffice cannot
act as a citizen. — BIO-002 _(the interface half is **RB-09**; the FE half is **RB-13**)_
- [ ] Row-level scoping on every read returning person data; acceptance = a second seeded
citizen cannot see the first's dashboard, notes, BRP address or diplomas. — BIO-013
- [ ] The PII-reveal capability comes from the app overlay, not the coarse role, and is
**not held by the default role**. — BIO-006 _(the `X-Step-Up` literal is in **RB-11**)_
- [ ] Real step-up: a server-verified assurance/recency attribute no client can satisfy
with a constant. — BIO-006
**Cryptography (8.24)**
- [ ] Encryption at rest with documented key custody and rotation. — BIO-014
**Prerequisite: RB-02/04/05 first**, so the BSN is not in three places that do not
need it before deciding what must be encrypted.
- [ ] Document bytes move to encrypted object storage keyed by `DocumentId`. — BIO-014
- [ ] TLS everywhere: `UseHttpsRedirection` + HSTS at the edge. — BIO-015
- [ ] Security response headers (`nosniff`, CSP, `Referrer-Policy`) and a real
`AllowedHosts`. — BIO-015 _(the Swagger gate is **RB-15**)_
**Logging, monitoring and retention (8.15, 8.16)**
- [ ] Audit retention, integrity and access defined — how long, append-only, and who may
read `/beheer/audit` (it reuses `cases:manage`, which `Program.cs:565` already flags
as a placeholder for a dedicated `audit:read`).
- [ ] Log shipping and alerting — the audit trail is a SQLite table with no export path.
- [ ] _(Covered by tickets: allow-path auditing = **RB-07**; no BSN in any audit row, log
line or persisted error field = **RB-02/04/05**.)_
**Data protection (5.12, 5.13)**
- [ ] A DPIA covering BSN, uploaded identity documents and the register, with lawful basis
and retention schedule. Nothing in the repo covers this.
- [ ] Deletion / retention policy for uploaded documents and the audit trail.
- [ ] _(Covered: data minimisation on list endpoints = **RB-03**.)_
**Secure development (8.25, 8.28, 8.29)**
- [ ] Secret scanning in CI (prevention — nothing is committed today, verified). — BIO-016
- [ ] Backend architecture enforcement (NetArchTest/ArchUnitNET) so `Domain/` purity, ZGW
containment (ADR-0005) and "authorization lives in `Authz`" are CI- rather than
review-maintained. — BL-006
- [ ] A coverage ratchet, so a security fix can be verified as not regressed by CI. — BL-009
- [ ] Penetration test / DAST, with BIO-004's object-level authorization and BIO-005's
document linking as named cases.
- [ ] _(Covered: backend dependency scanning = **RB-14**; the authorization regression gate
= **RB-12**.)_
**Change control (8.32)**
- [ ] A production build and deployment artifact exists, separate from the demo compose
file, and its release checklist references this list. — BIO-020
- [ ] Verify **by build, not by reading**: in a production bundle `?role=`, `?subject=`,
`?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the
three hand-written `fetch` paths. — BIO-012 _(the code fix is **RB-11**; this box is
the build-time proof)_
---
# Verified clean — do not "fix"
Each of these was read and judged correct by the agent named. Re-checking them is wasted
effort; "simplifying" them is a regression.
**Security and access control** (agent 07, verified endpoint by endpoint)
- `AccessStore.can()` deny-by-default + `whenReady()` — the pair exists so the guard cannot
read `can()` mid-load and deny an entitled user.
- `capabilityGuard`'s "UX pre-gate, the backend re-enforces" claim — verified true for all
six admin surfaces; every capability the guard checks has a server-side twin.
- `Authz.CanBeoordelen`'s caller-kind derivation — the one capability a forged `X-Role`
cannot reach, and the reason BIO-002 fails _closed_ in that direction.
- The four-eyes rule in `Authz.CanActOn`, Forbidden-before-Conflict ordering.
- The `isDevMode()` gate on the debug panel and on the interceptor chain (the _interceptor_
chain is correctly gated — RB-11 is about the three adapters that bypass it).
- The ZGW client secret never reaching the browser; the notification webhook failing closed
on an unset secret; `ZgwDiagnosticHandler` logging no bodies and being opt-in.
- The upload content-type allow-list enforced **server-side** — which is also why
`nosniff` is a checklist item and not a finding.
- Stamdata having no runtime write endpoint at all.
- `libs/shared/src/kernel/{bsn,pii}.ts` — the standard the rest should be measured against.
- No secrets committed; no `.db` file tracked (both verified by `git check-ignore`/`ls-files`).
**Architecture and structure**
- **ADR-0005 is fully conformed — zero findings** (agent 06). The ZGW anti-corruption layer
is the repo's worked example; the ADR even predicted its own remaining gap and the gap
stayed where predicted.
- **`bhp/behandeling` is the CQRS-light reference implementation** (agent 04). Query
adapters, command adapter and command factory in separate files, write-free read stores.
Do not "clean it up".
- **The FE dependency structure is not a problem area** (baseline §6): 0 violations across
11 `severity: error` rules, textbook instability gradient (`kernel` I=5%, contexts I≥83%).
Do not spend tickets here.
- `BigProfileStore` — the reference implementation of the read/write split (agent 04).
- The `ToDetailDto(now)` / `ToDto(now)` status projection — a real read-model derivation;
do not let a future ticket "simplify" it into a stored status column (agent 04).
- The 7 static backend stores and `[assembly: DisableTestParallelization]` — deliberate,
documented in `Data/Db.cs`, and explicitly _not_ challenged by agents 02, 04 or 07.
RB-30 works **because** the rules never needed the DbContext, not by redesigning stores.
**Baseline rows closed as false gaps** (agent 02, verified — do not ticket them)
- `libs/shared/domain` 0% reach / 3 files, and `libs/beheer/contracts` 0% reach / 1 file.
Both are pure type declarations with **zero executable statements**; 0% is correct and
unimprovable. BL-004 named both as "genuine gaps"; that part of BL-004 is superseded.
- 23 of the 25 CC>10 TS functions are reducers / `parse*` / `validate*` — mandated house
idioms (**BL-001**). A bare CC number is not grounds for a ticket against any of them.
- `createDraftSync` is **acquitted on testability** (explicit deps object, optional
injection, `enabled()` escape hatch, has a spec). RB-21 is a CQRS split, not a fix.
- `httpClientFetch`, `Contracts/Mappers.cs`, `submit-besluit.ts`, `breadcrumb-trail.ts`,
`route-focus.ts`, `AccessStore.can()` — all "missing test, not blocked test", or a seam
that costs more than it returns. Filing them would be volume, not quality.
---
# Out of mandate — recorded so a later phase does not read this file as a step toward them
- **OOM-A — `Program.cs` → `Features/` folders with handler types.** BL-003's most obvious
invitation, and out of mandate because §7 is explicit that the backend has "no handler
types, no mediator, no `Features/` folders" — there is no structure to extend, only one
to introduce. **RB-19 is a strict prerequisite** if it is ever taken: you cannot cut a
940-line file into vertical slices while five of its seven sections interleave
directions. Agent 03, which would have owned this, did not run.
- **OOM-B — read/write repository split in `backend/Data`.** Would introduce the pattern
where §7 records it absent, and collides with the documented static/no-DI design.
- **OOM-C — no read model, no event sourcing, and none proposed.**
- **OOM-D — BL-011: the FE suite is flaky under parallel load, and BL-009 means nothing
ratchets.** "CI green" alone does not verify any ticket in this backlog. Verify against
`00-baseline.md`'s numbers — **and note that §3a, §3b and §5 are already partly stale**
after the five shipped commits (auth duplication 211→151; `libs/shared/infrastructure`
coverage no longer dragged down by the generated client). **Re-run the baseline before
using it as the before-picture for any ticket below.**
---
## Provenance
| Source finding | Where it went |
| ------------------------------------------------------- | -------------------------------------------------------------------- |
| TE-001…008 | RB-10, RB-11, RB-25, RB-26, RB-27, RB-28, RB-29, RB-30 |
| TE-009 | **shipped** |
| CQ-001, 002, 003+005, 006, 007 | RB-21, RB-20, RB-17, RB-19, RB-22+RB-23 |
| CQ-004 | **shipped** (BE audit half outstanding → RB-07) |
| ADR-C-001, 003, 007, 009 | ADR-fix table |
| ADR-C-002, 004, 008, 010, 011 | RB-24, RB-13, RB-32, RB-31, RB-33 |
| ADR-C-005, 006 | **shipped** |
| BIO-001, 002 | RB-09 + checklist |
| BIO-003, 004, 005, 007, 008, 009, 010, 011, 018, 019 | RB-08, RB-01, RB-06, RB-07, RB-02, RB-04, RB-05, RB-03, RB-18, RB-16 |
| BIO-006 | RB-11 (a+b) + checklist (c) |
| BIO-012, 017 | RB-11, RB-10 |
| BIO-015, 016 | RB-15 + checklist; RB-14 + RB-12 + checklist |
| BIO-013, 014, 020 | checklist only |
| BL-008 | **shipped** |
| BL-005, BL-009, BL-011 | **unowned** — see "Coverage of this backlog" and OOM-D |
| BL-001, BL-002, BL-004 (partly), BL-006, BL-007, BL-010 | absorbed into the tickets/checklist above |
@@ -0,0 +1,112 @@
# Agent run status
| Agent | Status | Last module processed | Last updated | Notes |
| --------------- | ------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. |
| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. |
| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. |
| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. |
| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs` → `Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". |
| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. |
| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. |
| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). |
| consolidation | complete (approved) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. **Approved 2026-08-27; HALT lifted.** |
## Phase 3 — implementation
**All six batches complete, 2026-08-28. All 33 code tickets (RB-01..RB-33) and the four gated ADR-fixes (ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009) merged to `refactor/adr-c-006-shared-route-guards`, one commit per ticket. `npm run ci` green after every merge, verified independently before trusting any agent's own report.** Every batch carrying a **SIGN-OFF** ticket shipped only after the architect approval recorded 2026-08-27 (HALT lifted). Three of the four ADR-fixes required a matching CLAUDE.md correction (§2 once, §4 twice); all three landed in the same diff as their ADR amendment, per CLAUDE.md's own precedence rule.
Across the six batches, several tickets turned out to be factually wrong, incomplete, or overstated relative to the actual code, and every one was reported rather than silently patched over — among them: two ADR-fixes (ADR-C-001's stale out-of-scope bullet, ADR-C-007 catching only half of its own finding), RB-18 (real scope one endpoint, not the several the stale line numbers implied), RB-23 (an unmentioned second `GetOrCreate` call site forced onto the same contract change), RB-25 (overstated which methods the missing token actually blocked), RB-27 (correctly declined to extract abort-vs-error into a signature that cannot express it, and declined an optional move outside its stated scope), RB-28 (TE-006's "cannot test the success case" claim was already false when written), and RB-31 (found a genuinely unreachable state — cursor 2 with no diploma chosen — that the old hand-rolled fixture had been asserting). None of these weakened a ticket; each was implemented correctly once the discrepancy was named.
| CD batch | Tickets | Status | Notes |
| -------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. |
| 5 | RB-24..RB-30 | **complete** | All seven merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Ran as three waves, not the two the backlog implied: RB-24 rewrites imports in `brief.store.ts` and `org-template.store.ts`, which are two of RB-28's three targets — a dependency the backlog's "25/26/27 depend on 24" note never mentioned. **A** = RB-24 alone (the move), then **A2** = RB-29 + RB-30 in parallel (backend, no file overlap with the move or each other), **B** = RB-25 + RB-26 + RB-28 in parallel once RB-24 landed, **C** = RB-27 alone last, since it depends on RB-25's transport token. **RB-24 expanded its own scope, correctly.** Deleting the dependency-cruiser carve-out — the ticket's own acceptance criterion — exposed a second, real `ui-not-infrastructure` violation the old path had hidden: three UI components injected `UploadAdapter` for nothing but a one-line wrapper over its own exported pure function. The dispatch prompt said to report a second violation, not fix it; the agent judged this one was on the critical path (`dep:check` cannot pass with the carve-out gone otherwise) and fixed it minimally, reusing the existing pure function. Reviewed before merging — sound. **Two more findings were shown to be stale or overstated, on top of the two ADR-fixes found wrong and RB-18/RB-23's incompleteness from batch 4 — nine total now.** RB-25 found TE-003 overstated its own blocker: of the four methods named, only `upload()` and `cancel()` were actually unfakeable through the missing token — `delete()`/`pollReturning()` already went through the exported `UploadAdapter`. RB-28 found TE-006 already false at the time it was written: `brief.store.spec.ts` already had a `previewLetter` success test via jsdom's spyable `URL`/`window` stubs, contradicting the finding's "cannot test the success case" claim — the overall three-site diagnosis still held and was shipped as instructed. **RB-26 made one real design call**, reviewed before merging: `planFileSelection` must return `UploadMsg[]` per its literal signature, but an accepted file's real `localId` needs `crypto.randomUUID()`, which the ticket itself keeps in the controller. It ships a placeholder `localId: ''` discriminated by `.type` alone and never dispatched — verified the index alignment holds for both the multiple-rejection short-circuit and the per-file path. **RB-27 left one thing unextracted, correctly**: TE-005 lumped abort-vs-error disambiguation into the same extraction as `uploadOutcome`, but abort fires on a different event with no `status`/`responseText` at all — it structurally cannot fit the proposed signature. Left in place as a one-line ternary. The optional `currentScenario()` move into `KeepaliveTransport` was also correctly declined — it would have crossed into `upload-shell.service.ts`, outside this ticket's stated single-file scope. **End state of `libs/shared/upload`** (now split across proper layers): every layer that can hold pure logic has one and is spec'd — `upload.machine.ts` (domain, `planFileSelection`), `upload-shell.service.ts` (application, the `UPLOAD_TRANSPORT` seam), `upload-controller.ts` (application), `upload.adapter.ts` (infrastructure, `uploadOutcome`). Only the XHR/DOM boundary itself stays untested by design — TE-005 was explicit that abstracting `XMLHttpRequest` away is not wanted, since the file documents why XHR (not `fetch`) is required. |
| 6 | RB-31, RB-32, RB-33 | **complete** | All three merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Dispatched as one wave — no file overlap at all (four machine specs in three apps, one docs file, one testing helper plus its one call site). **RB-31 found a real ADR-0006 violation, not a false alarm.** Two `registratie-wizard` tests asserted a state — cursor 2, no diploma chosen — that the real reducer cannot produce, since advancing past `beroep` (cursor 1→2) requires `KiesDiploma`/`KiesHandmatig` to have already run. This is exactly what forcing fixtures through message replay is for: a hand-rolled literal let an impossible state sit in the suite undetected. Fixed by replaying to cursor 1 and applying the diploma choice there instead; `submit()` validates the whole draft regardless of cursor, so the assertions are byte-identical to before — reviewed the diff before merging to confirm the old and new test bodies check the same thing. `intake.machine.spec.ts` also had an existing, correct `intake.testing.ts` sitting unused in its own folder, imported only by the acceptance spec — now wired to both. **RB-32 added the missing `language-switcher` row and took the ticket's explicitly-optional second step**: a ~14-line drift guard in `check-tokens.sh` that diffs every `CIBG-GAP EXTENSION` marker's component directory against the register's rows and fails naming what's missing. Verified the regex before trusting it — two existing rows carry parenthetical suffixes (`wizard-shell (error summary only)`) and the extraction correctly captures only the backtick-quoted name. This closes ADR-0003's own predicted failure mode ("if markers and this table drift, trust the code and fix the table") permanently rather than fixing it once more. **RB-33 made the real adopt-or-delete call the finding asked for, and chose delete.** `unwrapOk` had zero consumers anywhere in the repo since it shipped; manufacturing a first caller purely to satisfy the ticket would have removed no actual duplication, since there was only one occurrence to begin with. Deleted the helper and its doc mention; left the one candidate call site's inline guard alone, since it already satisfies ADR-0006 §3's real requirement (never a cast). |
| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. |
**Standing caveat for every batch:** `dotnet test` reports one failure,
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
which needs a live OpenZaak container. It fails identically on a stashed tree — it is not
caused by any of these tickets. `npm run ci` does not run it.
## Gate integrity — read before trusting any "ci green" in this file
`scripts/ci-local.sh` chained six of its steps as `cmd1 && cmd2` under `set -e`. Bash exempts
every command of an AND-OR list **except the last** from `errexit`, so a crash in `cmd1` was
swallowed: the paired check never ran and the script still printed "local CI passed". Verified
directly — `bash -c 'set -e; false && true; echo hi'` prints `hi` and exits 0.
This hid a **real** `gen:api` crash introduced by RB-09 (`dotnet swagger`'s design-time host
defaults to Production, which RB-09 made throw). `.github/workflows/ci.yml` runs each step as
its own `- run:` and would have caught it, so the local gate was strictly **weaker** than the
remote one — the opposite of its purpose. The worst instance was
`ng build ssp --localize && ng build behandelportal --localize`: a missing English translation
in ssp could not fail the run.
Fixed in `build: stop ci-local.sh swallowing the first half of every paired step`. **Every
"ci green" recorded for batch 1 and for RB-07/RB-10/RB-11 predates that fix** and is therefore
weaker than it reads; the batch-2 completion run above is the first one made on the honest gate
(13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been
re-verified under the fixed gate either.
## Dispatching implementation agents — what actually goes wrong
Batches 2 and 3 ran tickets as parallel agents in git worktrees. Six of seven agent-runs hit at
least one of these. Put all of it in the prompt.
1. **The worktree base is not reliable.** **Four of the six** agents were handed a worktree
branched from a stale ancestor — batch 3 was **three for three**, all landing on `ae7781e`,
an unrelated lineage missing every RB ticket _and_ this backlog directory. Make step zero:
`git log --oneline -8`, confirm a **named expected commit**, `git merge` the target branch if
absent, and report which it was. The one agent that was not told to do this found out by luck.
2. **Agents park on background tasks.** Two agents in batch 2 ran `npm run ci` in the background,
then ended their turn waiting for a notification that never usefully arrived; one finished its
work twice and never committed it. Ban `run_in_background` and Monitor, **and say explicitly to
pass the Bash tool's own `timeout: 600000`** — a batch-3 agent still auto-backgrounded because
"run it in the foreground" alone does not defeat the 120s default.
3. **`git checkout <file>` destroys the work.** Agents verify a test is red by undoing the fix;
restoring it with `git checkout` reverts the whole file. Tell them to undo and redo with edits.
4. **`behaviour-spec.mdx` conflicts on nearly every merge.** It is generated and every agent
regenerates it. Resolve centrally with `npm run gen:behaviour-spec && git add`; tell agents to
expect it and never hand-edit.
5. **Concurrent `dotnet test` runs flake** — a burst of `SQLite Error 1: 'no such table: Documents'`.
Partly host pressure, but RB-12 found a real cause: a bare `new WebApplicationFactory<Program>()`
races on the static `Db.ConnectionString`. Use the house `TestWebApplicationFactory` +
`IClassFixture` idiom. Do not trust a backend green while another agent is testing.
6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by
ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`.
7. **The stale base is now the rule, not the exception.** Batch 4 ran six agents; **five were
handed a bad base**, three of them the same unrelated `ae7781e` lineage. Across batches 2-4 that
is **11 of 13 agent-runs**. Every one self-corrected at step zero. Keep the named-expected-commit
check as the first instruction in every prompt — it is the highest-value line in there. Give the
_current_ HEAD, not the batch's starting commit, when tickets merge sequentially.
8. **A spend limit can kill every agent mid-flight; resume, do not restart.** All four wave-A agents
died on an org monthly-spend 429, three of them at the final CI re-run. Their worktrees kept the
uncommitted work intact. Sending each agent a message resumed it from its own transcript and it
finished from exactly where it stopped — nothing was redone. Check `git -C <worktree> status`
before assuming work is lost.
9. **The `99-backlog.md` conflict is mechanical and has a fixed recipe.** It fired on three of five
merges. Cause: the central prettier pass reflows the table's column widths, so the whole table
conflicts even though the two sides differ in only one or two status cells. Recipe: take HEAD's
table, flip the incoming ticket's cell, re-run prettier. Verify by parsing both sides cell-by-cell
and printing only the differing cells before discarding either side — do not eyeball a 33-row
table. A ticket's cell reads `**done**` once merged (matching RB-01..RB-17), not `implemented`.
10. **For a zero-semantic-change commit, ask for evidence CI cannot give.** RB-19's diff is 181 lines
in `Program.cs` and no test can prove it changed nothing. The sorted-route-list diff, plus the
route/gate/handler-triple comparison, is what actually made it reviewable. Ask for it in the
prompt and re-run it centrally before merging.
11. **`gen:api` is not always a no-op on a pure reorder.** OpenAPI operation order follows mapping
order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to
prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to
commit the regenerated pair, or CI's drift job fails on a correct change.
12. **The backlog's own "depends on" column is not exhaustive — check actual imports before parallelizing a wave.** Batch 5's table said only "25/26/27 depend on 24"; it never mentioned that RB-24 rewrites imports in two of RB-28's three target files. `grep -rln` for the moved module's import path against every other open ticket's target files, before deciding what runs in parallel — not after a conflict.
**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong
about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and
RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review
of the diff alone.
@@ -0,0 +1,46 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/00-baseline.md
DEPENDS ON: none
---
PERSISTENCE & RESUME PROTOCOL
Before starting work:
1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run.
2. If "in_progress", read your own output file. Treat modules already listed as done.
Resume from "Last module processed" + 1.
3. If "not_started", confirm your dependencies show "complete" in _status.md. If not,
stop and report a blocking dependency instead of guessing.
While working:
4. Append findings incrementally, one module at a time. After each module, update
_status.md: "Last module processed" and "Last updated".
5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs.
6. If interrupted, the file + status row is the full recovery state.
On completion:
7. Mark your _status.md row "complete" only once every module in scope has a
corresponding section in your output file.
Every output file starts with:
## Scope: [modules covered]
## Status: [not_started | in_progress | complete]
## Last updated: [timestamp]
## Depends on: [file(s)]
## ---
ROLE: Metrics Baseline Agent
Before any refactoring suggestions, establish a baseline for the scoped codebase:
- Test coverage (line/branch) per module, .NET and Angular separately.
- Cyclomatic complexity per method/function (flag >10).
- Duplication percentage (tool-based, e.g. jscpd/SonarQube if configured).
- Dependency graph / layering violations (existing static analysis if present).
- Count and location of existing CQRS-light and hexagonal architecture patterns
already in use (so later agents compare against actual current state, not
assumed absence).
Output: a metrics table per module, plus a short list of modules ranked
worst-to-best on each metric. This file is fixed input to every Phase 1 agent —
no agent may propose a change without citing which baseline metric it improves.

Some files were not shown because too many files have changed in this diff Show More