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
@@ -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 (0107) + 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 (0107). **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%) | SM | 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% | SM | 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 26 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 | SM | 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**) | SM | 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 | SM | 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.
@@ -0,0 +1,39 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/01-readability.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,38 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/02-testability.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,40 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/03-ddd-hexagonal.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,39 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/04-cqrs-light.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,38 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/05-bdd.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,40 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/06-adr-conformance.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,48 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/07-bio2-compliance.md
DEPENDS ON: 00-baseline.md (complete)
---
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)]
## ---
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,59 @@
MODEL: Opus
OUTPUT FILE: /refactor-backlog/99-backlog.md
DEPENDS ON: 01-readability.md through 07-bio2-compliance.md (all complete)
---
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)]
## ---
Phase 1 file changed since last run]
ROLE: Consolidation & CD-Sequencing Agent
Input: all Phase 1 files (0107) + 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,57 @@
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
---
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)]
## ---
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,69 @@
# ADR-C-001 — rewrite ADR-0001's worked example against the shipped system
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-001
## What was wrong
ADR-0001's §"Worked example in this POC" opened with _"This POC has no real backend (static
mock JSON + fake submit timers), so the 'BFF output' is a static file"_. That premise is
false and every path the section cited was gone. The decision itself was intact; only the
description had drifted.
## What changed
| File | Change |
| -------------------------------------------------------- | --------------------------------------------------------------------------- |
| `docs/reference/architecture/0001-...md` §Worked example | rewritten against `backend/src/BigRegister.Api`; all six paths repointed |
| same file, §Out of scope here | 4 bullets → 2, plus a paragraph recording which two were discharged and why |
No code changed. No CLAUDE.md edit was required for this finding.
## Paths corrected, each verified
| Claimed | Actual |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| "no real backend … static file" | `backend/src/BigRegister.Api`, `var api = app.MapGroup("/api/v1")` at `Program.cs:168` |
| `public/mock/dashboard-view.json` | `GET /api/v1/dashboard-view` (`Program.cs:172`) |
| `public/mock/intake-policy.json` | `GET /api/v1/intake/policy` (`Program.cs:193`) |
| `src/app/registratie/contracts/dashboard-view.dto.ts` | `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` |
| `src/app/registratie/infrastructure/dashboard-view.adapter.ts` | `apps/ssp/.../infrastructure/dashboard-view.adapter.ts`, `parseDashboardView` at `:50` |
| `src/app/herregistratie/contracts/intake-policy.dto.ts` | **deleted** — the DTO is now the generated `IntakePolicyDto`; the adapter is `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts` |
`apps/ssp/public/mock/` does not exist (`ls`: no such directory).
## The finding was wrong about one out-of-scope bullet
ADR-C-001 said to _"reduce §Out of scope to the two items still genuinely open (the
`BigProfileStore` optimistic-update race, and session persistence / multi-tab sync)"_,
carrying the original bullet's parenthetical **"`SessionStore` is in-memory"**. That
parenthetical is no longer true, so the bullet could not be kept verbatim.
- `apps/ssp/src/app/auth/application/session.store.ts:13` reads
`parseStoredPrincipal(localStorage.getItem(STORAGE_KEY))`, and `:41` writes it back.
Session persistence **has landed** (RB-10 extracted the parser, RB-13 renamed it
`parseStoredPrincipal`). The file even carries a `ponytail:` note explaining the choice of
`localStorage` over `sessionStorage`.
- Multi-tab sync has **not** landed: `grep` for a `storage` event listener across `apps` and
`libs` returns nothing.
The bullet was therefore narrowed to multi-tab sync only, and states that the session itself
now persists. Recording this because the finding, taken literally, would have re-asserted a
false claim in the same edit that removed two others.
The other two survivors were verified rather than assumed: `BigProfileStore` still holds
`pending` as a bare `signal(false)` with `begin`/`confirm`/`rollback` mutating it
(`big-profile.store.ts:61-74`), so the concurrent-submit race is real.
## Discharged bullets, both verified
- _"Runtime DTO validation on **every** endpoint (only the dashboard view has it)"_ — 33
distinct `export function parse*` boundary functions exist across `apps` and `libs`.
- _"Real OpenAPI/TypeSpec codegen toolchain"_ — `npm run gen:api` (`package.json:12`) runs
`dotnet swagger tofile` then `nswag run`, emitting
`libs/shared/src/infrastructure/api-client.ts` (2329 lines). CI's `api-client-drift` job
regenerates and runs `git diff --exit-code` (`.github/workflows/ci.yml:319-321`).
## Scope discipline
Descriptive drift only, as the finding states. The decision, the options table, the two
policy shapes and the migration sequence are untouched.
@@ -0,0 +1,64 @@
# ADR-C-003 — state that the generated client is the wire contract
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-003
## What was wrong
ADR-0001 set "one source of truth that generates types for both sides" as the target state.
The code reached it. CLAUDE.md §4 still stated the pre-codegen rule — _"DTO lives in
`contracts/`"_ — as standing law, so §4 could be cited to justify both deleting the four
survivors and adding new hand-written DTOs for already-generated endpoints.
## What changed
| File | Change |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `docs/reference/architecture/0001-...md` | **new** §"Where the contract lives, after codegen" |
| `CLAUDE.md` §4 | the flat "DTO lives in `contracts/`" rule replaced with the generated-client rule + the two exceptions |
No code changed. Per CLAUDE.md's own precedence rule, the ADR was amended first and
CLAUDE.md corrected to match, in one diff.
## The decision the finding asked for: the four survivors stay
ADR-C-003 required an explicit, recorded decision on the four remaining hand-written DTOs.
**They stay**, all four under exception case 2 ("the generator types the shape too loosely").
This is not a preference — adopting the generated shapes would violate CLAUDE.md §3.
Evidence. NSwag emits every property as optional, and flattens a discriminated union into a
bag of optional fields:
| | generated (`api-client.ts`) | hand-written (`dashboard-view.dto.ts`) |
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `DashboardViewDto` | `registration?`, `person?`, `decisions?` — all optional (`:2017`) | all three required |
| `RegistrationDto` | six optional fields (`:2202`) | six required fields |
| `RegistrationStatusDto` | **one flat record of five optional strings**, `tag?: string` (`:2211`) | a real union of three variants, `tag: 'Geregistreerd' \| 'Geschorst' \| 'Doorgehaald'`, per-variant fields required |
The generated `RegistrationStatusDto` makes `{ tag: 'Geregistreerd', doorgehaaldOp: '…' }`
representable. That is precisely the illegal state CLAUDE.md §3 exists to forbid, and the
`parse*` boundary would have to reconstruct the union by hand anyway.
The ADR therefore records that retiring these four is **not** a cleanup to schedule. It
becomes correct only if the backend annotates its DTOs so the generator emits required
properties and real unions — which names the actual prerequisite instead of leaving the
question open.
## Verified counts, not carried over from the finding
- Hand-written `contracts/*.dto.ts`: **4**
`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`
and `libs/beheer/src/contracts/stamdata.dto.ts`.
- All four duplicate generated types **by the same names**: `BrpAddressDto` (`:1997`),
`DashboardViewDto` (`:2017`), `DuoLookupDto` (`:2056`), `DuoDiplomaDto` (`:2047`),
`PolicyQuestionDto` (`:2168`), `ManualDiplomaPolicyDto` (`:2106`), `StamdataColumnDto`
(`:2246`), `StamdataTableDto` (`:2253`), `StamdataTableSummaryDto` (`:2261`). None is a
codegen gap — the finding's "case 1" has no occupant today, which is worth knowing.
- The `parse*` boundary is restated as mandatory regardless of type provenance. The amendment
says why in one line: a generated type is a compile-time claim about the wire, not a
runtime guarantee.
## Gate released
ADR-C-003 blocked any ticket that would delete the four `contracts/*.dto.ts` files or add a
hand-written DTO for a generated endpoint. No open ticket needed it. The rule is now written
down, so a future one can be judged against it rather than against a stale §4.
@@ -0,0 +1,58 @@
# ADR-C-006 — extract the actor-agnostic route guards to `libs/shared`
Status: **implemented** · 2026-08-26 · Source finding: `06-adr-conformance.md` §ADR-C-006
## What changed
| File | Change |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `libs/shared/src/application/auth.guard.ts` | **new**`authGuard` + `capabilityGuard`, injecting `SESSION_PORT` instead of an app-local `SessionStore` |
| `libs/shared/src/application/auth.guard.spec.ts` | **new** — the single spec, provides `SESSION_PORT`; one case added asserting the guard reads the port |
| `libs/shared/src/application/session.port.ts` | widened by one member: `readonly isAuthenticated: Signal<boolean>` |
| `apps/ssp/src/app/auth/auth.guard.ts` | now a re-export |
| `apps/behandelportal/src/app/auth/auth.guard.ts` | now a re-export |
| `apps/{ssp,behandelportal}/src/app/auth/auth.guard.spec.ts` | **deleted** — both were byte-identical to the new shared spec |
Neither `app.routes.ts` was touched: both still `import { authGuard, capabilityGuard }
from '@auth/auth.guard'`. Routing asks the auth context for its guards, which is the
direction the boundary should read.
## Why the port widening was free
Both `SessionStore`s already exposed `readonly isAuthenticated = computed(() =>
this._session() !== null)` (`session.store.ts:40` in each app), and both apps already
registered `{ provide: SESSION_PORT, useExisting: SessionStore }`
(`app.config.ts:64` / `:65`). `SessionPort` is satisfied structurally, so adding the
member required no change in either app — the seam existed, it was just narrower than
what it already carried.
## Scope discipline
Only the guards moved. Per the finding, **no** ticket to merge `session.store.ts`,
`session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and no
relaxation of ADR-0002 §3. Those five are identical because ADR-C-004 (`Session →
Principal`) was never executed; merging them would cement a citizen DigiD/BSN login as
the backoffice's shared login, which is the outcome §3 exists to prevent.
## Measured effect
Re-ran `tools/baseline-scan.mjs --dup` after the change:
| Metric | Before | After |
| --------------------------- | -----: | --------: |
| `ssp/auth` duplicated lines | 211 | **151** |
| `bhp/auth` duplicated % | 86.8% | **82.5%** |
| Repo-wide duplication | 7.1% | **6.6%** |
The `auth.guard.spec.ts` (36 windows) and `auth.guard.ts` (21 windows) clone pairs have
dropped out of the top-clones list entirely. The remaining `ssp/auth``bhp/auth`
duplication is `session.store.ts` (39), `login-form.component.ts` (35) and `login.page.ts`
(23) — exactly the three ADR-C-004 is expected to differentiate. Re-measure BL-002 after
that lands; the finding's expectation is a drop to under 40 lines.
## Verification
`npm run lint` · `npm run typecheck` · `npm run dep:check` (0 violations, 224 modules)
· `npx prettier --check apps libs` — all clean.
Tests: shared 122, ssp 235, behandelportal 27, beheer 23 — **407 passed, 0 failed**.
`ng build --localize` for both apps.
@@ -0,0 +1,65 @@
# ADR-C-007 — repoint ADR-0003's WP-67 paths and fix its point 4
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-007
## What was wrong
Two separate defects in one ADR. Every file path in ADR-0003 predated WP-67's monorepo move,
and decision point 4 made a claim about `app-alert` that the code contradicts.
## What changed
| File | Change |
| ---------------------------------------- | ---------------------------------------------------------------------------------- |
| `docs/reference/architecture/0003-...md` | points 1, 2, 4 and both §Consequences bullets rewritten |
| `CLAUDE.md` §2 | the `alert` parenthetical corrected to `skeleton`/`spinner` + a denial for `alert` |
No code changed. ADR first, CLAUDE.md to match, one diff.
## Paths, each verified
| Claimed | Actual |
| ------------------------ | ---------------------------------------------------------------------- |
| `src/styles.scss` | `libs/shared/styles.scss` — one copy, both apps' `angular.json:41,169` |
| `src/index.html` | `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html` |
| `.storybook/` | `.storybook-ssp/` and `.storybook-behandelportal/` |
| `src/docs/cibg-gaps.mdx` | `libs/shared/docs/cibg-gaps.mdx` |
**One path in the finding's list needed no change.** ADR-C-007 implied point 1's
`public/cibg-huisstijl/` had moved with the rest. It has not: `public/` is still at the repo
root, and both apps' `angular.json` asset entries read `"input": "public"` (`:38`, `:166`).
Both Storybook configs serve it as `staticDirs: ['../public']`. Point 1's vendoring path is
left as written; only its `index.html` clause changed.
## Point 4: the finding was right, and understated
ADR-C-007 flagged the `.alert` half of point 4. Verified: `libs/shared/src/ui/alert/alert.component.ts`
documents itself as a _"Thin wrapper over the vendored `.feedback feedback-*` classes"_, its
template binds `.feedback-info/-success/-warning/-error`, its only local CSS is a 3-line flex
fix, and it carries **no** `CIBG-GAP EXTENSION` marker. `grep` confirms `feedback-error` is
present in `public/cibg-huisstijl/css/huisstijl.css` — the class is vendored, so `alert` is not
a gap.
**The finding missed that the same sentence's second claim is also false.** Point 4 said "the
header/side-nav use `.nav` + a local blue bar". They do not:
- `site-header.component.ts` composes the vendored `.titlebar` and `.logo__*` classes
(`grep` confirms `titlebar` in the vendored CSS) and its own comment says the titlebar
_"keeps its own robijn fill — `--ro-layout` — untouched"_.
- `shell.component.ts` emits only `.layout`, `.main`, `.content`, `.skip` — page scaffolding.
- No `.nav` class appears in either, and neither carries a gap marker.
Both corrections are stated in the amended point 4 rather than silently dropped, so a reader
comparing the old text against the code can see which claim was retired and why.
## Replacement example chosen
`skeleton` and `spinner`, as the finding proposed. Both are in the gap register, both carry
markers reading "No loading-skeleton/spinner class in the vendored build", and both are
genuinely absent — the cleanest live illustration of the principle point 4 exists to state.
## Noted, not fixed: the gap register is still one row short
`grep` finds **9** `CIBG-GAP EXTENSION` markers; `libs/shared/docs/cibg-gaps.mdx` has **8**
rows. The missing one is `language-switcher`. That is **ADR-C-008 → RB-32** (batch 6), not
this ticket, and it was left alone.
@@ -0,0 +1,71 @@
# ADR-C-009 — state the runtime-editable-config exception as a test, not a list
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-009
· Gated on: **RB-07** (satisfied — batch 2)
## What was wrong
ADR-0004 said "never runtime-editable" and then named **one** exception in the singular,
justified narrowly ("specific to one sub-organization's identity"). WP-47 added a second
runtime-editable SQLite surface, `FeatureFlagStore`, whose own doc-comment states the
equivalence the ADR did not: _"SQLite-backed like `OrgTemplateStore`, same single-gate
idiom."_
The code is right; the ADR's text was wrong. A closed list of one leaves the next
operational-config surface with no principle to test itself against.
## What changed
| File | Change |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `docs/reference/architecture/0004-...md` | §"The deliberate exception: org-templates" → §"The deliberate exception: operational configuration" — a four-part test plus a table of the two passing surfaces |
| same file, §Context + the table | `src/locale/*.xlf``apps/<app>/src/locale/*.xlf` (two apps since WP-67) |
| `CLAUDE.md` §4 | the singular "Org-templates are the deliberate exception" replaced with the four-part test |
No code changed — the finding says so outright, and verification confirmed it.
## Why the RB-07 gate was real, verified clause by clause
Clause (4) of the test is "writes are admin-capability-gated **and** audited". Signing this
ADR before RB-07 would have ratified a control the code did not implement. RB-07 has landed,
so the clause is now true. Read at `backend/src/BigRegister.Api/Program.cs:863-923`: each of
the five gates now computes `var ok = …`, calls `AuditAuthz(ctx, capability, resource, ok,
principal)` with the **real** boolean, and only then branches. `FlagsAdmin`'s own comment
names this ticket: _"this is the surface CQ-004/ADR-C-009 hinge on."_
All four clauses were checked against both surfaces rather than assumed:
| Clause | `OrgTemplateStore` | `FeatureFlagStore` |
| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| (1) catalog in code | the `OrgTemplateDto` shape + `OrgTemplateRules` validate before save (`OrgTemplateStore.cs:49`) | `FeatureFlags.Catalog` (`Domain/Features/FeatureFlags.cs:15`) |
| (2) fails closed | unknown `subOrgId``null` → endpoint 404s (`:44-45,:55-56,:72-73,:94-96`) | `Set` returns false for an unlisted key (`:54`); `IsEnabled` returns false (`:42-43`) |
| (3) operational | one sub-organisation's letterhead | an on/off rollout switch |
| (4) gated + audited | `OrgAdmin``orgtemplate:edit` (`Program.cs:863`) | `FlagsAdmin``flags:manage` (`Program.cs:914`) |
`FeatureFlagStore`'s own comment states clause (1) and (2) explicitly: _"The CATALOG … is
code … this store only holds the admin's on/off overrides. An unknown key is never
writable/enabled — the code catalog is the authority."_
## Judgement calls
- **Clause (2) is about the write/enable path, not every read.** `OrgTemplateStore` has a
deliberate read-path fallback for briefs from before WP-23 (`:110-114`, its own `ponytail:`
comment): an empty `SubOrgId` falls back to the first seeded sub-org rather than failing a
whole screen. That is a preview convenience on a read; the four write entry points all
return `null` for an unknown sub-org. The clause is worded "cannot invent a setting,
enable a feature, or be written" so this read fallback is not caught by it. Recorded
because a reader checking clause (2) against `OrgTemplateStore.cs` will meet that
fallback first.
- **Org-templates' publish/rollback versioning is mentioned but excluded from the test.** It
is stronger than the test requires, and making it a fifth clause would block a legitimate
flag-style surface that has nothing to version.
- **The stale `src/locale/*.xlf` paths were fixed in the same diff**, though ADR-C-009 did
not flag them. They are two occurrences of the same WP-67 drift ADR-C-001 and ADR-C-007
exist to correct, in the section being edited, and leaving a known-false path in a document
while amending it is the exact failure mode those two findings describe. Scope creep is
two words wide here; the alternative is filing a third ticket for it.
## Gate released
ADR-C-009 blocked "any ticket proposing a third runtime-editable config surface". Such a
ticket can now be judged against a written test rather than by analogy to org-templates.
@@ -0,0 +1,57 @@
# RB-01 — authorize `GET /uploads/{id}/content` and `/uploads/status`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-004 · `99-backlog.md` RB-01
## What was wrong
`GET /uploads/{documentId}/content` took `(string documentId)` — no `HttpContext`, so no
authorization was possible at all. It streams diploma and identity scans; the only
protection was the unguessability of the document GUID. `DELETE` on the same resource has
been owner-scoped (`DocumentStore.DeleteOwned`) since it was written.
`GET /uploads/status?localIds=` had the same shape, and leaks less but still confirms
whether a given client-chosen `localId` exists anywhere in the store, plus its documentId.
## What changed
| File | Change |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` |
| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` |
| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) |
| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases |
The two actor kinds are matched, not branched on a boolean, because `ctx.Zorgverlener()`
**throws** for a `MedewerkerCaller` — a behandelaar reading an aanvraag's linked documents
(`beoordeling-documenten.component.ts`) is a legitimate caller here:
```csharp
var allowed = ctx.Caller() switch
{
ZorgverlenerCaller z => doc?.Owner == z.Bsn,
var caller => Authz.CanBeoordelen(caller),
};
```
**404, not 403**, per the ticket: a foreign document id must not be distinguishable from
one that never existed. `doc is null || !allowed` collapses both to the same answer, and
`/uploads/status` reports a foreign `localId` as `"unknown"` — the same word an id that
never existed gets.
## Known residual — this endpoint is reached without identity headers
Both callers link to the URL directly (`<a href>` in `beoordeling-documenten.component.ts`,
`previewUrl` in `libs/shared/src/upload/upload.adapter.ts`), so the request is a plain
browser navigation that carries **no** `X-Medewerker` / `X-Subject` header and never passes
through an Angular interceptor. `StubIdentityProvider` therefore resolves it to the seeded
citizen, which owns every document in the POC, so both links keep working — by coincidence,
not by authorization. That coincidence **is** BIO-002, and it is fixed by **RB-09** (making
`IIdentityProvider` able to express "no identity"), not here. RB-09 will need this endpoint
to receive a real credential — a signed URL or a cookie — rather than the ambient default.
## Verification
`dotnet build` clean. `dotnet test`: **250 passed, 1 failed** — the failure is
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
which needs a live OpenZaak container and **fails identically on a stashed tree**, i.e. it
pre-dates this change.
@@ -0,0 +1,58 @@
# RB-02 — stop concatenating the BSN into `AuthzAudit.Resource`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-008 · `99-backlog.md` RB-02
## What was wrong
`Program.cs` (was `:674`) built the reveal attempt's audit resource ref as
`"brief/" + ctx.Zorgverlener().Bsn`. `AuditAuthz` persists that string to the
`AuthzAudit.Resource` column in SQLite, and `GET /admin/audit` renders it on the admin
audit page — so a BSN was written to durable storage and shown in a UI, on the one trail
four documents describe as data-minimised and PII-free.
The endpoint is the _BIG-nummer reveal_, whose own comment says the audit carries
"NO PII. Never the value that was (or wasn't) revealed" — and it did not carry the
BIG-nummer. It carried the BSN instead, in the adjacent argument.
## Why the existing test did not catch it
`AuthzAuditTests.The_audit_schema_carries_no_pii` asserts on **column names**:
```csharp
Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …));
```
A BSN inside a column called `Resource` is invisible to a regex over the word `Resource`.
The test was structurally incapable of failing on this defect, which is why the
value-asserting test is part of this ticket's definition of done rather than a follow-up.
## What changed
| File | Change |
| -------------------- | ------------------------------------------------------------------------------------------------ |
| `Program.cs` | resource ref is `"brief"`; a comment records why the id added nothing |
| `AuthzAuditTests.cs` | **new** `No_audit_row_carries_a_subjects_bsn` — asserts on stored **values**, every string field |
No identifier was lost. `BriefStore` keys one brief per owner, so `brief/<bsn>` named the
same thing the row's acting principal already implies; there is no second brief the ref
could have disambiguated.
The new test drives a denied reveal as a **non-default** subject (`X-Subject: 999999990`),
then scans every string field of every audit row for that BSN and for
`DocumentStore.DemoOwner`. Asserting against the two BSNs actually in play, rather than a
`\d{9}` shape, keeps it deterministic — a hex correlation id can hold nine consecutive
digits by chance.
**Confirmed it fails without the fix**: reverting only the `Program.cs` line turns
`No_audit_row_carries_a_subjects_bsn` red, and restoring it turns it green.
## Not in scope
`AuditEntry.Actor` on document audit rows also holds a raw BSN. That is a different store
(`DocumentStore.Audit`) and is **RB-04**, which is where the masking decision for it lives.
## Verification
`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the
failure is `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live OpenZaak container
and fails identically on a stashed tree.
@@ -0,0 +1,45 @@
# RB-03 — mask the owner BSN on the cross-owner case lists
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-03
## What was wrong
`Mappers.ToAdminSummaryDto` set `Owner = a.Owner` — the raw BSN. Two endpoints consume it,
both cross-owner lists read by someone who is **not** the subject:
- `GET /admin/cases` (`cases:manage`)
- `GET /werkvoorraad` (`aanvraag:beoordelen`)
`GET /beoordeling/{id}` — the _detail_ view of the same data — already masked. So the
detail screen showed `******782` while the list one click earlier showed the whole BSN.
## What changed
| File | Change |
| ---------------------- | ---------------------------------------------------------------- |
| `Domain/People/Pii.cs` | **new**`Pii.MaskTail`, moved out of `Program.cs` |
| `Contracts/Mappers.cs` | `Owner = Pii.MaskTail(a.Owner, 3)` |
| `Program.cs` | local `MaskTail` deleted; two call sites point at `Pii.MaskTail` |
| `AdminCasesTests.cs` | asserts the masked value and that `DemoOwner` does not appear |
| `WerkvoorraadTests.cs` | same assertion, replacing the `IsNullOrEmpty` one |
**Masked in the mapper, not at the endpoints.** The point of the ticket is that both
lists _inherit_ it, so a third cross-owner list cannot be added that forgets to mask.
**`MaskTail` moved to `Domain/People/Pii.cs`** because it now has three callers across
three folders (`Contracts`, `Program.cs`, and `Data` once **RB-04** lands), and a second
hand-rolled copy is how one of them drifts into leaking. It is documented as idempotent,
which is what lets `/beoordeling/{id}` keep its own call: `IZaakSource` has a second
implementation (`OpenZaakZaakSource``ZgwZaakMapper`, which maps `Owner` from the zaak
`identificatie`), so that endpoint's guarantee should not depend on which source answered.
## Blast radius on the frontend — none
Both consumers use the value for display only (`admin-cases.page.ts:101`,
`beoordeling-view.ts:40`, `werkvoorraad-item-view.ts:28`); the `parse*` boundaries require
a non-empty string, which a masked BSN still is. Nothing keys, filters or looks up by owner.
## Verification
`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
@@ -0,0 +1,37 @@
# RB-04 — mask the BSN recorded as `AuditEntry.Actor`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-005 · `99-backlog.md` RB-04
## What was wrong
`DocumentStore` writes one audit row per upload and per user delete, with the acting
citizen's raw BSN as `AuditEntry.Actor`, persisted to SQLite. The class's own doc comment
says "The audit log holds metadata only (never file content **or other PII**)" — a BSN in
every row is precisely other PII. Same failure shape as RB-02, in a second store.
## What changed
| File | Change |
| ------------------------------------- | --------------------------------------------------------- |
| `Data/DocumentStore.cs` `Add` | `Audit("upload", …, Pii.MaskTail(owner, 3))` |
| `Data/DocumentStore.cs` `DeleteOwned` | `Audit("delete-user", …, Pii.MaskTail(owner, 3))` |
| `Data/DocumentStore.cs` `Audit` | doc comment: actors arrive **already redacted** |
| `UploadAccessTests.cs` | **new** `The_document_audit_trail_records_a_masked_actor` |
**Masked at the two call sites, not inside `Audit`** — unlike RB-03, where masking in the
mapper was the point. `Audit`'s third actor is the literal `"admin"` (from `AdminDelete`),
and `MaskTail("admin", 3)` is `"**min"`: masking centrally would mean guessing which
actors are BSNs and which are role names. The contract is stated on `Audit` instead.
**`StoredDocument.Owner` is untouched**, per the ticket. It is the authorization key —
`DeleteOwned`, `ForeignIds` and now the RB-01 content check all compare against it — so it
has to stay whole. The BSN remains where it is load-bearing and leaves the trail where it
was only decoration.
Nothing reads `DocumentStore.AuditLog` today (no endpoint exposes it), so this is a
data-at-rest fix with no response-shape change.
## Verification
`dotnet format --verify-no-changes` clean. `dotnet test`: **252 passed, 1 failed** — the
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
@@ -0,0 +1,50 @@
# RB-05 — drop the BSN-bearing query and body snippet from the ZGW failure message
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-009 · `99-backlog.md` RB-05
## What was wrong
`ZgwHttpClient.SendWithRetryAsync` built its failure message as
```csharp
$"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"
```
with `snippet` being up to 500 characters of the **response body**. That message is not
transient: `Program.cs`'s submit endpoint catches it and stores it as `Aanvraag.ZgwError`
in SQLite, and logs it.
Two BSN paths into it:
- **the query string.** ZGW filters travel as query parameters, and the citizen-scoped zaken
list filters on `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<BSN>`.
- **the body snippet.** OpenZaak's error responses echo the offending request, so a rejected
`POST /rollen` (whose body carries `BetrokkeneIdentificatie(aanvraag.Owner)`) comes back
with the BSN in it.
The two `"returned null body"` throws in `GetAsync`/`PostAsync` interpolated the same url.
## What changed
| File | Change |
| ----------------------- | ---------------------------------------------------------------------------- |
| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` |
| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` |
Status + path is enough to route a failure to the right endpoint. The diagnostic detail
that was lost already has a deliberate home: `ZGW_DEBUG_HTTP=1` wires
`ZgwDiagnosticHandler`, which logs the full url and request bytes — opt-in, dev-only, and
not persisted.
## The test
Fails the `statustypen` GET (the only call in that fixture whose url carries a query
string) after the zaak POST succeeds, then asserts on the persisted `ZgwError`:
no `"stub failure"` (the body snippet), no `"?"` (the query string), but still the path and
the `503`. **Confirmed it fails without the fix** — restoring the old interpolation turns it
red on both counts.
## Verification
`dotnet format --verify-no-changes` clean. `dotnet test`: **253 passed, 1 failed** — the
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
@@ -0,0 +1,69 @@
# RB-06 — delete the dead `POST /registrations`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-010 · `99-backlog.md` RB-06
## What was wrong
`POST /registrations` took a `Documents` list and passed it straight to `Submit`, which
calls `DocumentStore.Link(...)` on every digital `documentId` in it. Linking a document
**blocks its owner from deleting it** (`DeleteOwned` → 409 `Linked`).
There was no `ForeignIds` ownership check on that path. The real submit endpoint,
`POST /applications/{id}/submit`, has had one since it was written:
```csharp
if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds)
return Results.Problem(detail: $"Onbekend of niet-eigen document(en): …", statusCode: 400);
```
So any authenticated citizen could post another citizen's document id and permanently
block them from deleting their own diploma scan.
## Deleted rather than guarded
The ticket allowed either. Deleted, because the endpoint is dead: no frontend caller (the
generated client's `registrations` method was unreferenced), and the whole registratie flow
goes through `POST /applications/{id}/submit`.
| File | Change |
| -------------------------------------------- | ------------------------------------------------- |
| `Program.cs` | endpoint deleted |
| `Contracts/Dtos.cs` | `RegistratieRequest` deleted (no other reference) |
| `Domain/Submissions/SubmissionRules.cs` | `RejectRegistratie` deleted — see below |
| `backend/swagger.json`, `api-client.ts` | regenerated (`npm run gen:api`) |
| `EndpointTests.cs`, `SubmissionRuleTests.cs` | retargeted, see below |
### Why `RejectRegistratie` went with it
It was reachable only from this endpoint, and the live path deliberately **contradicts**
it. `RejectRegistratie("handmatig")` returned a 422 rejection; the modern submit does
```csharp
"registratie" => (null, req.DiplomaHerkomst == "duo"),
```
— a manual diploma is not rejected, it simply does not auto-approve and goes to a
behandelaar. Its own message even said so ("doorgestuurd voor handmatige beoordeling")
while being returned as a rejection. Leaving it behind would have left an obsolete rule
with a passing spec, which is exactly how it gets reintroduced.
**This is the one judgement call in this ticket** — the backlog row says "delete the dead
endpoint", not "delete the rule". Reverting just the `SubmissionRules`/`SubmissionRuleTests`
hunks restores it without touching anything else.
### Test coverage that moved rather than vanished
- `Registration_with_manual_diploma_is_rejected_with_problem_details` was the only test
asserting the `Submit` helper's `application/problem+json` rejection shape. That assertion
moved into `Change_request_with_bad_phone_is_rejected_with_problem_details`
`/change-requests` is the other endpoint on the same helper.
- `User_delete_blocked_with_409_once_linked_to_submission` covered `DocumentStore.Link`
blocking a delete. Retargeted to `POST /applications/{id}/submit`, i.e. the path that is
actually in use. `POST /registrations` was the only other caller of `Link`.
- `Registration_with_duo_diploma_succeeds` was deleted outright — `Change_request_with_valid_phone_succeeds`
is the same assertion on the same helper.
## Verification
`npm run ci`. `dotnet test`: **249 passed, 1 failed** — the pre-existing
`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
@@ -0,0 +1,63 @@
# RB-07 — audit the allow path, not just the denial
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-007 (+ the outstanding half of CQ-004) · `99-backlog.md` RB-07
## What was wrong
All five authorization gates called `AuditAuthz(..., allowed: false, ...)` only on the deny
branch; the allow branch called `action()` and returned. So `/beheer/audit` — the queryable
trail the product ships as its audit surface — could answer "who was turned away" but never
"who changed this".
Nothing recorded: `PUT /admin/flags/{key}`, `PUT /admin/org-template/{subOrgId}`,
`POST /admin/org-template/{subOrgId}/rollback/{version}`, `DELETE /admin/cases/{id}`,
`DELETE /admin/uploads/{documentId}`, `POST /brief/approve|reject|send`, and
`POST /beoordeling/{id}/besluit`. The comment above `OrgAdmin` claimed the endpoints logged
their own effect instead; publish and admin case delete do, the other six did not log at all.
## What changed
| File | Change |
| ------------------------- | ----------------------------------------------------------------------------- |
| `Program.cs` × 5 gates | `var ok = Authz.CanX(p); AuditAuthz(ctx, …, ok, p); if (ok) return action();` |
| `Program.cs` `FlagsAdmin` | takes a per-call `resource` (see below) |
| `Program.cs` `LogBrief` | takes `HttpContext`, writes the audit row alongside the log line |
| `Program.cs` besluit | one `aanvraag:besluit` row recording **what** was decided |
| `AuthzAuditTests.cs` | allow-path row; the flag key + value; a refused brief transition |
| `BriefEndpointTests.cs` | the allow side of `brief:submit` |
| `BeoordelingTests.cs` | the `aanvraag:besluit` row |
**The row is written by the gate, not the endpoint.** That is the point: a new admin
endpoint cannot be added that forgets to audit itself. Same reasoning for the brief — every
transition already funnelled through `LogBrief` for its log line, so the audit call went
there too, which covers `submit`/`approve`/`reject`/`send` in one place and any fifth
transition automatically. The decision recorded is the transition's own outcome, so a 403 or
a 409 is as visible as a success.
**`FlagsAdmin` gained a `resource` parameter** — the one deviation from BIO-007's minimal
remediation, and the reason is in the finding itself: the toggle endpoint writes no log line
of its own, so a constant `"feature-flags"` row would record that a flag changed without
recording _which_. It now writes `feature-flags/<key>=<value>`. One call site.
`OrgAdmin`/`CasesAdmin` keep their coarse refs because those endpoints do log the specific
object; **that asymmetry is deliberate, not an oversight.**
**The besluit gets a second row.** The `Beoordelen` gate records that a behandelaar was
_allowed to act_; `aanvraag:besluit` records _what they decided_
(`aanvraag/<id>/Goedkeuren`). Only the first would leave "who rejected this aanvraag"
unanswerable, which is the question the trail exists for.
## Consequences worth knowing
- **Row volume goes up.** `StamdataAdmin` gates read endpoints, so every admin page load now
writes rows. That is what "audit the allow path" means and BIO-007 asks for it explicitly;
if `AuthzAuditStore` ever needs retention or sampling, this is the change that made it
necessary.
- **This unblocks ADR-C-009.** Clause (4) of agent 06's four-part test is "writes are
admin-capability-gated **and** audited". Both surfaces now are, so the amendment can be
signed without ratifying a control the code does not implement.
- **CQ-004's outstanding half is closed.** `PUT /admin/flags/{key}` writes an audit row.
## Verification
`dotnet test`: **252 passed, 1 failed** — the pre-existing
`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
@@ -0,0 +1,75 @@
# RB-08 — route `DELETE /admin/uploads/{documentId}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-08
## What was wrong
`Program.cs:790` (pre-change) had `static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";`
and `DELETE /admin/uploads/{documentId}` (`:274`) was gated by `IsAdmin` alone — outside
`Authz`, outside every wrapper the four sibling admin surfaces use, and writing no
`AuthzAuditStore` row at all. `DocumentStore.AdminDelete` bypasses ownership and deletes the
row and its bytes; the only record left behind was a `DocumentStore.Audit("delete-admin", …)`
metadata row, which never surfaces on `/beheer/audit`.
`grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e` (re-verified before deleting the
gate, as the ticket asked) confirmed the finding: the only sender was
`backend/tests/BigRegister.Tests/EndpointTests.cs:231`. No frontend or e2e path uses this
header — it was an orphaned gate, not a live seam.
## What changed
| File | Change |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Program.cs` `DELETE /admin/uploads/{id}` | now `CasesAdmin(ctx, () => DocumentStore.AdminDelete(...) ? NoContent : NotFound)` — same wrapper the other four admin-cases endpoints use; response doc changed `Produces(403)``ProducesProblem(403)` to match `CasesAdmin`'s `Results.Problem` |
| `Program.cs` `IsAdmin` | **deleted** |
| `Program.cs` two stale comments | the endpoint's own comment rewritten to describe the new gate; the brief-section banner comment ("mirrors the X-Admin seam") no longer references a gate that doesn't exist |
| `tests/BigRegister.Tests/EndpointTests.cs` | `Admin_delete_requires_admin_role` sends `X-Role: admin` instead of `X-Admin: true` |
| `tests/BigRegister.Tests/AuthzAuditTests.cs` | **new** `An_admin_upload_delete_is_recorded` — asserts the `cases:manage`/`allow`/`Admin` row count increases by exactly one after the delete |
No new `Authz` capability was added — `CasesAdmin`/`Authz.CanManageCases` is the wrapper the
ticket named as the expected outcome, and nothing about this endpoint needed a narrower
capability than "manage cases" already provides.
**RB-07 already moved `AuditAuthz` onto the allow path for every `*Admin` wrapper**, so
routing through `CasesAdmin` gives BIO-003's missing audit row for free. No second
`AuditAuthz` call was added — confirmed by reading `CasesAdmin`'s body (`Program.cs`): it
calls `AuditAuthz(ctx, "cases:manage", "cases", ok, principal)` unconditionally before
branching on `ok`.
## Judgement calls
- **Test asserts a count delta, not mere presence.** `CasesAdmin` audits under a fixed
`"cases"` resource literal shared by every `cases:manage` call (`GET /admin/cases`,
`DELETE /admin/cases/{id}`, `GET /admin/audit` itself, and now this endpoint), so
`Assert.Contains(rows, cases:manage/allow/Admin)` would already be satisfied by this test
class's _other_ tests even without the fix. The new test counts matching rows before and
after the delete and asserts the count grew by exactly one. It reads `AuthzAuditStore.List()`
in-process rather than through `GET /admin/audit` — that endpoint is itself a `CasesAdmin`
read, so calling it to take the "before" measurement would have written its own
`cases:manage`/`allow` row and silently inflated the count by one every time it was called
(caught this by running the test once against the fix with an HTTP-based baseline: it
failed with an off-by-one before switching to the in-process read).
- **Two comments referencing the old gate were also updated**, not just the endpoint mapping
itself — one directly above the endpoint, one in the brief-section banner comment
("dev-only stand-in via X-Role, mirrors the X-Admin seam") that would otherwise describe a
gate that no longer exists.
## Known residual
None new. RB-01's implementation note already records that `GET /uploads/{id}/content` is
reached with no identity header via plain browser navigation — that residual is RB-09's
territory, not this ticket's, and is untouched here.
## Verification
- Reverted `Program.cs`'s endpoint change only (`git stash push` on that one file, tests
left in place) and re-ran `dotnet test --filter "AuthzAuditTests|EndpointTests"`: **both**
`EndpointTests.Admin_delete_requires_admin_role` and
`AuthzAuditTests.An_admin_upload_delete_is_recorded` failed red (403 Forbidden — the old
gate rejects `X-Role: admin`, and the count-delta test throws on `EnsureSuccessStatusCode`
before it can assert). Restored the fix (`git stash pop`) and re-ran: both green.
- `dotnet build`: clean.
- `dotnet test` (full suite): **253 passed, 1 failed** — the pre-existing
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
which needs a live OpenZaak container and fails identically on a clean tree; not touched by
this ticket.
@@ -0,0 +1,168 @@
# RB-09 — make "no identity" representable; stub Development-only; fail fast in Production
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-002 (folds in BIO-001(a)/(b)) · `99-backlog.md` RB-09
## What was wrong
`IIdentityProvider.Resolve` returned a non-nullable `CallerIdentity`
(`IIdentityProvider.cs:12`, pre-change), so the interface could not express "no identity" — any
implementation, stub or real, was forced to invent one for an unauthenticated request.
`StubIdentityProvider` was registered unconditionally, for every environment.
Consequence, traced end to end: `apps/behandelportal/src/app/app.config.ts:57-63` puts
`medewerkerInterceptor` inside the `isDevMode()` provider array, so a production
behandelportal build sends **no** `X-Medewerker`/`X-Rollen` header. With neither header,
`StubIdentityProvider.Resolve` fell through to
`new ZorgverlenerCaller(DocumentStore.DemoOwner, ..., PrincipalRole.Drafter)` — the single
seeded citizen, role `drafter`. That:
- **Fails closed, correctly, on backoffice capabilities**`Authz.CanBeoordelen` is
`caller is MedewerkerCaller`, so a zorgverlener caller is always `false` regardless of role.
This part of the design was right and is untouched.
- **Fails open on every citizen-scoped endpoint**`GET/PUT/DELETE /applications*`,
`POST /applications/{id}/submit`, `DELETE /uploads/{id}`, `GET|PUT /brief`,
`POST /brief/submit|send|reset` all resolve `ctx.Zorgverlener().Bsn` to the seeded citizen's
BSN. An employee with no employee identity was granted a citizen's own read/write rights.
- **Holds `CanRevealBigNummer`** — that capability is `Role == PrincipalRole.Drafter`, and
`drafter` is exactly the no-header default.
`CallerIdentityHttpContextExtensions.Caller()` (`CallerIdentity.cs:44-50`) already throws
loudly when the identity middleware didn't run — the codebase reached for fail-loud one layer
up and then defaulted one layer down, which is the shape of the bug.
## What changed
| File | Change |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Domain/Authorization/IIdentityProvider.cs` | `Resolve` returns `CallerIdentity?`; doc comment states what null means and where it's turned into a response |
| `Domain/Authorization/StubIdentityProvider.cs` | **implementation signature unchanged** (`CallerIdentity`, non-nullable) — a valid, narrower override of the nullable interface method (return-type covariance; the compiler accepts it with zero warnings); doc comment records it is Development-only and never itself returns null |
| `Program.cs` — registration | `StubIdentityProvider` registered only under `builder.Environment.IsDevelopment()`; an `else if (builder.Environment.IsProduction())` branch throws `InvalidOperationException` immediately, before `builder.Build()` — the earliest possible failure point |
| `Program.cs` — identity middleware | resolves the identity once; if `null`, sets `401` and returns without calling `next()`, instead of passing a null (or invented) caller downstream |
| `tests/BigRegister.Tests/StubIdentityProviderTests.cs` | **new** `Never_returns_null_even_with_no_headers_at_all`; **new** `ProductionIdentityProviderTests.Production_environment_with_no_real_identity_provider_fails_at_startup` |
No consumer beyond the middleware itself calls `IIdentityProvider.Resolve` (`grep -rn
"IIdentityProvider\|identityProvider\."` over `backend/src` confirms exactly one call site) —
`Authz.ResolvePrincipal`, `ZgwTokenProvider.Mint`, and every endpoint read `ctx.Caller()` /
`ctx.Zorgverlener()`, which already throw on a missing identity and are untouched. The 401
now happens _before_ those are ever reached for a request the middleware rejects.
## Judgement calls
- **`StubIdentityProvider`'s own method signature stays `CallerIdentity`, not
`CallerIdentity?`.** The interface needed the nullable shape to make "no identity"
representable in general; the stub itself never has that case (it is a developer
convenience that always invents a caller by design) and returning a narrower,
non-nullable type from an override of a nullable-returning interface method is valid C#
nullable-reference-type covariance — verified with a clean `dotnet build` (0 warnings).
This kept every existing `StubIdentityProviderTests` call site (`private static
CallerIdentity Resolve(...)`) compiling with zero changes, rather than sprinkling
null-forgiving operators through a file whose entire point is "the stub always resolves."
- **The Production-only check is `IsProduction()`, not `!IsDevelopment()`.** The ticket and
BIO-002 both say "Production must fail at startup" specifically. A third environment (e.g.
a hypothetical `Staging`) falls through neither branch, registers no `IIdentityProvider` at
all, and would still fail — one line later, when `app.Services.GetRequiredService<
IIdentityProvider>()` throws .NET's own "no service for type" exception — just with a less
specific message than the one this ticket adds for Production. That fallback is a safety
net, not the intended fail-fast message; if a real non-Production, non-Development
environment is added later, giving it the same explicit message is a one-line follow-up,
not a design gap today.
- **The throw sits before `builder.Build()`**, not after (where `GetRequiredService` already
runs today). Both satisfy "throw during service registration / app build so a misconfigured
deploy never serves a request" — throwing earlier was free and gives a message naming the
actual cause (no real identity provider) rather than a generic DI resolution failure.
- **The 401 short-circuits before `ctx.SetCaller`, not after.** `next(ctx)` is never called,
so no downstream middleware or endpoint runs for a request with no identity — a citizen or
behandelaar endpoint reached this way now gets a clean 401 instead of ever executing.
- **`TestWebApplicationFactory` needed no change.** `WebApplicationFactory<T>` defaults its
test host to the `Development` environment when nothing overrides it (confirmed
empirically: `dotnet test` — every non-Production test, all 253 of them pre-existing plus
2 new, passed unchanged), so the entire existing test suite continues to exercise the
Development path exactly as before. The Production test builds its own
`WebApplicationFactory<Program>().WithWebHostBuilder(b => b.UseEnvironment("Production"))`
rather than touching the shared fixture.
## Known residual — explicitly out of scope, confirmed and written up per the ticket
**RB-01's residual is this ticket's territory but is explicitly out of scope for this
ticket**, per the task: `GET /uploads/{documentId}/content` is reached by a plain browser
navigation (`<a href>` in `beoordeling-documenten.component.ts`, `previewUrl` in
`libs/shared/src/upload/upload.adapter.ts`) that sends no identity header and never passes
through an Angular interceptor.
- **In Development, this is unchanged** — verified by reading the endpoint
(`Program.cs:253-266`) and confirming `StubIdentityProvider` is still registered and still
resolves the same non-null seeded-citizen default it always did when no headers are
present. `dotnet test`'s full pass (255/255, excluding the pre-existing OpenZaak failure)
including `UploadAccessTests` — which exercises exactly this endpoint — confirms it
byte-for-byte.
- **The Production consequence, for the next ticket:** today Production cannot start at
all (this ticket's fail-fast), so the question is moot until a real `IIdentityProvider`
exists. Once one does, this endpoint's plain-navigation callers carry no credential a real
provider could resolve — the identity middleware would treat that as "no identity" and
return 401 before the endpoint ever runs, breaking both preview links outright. Making the
stub Development-only does not itself break anything (nothing in Production exists yet to
break), but it does mean **whoever builds the real provider must also solve this endpoint's
credential-carrying problem in the same change**, or ship it broken. This is not a
signed-URL or cookie scheme, and no such scheme was designed here, per the ticket's explicit
instruction — it is recorded so the next ticket (RB-13, or whichever lands the real
provider) picks it up deliberately rather than discovering it in a production incident.
## Verification
- **Reverted the registration change only** (kept `AddSingleton<IIdentityProvider,
StubIdentityProvider>()` unconditional, left the new tests in place) and ran
`ProductionIdentityProviderTests`: it failed red — `Assert.ThrowsAny() Failure: No exception
was thrown` (the stub gets registered in every environment, so the host builds fine and
`factory.CreateClient()` never throws). Restored the fix and re-ran: green.
- `dotnet build`: clean, **0 warnings** (confirms the nullable-covariance judgement call
above compiles cleanly).
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
- `dotnet test` (full suite): **255 passed, 1 failed** — the pre-existing
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`
(needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter,
`dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**.
## A regression found by actually running `npm run ci`, and its fix
`npm run gen:api` (`dotnet swagger tofile`) loads `BigRegister.Api.dll` through .NET's
design-time `HostFactoryResolver` — the same mechanism `dotnet ef` migrations use — which
executes this file's top-level statements, including the identity middleware's
pre-existing, unconditional `app.Services.GetRequiredService<IIdentityProvider>()`, without
ever setting `ASPNETCORE_ENVIRONMENT`. Unset defaults to `Production`. Before this ticket
that was harmless (`StubIdentityProvider` was registered unconditionally); after it, nothing
is registered for that default environment, so the tool crashed
(`dotnet swagger tofile` exited **134**, confirmed by running it directly, both before and
after the fix below).
This is real breakage of a real workflow, not a false alarm from `ci-local.sh` — verified by
reading `.github/workflows/ci.yml`'s `api-client-drift` job: `npm run gen:api` and
`git diff --exit-code ...` are **two separate `- run:` steps** there, so the crash would fail
actual CI. `ci-local.sh` chains them as `npm run gen:api && git diff --exit-code ...` on one
line, and its first full run of `npm run ci` after this ticket's change **printed the crash
but still reported `✔ local CI passed`** — a bash `set -e` gotcha, not a false negative
specific to this fix: a failing command that is not the last element of an `&&`/`||` list is
exempt from triggering `errexit`, so `cmd1 && cmd2` silently "passes" whenever `cmd1` alone
fails. That is a pre-existing fragility in `ci-local.sh`'s three `step "X"; gen && git diff`
lines (snippets/behaviour-spec/api-client drift), unrelated to RB-09 and out of this
ticket's scope — flagged here rather than fixed, since fixing a local convenience script's
error handling is a different, standalone change. Running the failing command directly
(rather than trusting the local script) is what caught this.
**Fix:** `package.json`'s `gen:api` script now sets `ASPNETCORE_ENVIRONMENT=Development`
on the `dotnet swagger tofile` invocation specifically — the same value
`backend/src/BigRegister.Api/Properties/launchSettings.json` already sets for `dotnet run`,
and the same value `docker-compose.yml` already sets for local Docker (confirmed by reading
both: `docker-compose.prod.yml` sets `Production` explicitly, `docker-compose.yml` sets
`Development` explicitly — the bare CLI tool invocation was the **one** place with no
environment variable set at all). Verified: `npm run gen:api` now exits 0 and produces the
regenerated `backend/swagger.json` / `libs/shared/src/infrastructure/api-client.ts` cleanly.
This also surfaced a second, unrelated gap: RB-08 changed
`DELETE /admin/uploads/{documentId}`'s 403 mapping from `.Produces` to `.ProducesProblem`
but the generated OpenAPI doc/client were never regenerated for it (RB-08's own `npm run ci`
was run before this fix existed, so `gen:api` was already broken by the time RB-09 landed
and the drift went unnoticed). Regenerated and committed separately — see the two follow-up
commits **fix(api): regenerate client for RB-08's 403 response shape** and
**fix(tooling): keep gen:api working under RB-09's Development-only stub**. No frontend
consumes the admin-uploads-delete endpoint (confirmed by grep), so the client regeneration
has no consumer impact.
@@ -0,0 +1,87 @@
# RB-10 — extract `parseStoredSession` (both apps) and spec `redactProfile`
Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-001 (ssp/auth and bhp/auth) · `07-bio2-compliance.md` BIO-017 · `99-backlog.md` RB-10
## What was wrong
`SessionStore.restore()` — identical in `apps/ssp/src/app/auth/application/session.store.ts`
and `apps/behandelportal/src/app/auth/application/session.store.ts` — called
`localStorage.getItem(STORAGE_KEY)` itself and did the parse + shape validation in the same
module-private function. It was invoked from a field initializer
(`private _session = signal<Session | null>(restore())`), so the storage read happened the
instant the singleton was constructed; a spec could not feed it a raw string without
stubbing the `localStorage` global before the injector built the store.
The logic behind that guard is a trust boundary, not incidental validation — the comment
above it names two guarantees: **G1** (never persist the BSN) and **G2** (validate the shape
before trusting it). CLAUDE.md §5 mandates a spec for boundary `parse*` adapters, and none
existed. Baseline evidence: `02-testability.md` §3a cites `ssp/auth` and `bhp/auth` at
42.9% line / 46.2% branch — jointly the worst line coverage in the frontend table — with
this file's own lcov at LH 2/LF 20 (10.0% line), BRH 3/BRF 13 (23.1% branch).
BIO-017 read the same code and confirmed G1 holds on every path by inspection (`restore()`
returns `{ bsn: '', naam }`, the persistence `effect()` writes only `naam`, `login()`/
`logout()` never touch storage with a BSN) — but "correct, unverified by a test" is exactly
the gap TE-001 already targeted, so BIO-017 folds into it and adds one required assertion:
a stored `{"bsn":"…","naam":"…"}` must yield a session whose `bsn` is `''`.
Separately, `apps/ssp/src/app/shell/debug-state/mask.ts` — confirmed at the path the finding
cites — has `redactProfile`, a pure, exported, directly callable PII-redaction function with
no spec. It redacts name, birthdate and address and masks the BIG-nummer; BIO-017 verified it
correct by reading, same "no regression net" gap.
## What changed
| File | Change |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/ssp/src/app/auth/domain/session.ts` | added `export function parseStoredSession(raw: string \| null): Session \| null` — the exact parse+validate body `restore()` used to hold |
| `apps/ssp/src/app/auth/application/session.store.ts` | `restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))` |
| `apps/ssp/src/app/auth/domain/session.spec.ts` | 4 new cases: absent, non-JSON, wrong shape, and the G1 assertion |
| `apps/behandelportal/src/app/auth/domain/session.ts` | identical extraction, second app |
| `apps/behandelportal/src/app/auth/application/session.store.ts` | identical collapse, second app |
| `apps/behandelportal/src/app/auth/domain/session.spec.ts` | identical 4 cases, second app |
| `apps/ssp/src/app/shell/debug-state/mask.spec.ts` | new file — spec for `redactProfile`: masks the BIG-nummer, redacts name/geboortedatum/adres on both `registration` and `person`, leaves `beroep`/`registratiedatum`/`status` untouched |
The extracted function's body is a byte-for-byte move — same `try`/`catch`, same
`JSON.parse` cast, same `typeof parsed?.naam === 'string'` guard, same `{ bsn: '', naam }`
construction. Only its location and the doc comment (rewritten to explain the _why_ of G1/G2
for a function now read on its own, rather than inline next to the `effect()` it used to sit
beside) changed.
## The seam lands twice, on purpose
`auth` is deliberately unshared per ADR-0002 / CLAUDE.md §1: Zorgverlener and Medewerker are
different `Principal` variants with different login flows, and the two `session.ts` files are
expected to diverge. TE-001 says this outright, and BL-002 flags any extract-to-`libs/shared`
here as contradicting an accepted ADR. `parseStoredSession` was therefore written twice, once
per app's own `domain/session.ts` — not factored into a shared helper, and not resisted only
in this note; the two functions are word-for-word identical today and that is expected to
change the moment `RB-13` (`Session → Principal`) lands.
## Judgement calls
- **`redactProfile`'s spec asserts on the concrete shape**, not just "not equal to the input" —
it pins `bigNummer` to `'********901'`, checks `REDACTED` on each PII field individually, and
separately asserts the non-PII fields (`beroep`, `registratiedatum`, `status`) survive
unchanged. A looser "no PII substring appears" assertion would have been weaker at catching
the regression this ticket exists to prevent (e.g. a future field added to `redactProfile`'s
output that is left unmasked by accident).
- **The G1 spec case uses `toEqual`, not `toBe`**, since the parser constructs a new object;
this matches the existing `isAuthenticated` spec's style in the same file.
- No production code beyond the `restore()` one-liner in each `session.store.ts` changed —
`login()`, `logout()`, and the persistence `effect()` were already correct and are
unaffected.
## Verification
Confirmed both new specs are red without the fix:
- Temporarily changed `parseStoredSession` to keep a stored `bsn` (`bsn: parsed.bsn ?? ''`
instead of `bsn: ''`) — the new G1 test failed with
`expected { bsn: '19012345601', naam: 'Test' } to deeply equal { bsn: '', naam: 'Test' }`,
all 243 other tests stayed green. Reverted; `git diff` on the file is empty afterward.
- Temporarily changed `redactProfile` to pass `naam` through unmasked — the new "redacts the
name" test failed with `expected 'J. Jansen' to be 'redacted'`. Reverted; `git diff` on the
file is empty afterward.
`npm run ci`: **green** (see PR/commit for the run this doc ships with).
@@ -0,0 +1,179 @@
# RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-012,
TE-002, BIO-006(a)+(b) · `99-backlog.md` RB-11
## What was wrong
**BIO-012 — the dev hatches were not actually dev-only.** The `roleInterceptor` /
`subjectInterceptor` chain is correctly registered only under `isDevMode()`
(`app.config.ts`), but three adapters bypass `HttpClient` entirely and set headers
themselves with no guard at all:
- `reveal-bignummer.adapter.ts:26``'X-Role': currentRole(), 'X-Step-Up': 'true'`
- `letter-preview.adapter.ts:46``'X-Role': currentRole()`, plus `'X-Subject'` when present
- `org-template.adapter.ts:79``'X-Role': currentRole()`
The readers underneath were ungated too: `role.ts:24` and `subject.ts:24` both read the
`?role=`/`?subject=` query param and **wrote it into `sessionStorage`** on any
navigation, in any build. For `?subject=` that value is a BSN — `subject.ts`'s own doc
comment argued at length that the BSN must never leave `SessionStore` and then routed it
through `sessionStorage` anyway. `docs/reference/roles-and-access.md:23` claimed "Both
are wired only under `isDevMode()` — they do not exist in a production build", which was
false for exactly these three call sites.
**TE-002 — the reveal's trust boundary was not callable.** The response-shape validation
in `reveal-bignummer.adapter.ts` (the code's own comment called it a "Trust boundary")
lived inline inside `async reveal()`, after `await fetch(...)` on the global `fetch`. A
spec could not reach it without stubbing `globalThis.fetch`. The same shape recurred,
un-exported, in `letter-preview.adapter.ts`'s `errorMessage` and — contrary to the
finding's text, see "Judgement calls" below — as an inline `try/catch` (not yet a
function) in `org-template.adapter.ts`'s `proefbrief()`.
**BIO-006(a) — the step-up stub was a constant.** `reveal-bignummer.adapter.ts` sent
`'X-Step-Up': 'true'` unconditionally, as a literal, so the backend's
`canReveal && X-Step-Up == "true"` precondition was satisfied by every call that reached
the endpoint and constrained nothing.
**BIO-006(b) — the default role holds the PII-reveal capability, undocumented.**
`StubIdentityProvider`'s `_ =>` role-switch arm resolves any request with no (or an
unrecognised) `X-Role` header to `drafter` — the one role `Authz.CanRevealBigNummer`
grants. `roles-and-access.md` documented `drafter` as "the only role that may reveal a
BSN" without noting that it is also the fallback identity, so the least-privilege
consequence was invisible.
## What changed
| File | Change |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `libs/shared/src/infrastructure/role.ts` | `currentRole()` returns `'drafter'` immediately when `!isDevMode()` — no query-param read, no `sessionStorage` write |
| `libs/shared/src/infrastructure/subject.ts` | `currentSubject()` returns `undefined` immediately when `!isDevMode()` — same treatment for the BSN |
| `reveal-bignummer.adapter.ts` | `reveal(stepUp: boolean)`; `X-Role` sent only under `isDevMode()`, `X-Step-Up` sent only when `stepUp`; inline shape check moved to exported `parseRevealed(body)`; `REVEAL_FAILED` exported |
| `letter-preview.adapter.ts` | headers wrapped in `isDevMode() ? {...} : {}`; `errorMessage` exported |
| `org-template.adapter.ts` | `X-Role` sent only under `isDevMode()`; the inline `proefbrief()` error `try/catch` extracted to an exported `proefbriefErrorMessage`; `PROEFBRIEF_FAILED` exported |
| `apps/ssp/src/app/brief/application/brief.store.ts` | `revealBigNummer()` calls `this.revealAdapter.reveal(true)` — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter |
| `docs/reference/roles-and-access.md` | records which two places the `isDevMode()` gate now lives (interceptor registration **and** the reader functions) and why; adds the BIO-006(b) note that `drafter` is also the backend's fallback identity |
| 6 new/extended `*.spec.ts` | see "Verification" below |
**The fix is two layers, not one, because the finding named both.** Gating only
`currentRole()`/`currentSubject()` would already stop the query param and the
`sessionStorage` write from working outside `isDevMode()` — the three adapters would
then send the safe default (`'X-Role': 'drafter'`, no `X-Subject`) even in production.
The adapters are _also_ wrapped in `isDevMode()` so a production request from any of the
three hand-written `fetch` calls carries no `X-Role`/`X-Subject` header at all, exactly
matching what a `HttpClient` request already does once `roleInterceptor` is not
registered — the two paths now agree on production behaviour instead of merely agreeing
on the resulting header value.
**`X-Step-Up` is deliberately not folded into the same `isDevMode()` gate.** It is not a
`?role=`/`?subject=`-style dev override; it is the stub for a control BIO-006 says must
survive into production (in stubbed form) until a real step-up exists. Nesting it inside
`isDevMode()` would make the reveal endpoint permanently unreachable in a production
build. Instead it is gated on the `stepUp` parameter alone, which is `true` only when
`BriefStore.revealBigNummer()` — reachable only via `behandel-scherm.component.ts`'s
`onReveal()` confirm — calls it.
## Judgement calls
- **`org-template.adapter.ts`'s proefbrief error mapping was not "already a separate
function".** The finding's remediation text says "the proefbrief error mapping in
`org-template.adapter.ts` — both are already separate functions and only need
`export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading
the file: the other two adapters do have a standalone `errorMessage`/similar function,
but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the
`try { … } catch { … }` block, not a named function. This is a minor factual
imprecision in the finding, not a blocker — I extracted the same inline logic into a
named `proefbriefErrorMessage`, exported it, and added the same spec shape as its two
siblings. The result matches the finding's intent (a callable, spec'd trust boundary)
even though the starting shape needed one extra step the finding didn't mention.
- **The BIO-006(a) literal moved to `BriefStore.revealBigNummer()`, not to the UI.**
`behandel-scherm.component.ts`'s `onReveal()` already gates the _only_ path that can
reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own
docstring says the step-up gesture "is the UI's concern". Threading a boolean through
the component's `output<void>()` and the page's template binding would touch three more
files for no behavioural change, since the call graph already guarantees confirmation
happened first. I moved the literal one layer up instead — out of the adapter (the
transport) and into the store (the command that is exclusively reachable via the
confirmed gesture) — which is the smallest change consistent with "not from the
adapter's literal" and with this repo's ui → application → infrastructure layering (ui
cannot call infrastructure directly to pass the flag down any other way).
- **Redundant-looking `isDevMode()` guards, kept anyway.** After gating
`currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around
the headers object is not strictly load-bearing for `X-Subject` (already `undefined`
outside dev) and only changes the _value sent_ for `X-Role` (a hardcoded `'drafter'`
vs. no header) rather than any security outcome (the backend treats both identically).
I kept the adapter-level gate anyway so the security posture is visible by inspection
at the fetch call site — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern
— rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it.
- **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via
`inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient`
purely to reach a method that doesn't use either. I judged that disproportionate to the
marginal coverage gained, since the identical `isDevMode()` pattern is already
exercised end-to-end (via `fetch` stubbing) on the other two adapters
(`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the
underlying reader-level fix is covered directly in `role.spec.ts`. Noted here as a
residual rather than silently skipped.
- **`setRole()` (the dev-switcher writer) was left ungated.** BIO-012's evidence names
the two _readers_ (`currentRole`/`currentSubject`); `setRole()` is only ever invoked
from `debug-state.component.ts`, which is itself rendered only under
`shell.component.ts`'s `@if (isDev && debugPanel)`. Gating it too would be harmless but
wasn't asked for and has no reachable production call site to protect — left alone to
keep the diff to what the finding actually named.
## Consequences worth knowing
- **Doc correction, same diff.** `roles-and-access.md`'s "Both are wired only under
`isDevMode()`" line is accurate as of this commit — the gate now lives in the
interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves.
Before this commit, the sentence was false for the three hand-written `fetch` paths; the
doc has been extended, not merely left as-is, to say _where_ the gate lives so a future
reader doesn't have to rediscover why the interceptor site alone wasn't sufficient.
- **CLAUDE.md needed no correction.** Its "Scenario toggle (dev-only, not wired in prod
builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three
hand-written `fetch` adapters specifically (the accompanying sentence already says they
"bypass the interceptor", which stays true — they still don't go through
`HttpClient`). Those claims were already compatible with a fix landing here; they made
no false statement that needed walking back.
- **`?subject=` is still undocumented by name in `roles-and-access.md`.** The BIO-012
evidence and this ticket's brief both discuss it, but the doc file never named
`?subject=`/`X-Subject` before this change and still doesn't get a dedicated section —
only the new paragraph under "How to switch role" mentions it in passing. A full
`?subject=` write-up (its own e2e-only purpose, `X-Medewerker`/`X-Rollen` parallel) is
arguably worth a follow-up doc pass, but out of scope for a security-focused ticket
about production leakage.
- **Behaviour spec regenerated.** `libs/shared/docs/behaviour-spec.mdx` is generated from
the suite (`npm run gen:behaviour-spec`) and is included in this diff — the CI gate's
drift check would otherwise fail on the 6 new `describe` blocks this ticket adds.
## Verification
Every fix below was confirmed **red without it** by temporarily reverting the source
change (tests unchanged) and re-running the affected suite, then restoring the fix:
- `libs/shared/src/infrastructure/role.spec.ts` — removing the `if (!isDevMode())` guard
from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured,
still written to `sessionStorage`).
- `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()`
turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written
to `sessionStorage`).
- `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting
`reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }`
turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the
`stepUp` argument; `X-Role` sent regardless of `isDevMode()`).
New specs, all pure/exported-boundary tests per house convention (no `TestBed`, no
`globalThis.fetch` stub needed for the pure halves):
| File | Covers |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `reveal-bignummer.adapter.spec.ts` | `parseRevealed` (incl. the finding's own `{ bigNummer: 42 }` rejection case); `reveal()`'s `X-Step-Up`/`X-Role` gating via a stubbed `fetch` |
| `letter-preview.adapter.spec.ts` | `errorMessage`; `preview()`'s header gating via a stubbed `fetch` |
| `org-template.adapter.spec.ts` (extended) | `proefbriefErrorMessage` |
| `role.spec.ts` (new) | `currentRole()` dev behaviour + the `isDevMode()`-gated production behaviour |
| `subject.spec.ts` (new) | `currentSubject()` dev behaviour + the `isDevMode()`-gated production behaviour |
`npm run ci` (lint, typecheck, `dep:check`, `format:check`, `check:tokens`, `check:seam`,
full test suite with coverage, `ng build --localize` for both apps, `npm audit`, backend
`dotnet format` + `dotnet test`, showcase-snippets/behaviour-spec/api-client drift
checks): **green**, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and
`dotnet test` (241 passed).
@@ -0,0 +1,115 @@
# RB-12 — a route-table test: every route hits an authz wrapper or an explicit allow-list
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016, `00-baseline.md` BL-006 · `99-backlog.md` RB-12
## What was wrong
BL-006, verbatim: "the backend has zero automated architecture enforcement … `Domain/`
purity currently holds by convention." BIO-016 names the specific consequence for
authorization: nothing asserted the **set** of gated endpoints, so an endpoint added
without a gate (BIO-003's `X-Admin` gate outside `Authz`, BIO-004's two endpoints with
no gate at all) failed no test. Both were caught by a human reading `Program.cs`, not by
CI.
## What changed
| File | Change |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Program.cs` — 16 endpoint mappings | each chains a new `.Gate("XAdmin")` call, naming the admin wrapper (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`) already used inside its handler |
| `Program.cs` — new types, end of file | `public sealed record AuthzGateMetadata(string Wrapper)` + a `Gate(...)` extension method on `IEndpointConventionBuilder` that attaches it via `.WithMetadata(...)` |
| `tests/BigRegister.Tests/RouteInventoryTests.cs` | **new** — walks the real app's `EndpointDataSource`, asserts every route carries either an `AuthzGateMetadata` naming a known wrapper, or an entry in a written-down allow-list; a second test asserts every `.Gate(...)` name is one of the five known wrappers |
## Design: metadata at mapping time, not reflection over the compiled lambda
The ticket left the detection mechanism open, noting the wrappers are local functions
in `Program.cs`. Reflecting over a compiled minimal-API lambda to determine which local
function its closure calls is fragile-to-impossible (the call is inside IL a test would
have to disassemble, and a local function's identity isn't easily recoverable from the
delegate's `MethodInfo`). Endpoint **metadata**, attached at the same call site where the
route is mapped, is exactly what `EndpointDataSource` hands back to a test host and
doesn't depend on inspecting compiled code at all — so a `.Gate("XAdmin")` extension
method was added and chained onto each of the 16 mappings that call one of the five
wrappers.
This is a **declaration**, not a **derivation**: the test does not verify that
`.Gate("CasesAdmin")` and an actual `CasesAdmin(ctx, …)` call inside the handler agree —
it only verifies that a marker is present. A handler that swapped its `CasesAdmin(ctx,
…)` call for a no-op without updating `.Gate(...)` would go undetected here. What _is_
caught, reliably, is the actual BIO-003/BIO-004 failure mode: a new endpoint mapped with
**no** marker and **no** allow-list entry — verified below by adding one and watching the
test go red.
## Judgement call: the allow-list is not "public routes"
The ticket's literal framing — every route "goes through one of the authz wrappers …
or appears in an explicit, named allow-list of deliberately-public routes" — doesn't fit
this codebase as read. Only 16 of the app's 47 routes go through one of the five admin
wrappers. The other 31 are not uniformly public:
- **10 are genuinely public** — orchestrator health probes and static/reference demo
data (`SeedData`, the DUO/BRP fixtures, the scholing-threshold config value, the
feature-flag catalog, `/me`'s reflection of the caller's own capabilities) that reads
the same for every caller in this one-seeded-citizen POC.
- **19 are ownership-scoped inline**, not public and not wrapper-gated: `GET
/applications/{id}`, the upload endpoints, every brief transition, etc. all key off
`ctx.Zorgverlener().Bsn` / `ctx.Caller()` — an authenticated citizen (or, for the
uploads-content endpoint, a behandelaar) reading or writing only their own resource.
Calling these "public" in an allow-list would misrepresent exactly the property
BIO-004 was about — object-level authorization existing at all.
- **1 (`POST /zgw/notificaties`) uses a different mechanism entirely** — a fixed-time
shared-secret comparison for a non-Principal external caller (OpenZaak's
notifications), audited the same way but never going through `Authz`.
- **1 (`POST /brief/reset`) is deliberately, literally unguarded** — the endpoint's own
pre-existing comment says so ("No guards — showcase affordance only").
The allow-list (`RouteInventoryTests.AllowList`) keeps all 31 as one array for the test's
sake, but every entry carries its own reason string rather than a blanket "public" label —
preserving BIO-016's actual intent ("makes 'this endpoint is public' a decision someone
wrote down rather than an omission") generalised to "this endpoint's access boundary is
_X_, deliberately," which is true of all 31 and false of "public" for 20 of them. This is
recorded here rather than silently reinterpreted, per this task's brief: implementing the
literal "public" framing would have been actively misleading about which endpoints have no
access control at all.
## Other judgement calls
- **`AuthzGateMetadata` and its extension method are `public`, not `internal`.** The test
project has no `InternalsVisibleTo` wired up for `BigRegister.Api` (checked — none
exists anywhere in `backend/`), and adding one for a single marker type was more
machinery than the alternative. Both types carry a comment stating why.
- **A second test (`Every_gate_marker_names_a_known_admin_wrapper`) guards against a typo
in a `.Gate(...)` call.** Without it, a call like `.Gate("CasesAdmn")` would just fall
through to "unaccounted for" in the main test with a less specific failure message —
fine, but a dedicated assertion names the actual mistake.
- **The main test also asserts the reverse direction: no stale allow-list entries.** An
allow-list entry for a route that was renamed or removed is exactly the kind of drift
a "decision someone wrote down" ledger needs to catch, not just silently keep. Verified
this fires: temporarily added one extra `AllowList` entry for a route that doesn't
exist (via Edit, not committed) — every real route was still covered, so only the
stale-entry assertion tripped, naming exactly that bogus entry. Reverted the same way.
- **`RouteInventoryTests` uses the house `TestWebApplicationFactory` + `IClassFixture`
idiom**, not a bare `new WebApplicationFactory<Program>()` per test. The first draft did
the latter and immediately hit `SQLite Error 1: 'table "Applications" already exists'`
`Db.ConnectionString` (`Data/Db.cs`) is a shared mutable **static** field, and a bare
factory doesn't override `ConnectionStrings:AppDb`, so two such factories in the same
class end up pointed at the same file, and the second one's `Migrate()` collides with
the first's already-created tables (the first factory's default `Dispose()` doesn't
delete that file — only `TestWebApplicationFactory`'s override does, to its own
per-instance temp path). This is exactly the hazard `TestWebApplicationFactory`'s own
doc comment describes; switching to it (as every other endpoint-test class in this
suite already does) fixed it outright — no product code involved, purely a test-fixture
choice.
## Verification
- **Proved the test is hard to fool**, per the ticket's explicit ask: added a throwaway
`api.MapDelete("/rb12-throwaway-unguarded/{id}", …)` with no `.Gate(...)` and no
allow-list entry (via Edit, not `git checkout`) — `Every_mapped_route_is_authz_gated_or_
on_the_named_allow_list` failed red, naming exactly that route. Reverted the same way;
re-ran green. Repeated once more after switching to `TestWebApplicationFactory` to
confirm the fixture change didn't weaken the check — same red, same green.
- `dotnet build` (both `src/BigRegister.Api` and `tests/BigRegister.Tests`): clean, 0
warnings.
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
- `dotnet test --filter "Category!=Integration"`: **257 passed, 0 failed** (255
pre-existing + 2 new).
@@ -0,0 +1,186 @@
# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form
Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13
## What was wrong
ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated
`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker';
medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands."
Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow.
Verified before this ticket:
- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in
`libs/shared/src/infrastructure/role.ts:8`. No such type existed.
- `apps/ssp/src/app/auth/domain/session.ts` and
`apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical:
`interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar
carrying a `bsn`, which §3 names as precisely the state the union exists to make
unrepresentable.
- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw
persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` →
`DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de
Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD,
under a citizen's name.
- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already
stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of
`SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door
instead of the `Principal` union, which is why the two `auth` contexts still measured
as identical.
- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after
ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%),
`bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's
"auth stays duplicated because it's expected to diverge" claim had never actually been
tested, only asserted.
RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider`
able to say "no identity" and fail closed; this ticket is its stated FE half — without
it, a production behandelportal falls through to the seeded zorgverlener by default,
open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket
does not touch that backend behaviour — it makes the FE identity model honest about
who is actually authenticating.
## What changed
| File | Change |
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/ssp/src/app/auth/domain/session.ts``principal.ts` | `Session``Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession``parseStoredPrincipal` (G1/G2 unchanged) |
| `apps/ssp/src/app/auth/domain/session.spec.ts``principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape |
| `apps/ssp/src/app/auth/application/session.store.ts` | `Session``Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) |
| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` |
| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session``Principal` (the one other consumer of the domain type) |
| `apps/behandelportal/src/app/auth/domain/session.ts``principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) |
| `apps/behandelportal/src/app/auth/domain/session.spec.ts``principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) |
| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts``medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) |
| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it |
| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output<void>()` |
| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) |
| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) |
| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) |
| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure |
| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases |
## Judgement calls
- **Each app's `Principal` holds only the one variant it has an actor for**, not the
full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own
proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`:
replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener`
variant"), and it matches how the codebase already splits `auth` per app. `kind` stays
on both single-member types anyway — it is what makes the two types genuinely
different rather than a same-shaped coincidence, and it is where a third actor (§4 —
admin/auditor/institution-rep) would add a member.
- **`MedewerkerAdapter.authenticate()` returns `Promise<Principal>`, not
`Promise<Result<string, Principal>>`.** The first draft mirrored `DigidAdapter`'s
`Result`-returning shape for symmetry, but that `Result`'s error variant could never
actually be produced — there is no credential to check, so wrapping the return in a
type that claims to have a failure mode was itself a small instance of the thing
CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a
direct `Promise<Principal>` and dropped the now-dead error-handling branch from
`login.page.ts` (`error` signal, the `<app-alert type="error">`, the `AlertComponent`
import) — a real SSO integration is where that branch would come back, not before.
This was also the change that did the most to bring the duplication figure down (see
below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two
pages' control flow, not just their copy, actually differed.
- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen`
lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an
unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed
`Rol[]` is pure string logic with no Angular dependency, so it belongs in
`domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`)
stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and
hands them to a pure function. `parseRollen` deliberately mirrors the backend's own
`StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so
`?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule
(ADR-0001's boundary is about _authorization decisions_, which still come only from
`GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the
same header value it is about to send, for display, the same way `DigidAdapter`
already fabricates its own fake identity.
- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a
stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN")
doesn't apply here — a `medewerker` principal has no national identifier — so there is
nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and
restores it as-is. This was a deliberate choice against an alternative: reconstructing
`medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every
restore, which would have made `domain/principal.ts` depend on
`infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency
rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing
`?rollen=` mid-session does not retroactively change an already-restored `Principal`
until the next `login()`/`logout()` — the same way changing the DigiD demo BSN
requires a fresh login in the SSP. The backend's own authorization is unaffected
either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP
request regardless of what `SessionStore` holds.
- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left
unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside
`auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change,
not as something the change renames. `libs/shared/src/application/session.port.ts`'s
`SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and
`isAuthenticated`, neither of which is `kind`-dependent.
- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments
updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why
`?subject=` exists instead of reading the store directly; renaming the type these
comments describe without updating the comment would have left them citing a type
that no longer exists.
- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is
explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it
reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for
this ticket to change there.
- **No backend change.** RB-09 already made `IIdentityProvider` nullable and
Production-fail-fast; this ticket is purely the frontend counterpart it named. The
residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation
callers carrying no identity header once a real, non-stub `IIdentityProvider` exists)
is unaffected by anything here — it is about a _future_ real provider replacing the
Development-only stub, which this ticket does not touch.
## Duplication, measured (`tools/baseline-scan.mjs --dup`)
| When | `ssp/auth` dup lines | `bhp/auth` dup lines |
| ----------------------------------- | -------------------: | -------------------: |
| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — |
| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) |
| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** |
Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under
target. The full clone-pair listing (the script's own output truncates to the top 15
pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was
hiding below that cut) resolves to exactly four remaining pairs:
- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before
trusting a stored shape" concept with a parallel `describe`/`it` structure (including
the shared `import { describe, it, expect } from 'vitest';` line); the assertions
themselves differ (BSN-stripping vs. kind/rollen validation).
- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default`
scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject.
- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is
meant to stay identical.
- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small
shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root
singleton store in this codebase shares).
None of what remains is re-converged identity or login-flow logic — the domain type,
the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction
("the two groups authenticate differently") has been tested for the first time by this
ticket, not just asserted, and it held.
## Verification
Confirmed each non-trivial change is red without its fix (edited in place, verified red,
edited back — never `git checkout`):
- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''`
`G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …}
to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected.
- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'`
clause → `returns null when kind is not medewerker` failed, returning the parsed
zorgverlener-shaped object instead of `null`. Reverted.
- **bhp `parseRollen`:** dropped `.filter(isRol)``drops unrecognized tokens` and
`returns an empty list for an empty string` both failed (`['geen']`/`['']` returned
instead of `[]`). Reverted.
`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) +
133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs.
`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp
--localize` and `ng build behandelportal --localize`: both succeed (the new
`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro`
sources all resolve to an English `<target>`). `npm run ci`: green (see the commit this
doc ships with).
@@ -0,0 +1,57 @@
# RB-14 — scan the .NET dependency tree for known advisories
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016 · `99-backlog.md` RB-14
## What was wrong
`npm audit --omit=dev` gates the shipped frontend bundle. Nothing equivalent existed for the
backend, so **the entire .NET dependency tree — direct and transitive — was unscanned.** BIO-016
lists it first under "Absent".
## The trap the ticket walked into
The backlog row says: `dotnet list package --vulnerable --include-transitive` **as a failing
step**. Implemented literally, that step cannot fail. `dotnet list package --vulnerable` is a
_reporting_ command: it prints the advisory table and exits 0 regardless.
Verified rather than assumed — a throwaway project with `System.Net.Http 4.3.0`:
```
Project `vulntest` has the following vulnerable packages
> System.Net.Http 4.3.0 4.3.0 High https://github.com/advisories/GHSA-7jgj-8wvc-jh57
EXITCODE=0
```
A **High** severity advisory, exit code **0**. A bare `- run: dotnet list package --vulnerable`
would have added a line to `ci.yml` that reads like coverage in a compliance review and enforces
nothing — which is worse than leaving the gap visible.
## What changed
| File | Change |
| -------------------------- | -------------------------------------------------------------------------- |
| `scripts/dotnet-audit.sh` | **new** — runs the scan, matches its output, exits 1 on a hit |
| `.github/workflows/ci.yml` | new backend step calling the script (same `changes.outputs.backend` guard) |
| `scripts/ci-local.sh` | new `backend dependency audit` step calling the same script |
**One script, two callers**, rather than the same four lines pasted into a workflow and a shell
script that would then drift. The guard matches `has the following vulnerable packages` — the
exact sentence `dotnet list` prints per project on a hit; the clean case prints
`has no vulnerable packages given the current sources` instead.
## Verification
- Against the real solution: passes, both projects clean (exit 0).
- Against the marker sentence `dotnet list` actually emits: the guard fires and exits 1.
- The exit-0-on-High behaviour that motivates the whole script is reproduced above.
## Residual
`--include-transitive` means a vulnerable package pulled in by a dependency turns CI red with no
direct upgrade available. The fix in that case is a direct `PackageReference` pinning a patched
version; the script's failure message says so. There is deliberately **no severity threshold and
no suppression list** — adding one before a real advisory forces the question would be guessing
at a policy nobody has needed yet.
Secret scanning (gitleaks/trufflehog), BIO-016's other named absence, is **not** in this ticket
and remains on the pre-production checklist.
@@ -0,0 +1,93 @@
# RB-15 — Swagger and the OpenAPI document behind `IsDevelopment()`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-015 · `99-backlog.md` RB-15
## What was wrong
`Program.cs:145-146` (pre-change) ran `app.UseSwagger(); app.UseSwaggerUI();`
unconditionally — the full OpenAPI document (every route, every request/response shape)
and SwaggerUI's interactive "Try it out" were reachable in every environment, including a
real deployment, with no `app.Environment.IsDevelopment()` guard. BIO-015's own evidence
notes this is one line of genuine attack-surface reduction with no POC cost.
## What changed
| File | Change |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `Program.cs` | `app.UseSwagger(); app.UseSwaggerUI();` now run only inside `if (app.Environment.IsDevelopment()) { … }` |
| `tests/BigRegister.Tests/SwaggerGateTests.cs` | **new** — asserts `/swagger/v1/swagger.json` is served in Development and 404s outside it |
`builder.Services.AddSwaggerGen(...)` and `AddEndpointsApiExplorer()` were left
unconditional — they only register DI services (the swagger-generation machinery),
expose nothing over HTTP by themselves, and (see below) are exactly what `npm run
gen:api` depends on staying registered in every environment it might run against.
## The hazard, checked rather than assumed
RB-09 made a non-Development environment throw during `builder.Build()` (no
`IIdentityProvider` registered for a bare/unset environment, which defaults to
Production), which crashed `dotnet swagger tofile` until `package.json`'s `gen:api`
script was pinned to `ASPNETCORE_ENVIRONMENT=Development` for that one invocation
(`docs/.../implementation/rb-09.md`). This ticket's change sits in exactly the same
pipeline, so it needed the same empirical check, not an assumption.
**Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:**
`dotnet swagger tofile` (`Swashbuckle.AspNetCore.Cli`) loads the built DLL through
.NET's design-time `HostFactoryResolver`, builds the host, and then resolves
`ISwaggerProvider` **directly out of the DI container** to produce `swagger.json` — it
never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's
`if (app.Environment.IsDevelopment())` guard lives in. Gating `UseSwagger()`/
`UseSwaggerUI()` therefore cannot affect it, in any environment, by construction — those
are pipeline middleware; the CLI tool bypasses the pipeline entirely.
**Verified, not assumed:** ran `npm run gen:api` for real. It exited 0, printed "Swagger
JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag
client. `git status`/`git diff` on both `backend/swagger.json` and
`libs/shared/src/infrastructure/api-client.ts` showed **zero changes** — the regenerated
files are byte-identical to what's already committed, confirming the gate has no effect
on the generated contract at all.
## Judgement calls
- **The guard wraps both `UseSwagger()` and `UseSwaggerUI()` together**, not just one —
the ticket's own wording lists both, and gating only the document while leaving the UI
reachable (or vice versa) would be a strange half-measure: SwaggerUI without the
document 404s on load anyway, and the document without the UI still leaks the same
route/shape enumeration BIO-015 is about.
- **`AddSwaggerGen`/`AddEndpointsApiExplorer` were left unconditional.** They're
DI-registration-time calls with no HTTP surface, and — now confirmed rather than
assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever
environment it runs the host under (pinned to Development by `gen:api`'s own script,
but nothing stops a future non-Development invocation), so conditioning those
registrations on `IsDevelopment()` would risk breaking the CLI tool for no
attack-surface benefit — nobody can reach a DI-registered-but-never-routed service
over HTTP.
- **New tests build the "non-Development" case on a third environment name
("Staging"), not `"Production"`.** RB-09 already made Production fail at startup
entirely (no real `IIdentityProvider` exists yet) — a stronger guarantee than "no
Swagger in Production," but one that means a `UseEnvironment("Production")` host
never reaches this middleware to prove the gate itself works; it only proves RB-09's
unrelated startup throw, which already has its own test. A `"Staging"` environment
satisfies neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no
`IIdentityProvider` for it — the test supplies one via `ConfigureTestServices`
(`StubIdentityProvider`, the same one Development uses) so the host actually boots,
and the test exercises this ticket's real gate rather than a different ticket's.
- **The Staging host is built via `factory.WithWebHostBuilder(...)`** (layering on the
shared `TestWebApplicationFactory` fixture), not a bare `new
WebApplicationFactory<Program>()` — RB-12's implementation note already records the
"table already exists" collision a bare factory hits by sharing the mutable static
`Db.ConnectionString` instead of the fixture's own per-class isolated temp path;
layering avoids repeating that mistake here.
## Verification
- **Reverted the guard only** (unwrapped `UseSwagger()`/`UseSwaggerUI()` back to
unconditional, via Edit, tests left in place) and ran `SwaggerGateTests`:
`Swagger_document_is_not_served_outside_development` failed red (`Expected: NotFound,
Actual: OK`). Restored the fix (via Edit) and re-ran: both green.
- **`npm run gen:api`, run for real**: exit 0; `backend/swagger.json` and
`libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean on
both) — see "The hazard" above.
- `dotnet build` (both projects): clean, 0 warnings.
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
- `dotnet test --filter "Category!=Integration"`: **259 passed, 0 failed** (257 + 2 new).
@@ -0,0 +1,82 @@
# RB-16 — `DateOnly.TryParse` on `?peildatum=`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-019 · `99-backlog.md` RB-16
## What was wrong
`Program.cs:215` (pre-change) —
`var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`.
`DateOnly.Parse` throws `FormatException` on anything unparseable; there was no
`TryParse`, no 400 path, and `.Produces` on the endpoint declared only 200/403/404 — so
an unparseable `?peildatum=` value 500'd, and in Development the exception detail was
returned to the caller. §3c's baseline named `backend/Stamdata` 96.8% line but **71.7%
branch** (BL-005) — this was one of the unentered branches.
## What changed
| File | Change |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Program.cs``GET /stamdata/{table}` | `DateOnly.Parse` replaced with `DateOnly.TryParse`; an unparseable value now returns `Results.Problem(detail: …, statusCode: 400)` instead of throwing; endpoint doc gained `.ProducesProblem(StatusCodes.Status400BadRequest)` |
| `tests/BigRegister.Tests/StamdataEndpointTests.cs` | **new** `Unparseable_peildatum_is_400_not_500` |
| `backend/swagger.json`, `libs/shared/src/infrastructure/api-client.ts` | regenerated (`npm run gen:api`) — the new 400 response is now part of the documented contract |
## What the fix looks like
```csharp
DateOnly? peildatumWaarde = null;
if (peildatum is { Length: > 0 } p)
{
if (!DateOnly.TryParse(p, out var parsed))
return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest);
peildatumWaarde = parsed;
}
var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows();
```
Matches the shape every other bad-input check in this file already uses (e.g. the
upload endpoint's `Results.Problem(detail: …, statusCode: 400)` for a malformed
multipart request) — a `Results.Problem` with a Dutch detail message, not a bespoke
response shape.
## Judgement calls
- **No FE change needed, and none made.** `libs/beheer/src/infrastructure/
stamdata.adapter.ts`'s `load()` already routes every call through `runSubmit`
(`libs/shared/src/application/submit.ts`), which try/catches any thrown
`ApiException` — including the client's new 400 branch — into a generic `Result`
error string via `problemDetail`. There is no status-code-specific branching to
extend; ADR-0001's "the FE renders the decision, it does not recompute the rule"
already covers "the server rejected this input" as a case the generic error path
handles, same as the existing 403.
- **Regenerated the API client and committed it in this ticket's diff**, rather than
leaving it to drift. RB-09's implementation note records a real prior incident where
a response-shape change (RB-08's 403 → `ProducesProblem`) landed without a
regeneration and the drift went unnoticed until the next ticket's `gen:api` run. This
ticket's `.ProducesProblem(400)` is exactly that same category of change, so
`npm run gen:api` was run immediately as part of implementing it, not deferred.
- **The Dutch detail message follows the file's own convention** (`$"Ongeldige
peildatum '{p}'."`) rather than English — every other `Results.Problem(detail: …)`
call in `Program.cs` (change-request rejection, upload validation, submit rejection)
is Dutch; this is server-internal wire text, not `$localize`-wrapped UI copy (the FE
never renders it verbatim — CLAUDE.md's `$localize` rule is about user-facing copy
the FE owns, not backend `ProblemDetails.detail` strings), so no locale entry was
needed.
## Verification
- **Reverted the fix only** (`DateOnly.Parse` restored, ternary un-nested, via Edit —
test left in place) and ran `StamdataEndpointTests`:
`Unparseable_peildatum_is_400_not_500` failed red (`Expected: BadRequest, Actual:
InternalServerError` — confirming the endpoint really did 500, not some other status).
Restored the fix (via Edit) and re-ran: all 6 tests in the class green.
- `dotnet build`: clean, 0 warnings.
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
- `dotnet test --filter "Category!=Integration"`: **260 passed, 0 failed** (259 + 1
new).
- `npm run gen:api`: exit 0; `backend/swagger.json` gained the 400 response shape for
this one endpoint; `libs/shared/src/infrastructure/api-client.ts` gained the
matching `status === 400` branch. Both regenerated files committed alongside the
code change.
- `npm test` (all four Vitest projects — ssp/behandelportal/shared/beheer): **445
passed, 0 failed**, confirming the regenerated client doesn't break any existing FE
consumer of `stamdataTable(...)`.
@@ -0,0 +1,130 @@
# RB-17 — split `runResult` (fold) from `runSubmit` (fold + idempotency mint)
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-003 + CQ-005 ·
`00-baseline.md` BL-007 (+ its §10 amendment) · `99-backlog.md` RB-17
## What was wrong
`runSubmit` (`libs/shared/src/application/submit.ts`) did two things in one function: fold a
call into a `Result`, and mint an Idempotency-Key for it
(`withIdempotencyKey(crypto.randomUUID(), fn)`). Its own docstring called that mint "the one
place a logical submit's Idempotency-Key is minted". Five call sites are reads and had no
business minting one:
| Adapter | Method | Wire call |
| ---------------------------- | -------------------- | --------------------- |
| `brief.adapter.ts:56` | `load()` | `briefGET()` |
| `org-template.adapter.ts:42` | `list()` | `orgTemplates()` |
| `org-template.adapter.ts:54` | `load(subOrgId)` | `orgTemplateGET(...)` |
| `stamdata.adapter.ts:27` | `list()` | `stamdataTables()` |
| `stamdata.adapter.ts:42` | `load(tableId, ...)` | `stamdataTable(...)` |
That is **exactly five** — verified by grepping every `runSubmit` call site in
`libs/shared/src/application` plus the `brief` and `beheer` scopes (13 call sites total) and
reading each one's wire call for a request body / non-GET verb. The other 8 are genuine
writes (`brief.adapter.ts` save/submit/approve/reject/send/reset,
`org-template.adapter.ts` save/publish/rollback) and stay on `runSubmit` unchanged.
`stamdata.adapter.ts`'s own module docstring already said "Both endpoints are reads … There
is no write method" while both called `runSubmit` — the sharpest instance of the mismatch,
and the one BL-007's original "~13 mutations" count mis-classified because the count was
derived from the helper's name, not from what the call actually does.
**Not this ticket, seen while auditing:** `ApplicationsStore.cancel`, `AdminCasesStore.delete`
(RB-20) and `FeatureFlagStore.set` reach `ApiClient` more directly; the baseline's §10
amendment flags these as writes the original "~13" count missed. Grepping confirms
`FeatureFlagStore.set` (`libs/shared/src/application/feature-flags.store.ts:63`) already
calls `runSubmit` correctly and returns a `Result` — it is not broken, just outside this
ticket's five. `ApplicationsStore.cancel`/`AdminCasesStore.delete` were not touched; they are
RB-20's.
## What changed
| File | Change |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `libs/shared/src/application/submit.ts` | Split: `runResult` = the try/catch + `problemDetail` fold, no mint. `runSubmit` = `runResult` wrapping `withIdempotencyKey`. |
| `libs/shared/src/application/submit.spec.ts` | Specs for both, including one that would catch a read minting a key again (see below). |
| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()``runResult`; docstring updated to name both halves. |
| `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts` | `list()`, `load()``runResult`. |
| `libs/beheer/src/infrastructure/stamdata.adapter.ts` | `list()`, `load()``runResult`; import trimmed to `runResult` only (no writes in this file). |
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `runResult` describe block and the extra `runSubmit` case. |
`runSubmit`'s new body is exactly the minimal composition the ticket asked for:
```ts
export function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback);
}
```
Zero behaviour change for the 8 write call sites — same fold, same mint, same timing (the key
is still minted before `fn` runs and cleared in `withIdempotencyKey`'s `.finally`). The five
reads now run the fold with no `pendingIdempotencyKey` touched at all.
## The spec that would catch a regression
`currentIdempotencyKey()` (`api-client.provider.ts`) returns the pending key while one is
"in flight" for the duration of a `withIdempotencyKey` call, and a fresh `crypto.randomUUID()`
on every call otherwise. That gives a real, mock-free way to assert "no key was minted": call
`currentIdempotencyKey()` twice inside the function passed to `runResult`/`runSubmit` — two
different reads means no pending key existed (each fell back to its own random UUID); two
equal reads means one pending key was minted and reused.
```ts
it('mints no Idempotency-Key — the read fold', async () => {
let first = '',
second = '';
await runResult(async () => {
first = currentIdempotencyKey();
second = currentIdempotencyKey();
return 'x';
}, 'fallback');
expect(first).not.toBe(second);
});
```
This mirrors the house convention of not mocking relative imports under this repo's
Angular/vitest setup (see `role.interceptor.spec.ts`'s comment) — it asserts on real,
exported behaviour instead of a spy.
**Verified red without the fix**: temporarily changed `runResult` to also call
`withIdempotencyKey` (i.e. reintroduced the bug it exists to prevent) and reran `ng test
shared`. Result: `runResult > mints no Idempotency-Key — the read fold` failed
(`expected 'd185d827-...' not to be 'd185d827-...'`), all 137 other tests stayed green. Then
reverted the temporary edit back to the real fix (an `Edit` undo, not `git checkout`, so the
rest of the change stayed in place) and reran — 138/138 green.
## Judgement calls
- **Docstring on `brief.adapter.ts`** was rewritten (it previously said only "Mutations go
through `runSubmit`") to name `load`'s `runResult` path explicitly, since the file mixes
both now and a future reader needs the split spelled out at the top, not just per-method.
`org-template.adapter.ts` and `stamdata.adapter.ts`'s docstrings needed no change — neither
named `runSubmit` specifically (`stamdata.adapter.ts`'s already correctly said "no write
method").
- **No new concept, per the ticket's "minimal" framing**`runSubmit` stays exported with
the same signature and the same call sites for the 8 real mutations; only its body changed
to delegate.
- **Left `runResult`'s JSDoc pointing at `runSubmit`** ("never route a read through that
one") rather than duplicating the Idempotency-Key explanation, so the two docs stay
synchronized by cross-reference instead of by copy.
## Residuals (not this ticket)
- RB-18 (key the `IdempotencyStore` on `{SubjectId}:{idemKey}`) is sequenced behind this one
per `99-backlog.md` and is unaffected by this split beyond it now landing on a correctly
write-only call set.
- RB-20 (`ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`) is
untouched, as scoped.
## Verification
`npm run ci` (foreground): **green** — lint, typecheck, `dep:check` (341 + 226 modules, 0
violations), `format:check`, `check:tokens`, `check:seam`, tests (ssp 258/258, behandelportal
31/31, shared 138/138, beheer 23/23 — 450 total), `ng build --localize` (both apps), `npm
audit` (0 vulnerabilities), backend `dotnet test` (255/255 — the known
`OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not reproduce on
this run), `gen:snippets` drift clean, `gen:behaviour-spec` drift clean once the regenerated
file is committed alongside the code (the local gate compares the working tree to `HEAD`, so
it necessarily shows a diff pre-commit — this is the documented "will conflict at merge time"
behaviour, not a defect).
@@ -0,0 +1,115 @@
# RB-18 — key `IdempotencyStore` on `{SubjectId}:{idemKey}`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-018 ·
`00-baseline.md` §7 (`IdempotencyStore` listed among the 7 stores "Not behind any port"),
agent 02's `backend/Data` note ("no `Reset()` and no TTL") · `99-backlog.md` RB-18
## What was wrong
`Data/IdempotencyStore.cs` is a process-global `Dictionary<string, IResult>` keyed only on
the raw `Idempotency-Key` header value. `Program.cs`'s `Submit` helper read and wrote it
with that raw value, never composed with the caller's identity:
```csharp
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
? k.ToString()
: null;
```
The client picks the header value. Two different callers who happen to send the same
value shared one cache slot: the second caller's request short-circuited to the first
caller's cached `IResult` instead of running its own submission. BIO-018 rates this
**severity low** — the cached value is only a `ReferentieResponse` (a reference number) or
a `ProblemDetails`, never personal data — but flags it as a defect in an access-control
path with a trivial fix.
**Location check against the finding.** BIO-018 cites `Program.cs:901-909` for the read/
write and `Data/IdempotencyStore.cs:11-27` for the store. RB-17 (landed the day before,
same file, unrelated change) shifted line numbers; the real call sites are
`Program.cs:994` (read) and `:1028` (write), inside the local `Submit` helper starting at
`:991`. The store file itself is untouched by RB-17 and matches the finding's shape
exactly. `Submit` has exactly one call site (`POST /change-requests`, `:239`) — the
`ChangeRequestRequest``telefoonwijziging` endpoint — so the scoping change lands on a
single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to
produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used
this helper in the first place, so its ordering benefit does not change what this ticket
touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped
its residual to "this ticket is unaffected by this split beyond it now landing on a
correctly write-only call set" — true, and the call set was already this one endpoint
before and after RB-17.
## What changed
| File | Change |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/BigRegister.Api/Program.cs` | `Submit`'s `idemKey` is now `$"{ctx.Caller().SubjectId}:{k}"` instead of the raw header value `k.ToString()`; doc comment above `Submit` states the scoping and cites RB-18/BIO-018 |
| `tests/BigRegister.Tests/IdempotencyTests.cs` | **new** `A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result` |
`ctx.Caller()` (`Domain/Authorization/CallerIdentity.cs`) is already in scope in
`Program.cs``ctx.Zorgverlener()` is used elsewhere in the same file — and it throws if
the identity middleware did not run, so this composition cannot silently fall back to an
unscoped key. `SubjectId` is the BSN for a `ZorgverlenerCaller` and the medewerkerId for a
`MedewerkerCaller`; either way it is stable per caller and never empty.
This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound,
no `Reset()`, no port/interface extraction. `IdempotencyStore`'s own `ponytail:` comment
("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory
leak at scale") is untouched — the store is still unbounded and still keyed on a
client-supplied string, only now composed with a server-resolved one first. The comment
stays accurate; this ticket did not touch the part it would need to correct.
## The test
`IdempotencyTests.cs` already existed (RB-17's predecessor work, not this ticket) with
three cases exercising same-caller replay/independence. Added a fourth:
```csharp
[Fact]
public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
{
var sharedKey = Guid.NewGuid().ToString();
var callerARequest = ChangeRequestWithKey(sharedKey);
callerARequest.Headers.Add("X-Subject", "111222333");
var callerA = await _client.SendAsync(callerARequest);
callerA.EnsureSuccessStatusCode();
var callerABody = await callerA.Content.ReadFromJsonAsync<ReferentieResponse>();
var callerBRequest = ChangeRequestWithKey(sharedKey);
callerBRequest.Headers.Add("X-Subject", "999888777");
var callerB = await _client.SendAsync(callerBRequest);
callerB.EnsureSuccessStatusCode();
var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
}
```
`X-Subject` is `StubIdentityProvider`'s existing header for setting the caller's BSN in a
test (the same idiom `ApplicationTests.cs` and `UploadAccessTests.cs` use), so caller A and
caller B are two different `ZorgverlenerCaller`s sending the identical `Idempotency-Key`.
**Verified red without the fix.** Reverted `Program.cs`'s `idemKey` line to
`k.ToString()` with an `Edit` (not `git checkout`, so the rest of the working tree stayed
intact), reran `dotnet test --filter "FullyQualifiedName~IdempotencyTests"`:
```
Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms]
Error Message:
Assert.NotEqual() Failure: Strings are equal
Expected: Not "BIG-2026-476969"
Actual: "BIG-2026-476969"
Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4
```
Caller B received caller A's cached reference. Then reapplied the fix with a second
`Edit` and reran: `Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4`.
## Verification
`dotnet test` (full suite): **261 passed, 1 failed** — the failure is
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
which needs a live OpenZaak container and fails identically on a stashed tree; it predates
this change and is not run by `npm run ci`.
`npm run ci` (foreground): green — see the commit's own record for the full step list.
@@ -0,0 +1,220 @@
# RB-19 — reorder `Program.cs`: reads before writes per section, regroup admin-cases + org-template preview
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-006 ·
`99-backlog.md` RB-19 · Depends on `implementation/rb-12.md` (the route-table test this
ticket leans on as its regression net)
This is a **pure reorder**. No route, signature, DTO, or handler-body text changed. The
sorted list of `HTTP METHOD + path` mapping calls is byte-identical before and after (see
"Verification" below) — that identity is the strongest evidence this ticket did what it
says and nothing else.
## What was wrong
CQ-006, verbatim: `Program.cs` opens by declaring direction as its organising principle
(a "GET: screen-shaped reads" banner, then a "POST: submits" banner), then from the
Document-upload section onward switches to feature grouping without saying so, and every
subsequent section interleaves reads and writes. One feature (Beoordeling/Besluit, WP-65)
already got the fix — a `:441`/`:464`-style banner pair splitting its query endpoint from
its command endpoint — and CQ-006 asks for the same treatment on the five sections that
predate that pattern: Document upload, Applications, Admin cases, Brief, and Organization
templates. Separately, `DELETE /admin/cases/{id}` sat 129 lines away from `GET
/admin/cases`, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in
between; and `GET /admin/org-template/{subOrgId}/preview` was filed under the Brief
section's banner instead of the Org-templates section it actually belongs to.
## What changed
| Section (banner) | Before | After |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Document upload | categories, **POST /uploads**, content, status, DELETE, admin-DELETE | categories, content, status, `--- reads ---`/`--- writes ---` sub-banners, **POST /uploads** moved after the reads, DELETE, admin-DELETE |
| Applications | already reads-first (2 GETs, then POST/PUT/DELETE/POST-submit) | unchanged order; sub-banners inserted only |
| Admin cases | GET /admin/cases, _(werkvoorraad/beoordeling/besluit/zgw-notificaties in between)_, **DELETE /admin/cases/{id}**, **GET /admin/audit** | GET /admin/cases, **GET /admin/audit** (moved up), `--- writes ---`, **DELETE /admin/cases/{id}** (moved up) — all three now contiguous; werkvoorraad/beoordeling/besluit/zgw-notificaties follow, unmoved and unchanged |
| Brief | GET /brief, PUT, submit, approve, reject, send, reveal-bignummer, **GET /brief/preview**, _(org-template preview)_, POST /reset | GET /brief, **GET /brief/preview** (moved up beside GET /brief), `--- writes ---`, PUT, submit, approve, reject, send, reveal-bignummer, POST /reset — org-template preview removed from this section |
| Organization templates | list, detail, PUT, publish, rollback | list, detail, **GET /admin/org-template/{subOrgId}/preview** (moved in from Brief), `--- writes ---`, PUT, publish, rollback |
Every section above got a `// --- reads ---` / `// --- writes ---` sub-banner pair
(matching the short, bare form already used at the file's top-level `:170`/`:236`
banners) inserted at the reads→writes boundary. Werkvoorraad, Beoordeling and Besluit —
not named by CQ-006 as mixed, and already correctly split (Beoordeling is the read,
Besluit is the write, each with its own WP-65 banner) — were left exactly as they were,
including their absolute position relative to each other; only the block ahead of them
(admin-cases) grew, pushing their line numbers down without touching their content.
`backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` were
regenerated (`npm run gen:api`) and are part of this commit — see "The regenerated pair"
below.
## Design: line-range slicing, not manual retyping
Every moved block was cut with a Python script operating on exact 1-indexed line ranges
against the file as it stood after merging in `refactor/adr-c-006-shared-route-guards`
(this branch's actual base — see "Base commit" below), then reassembled in the new order.
No handler body was retyped by hand. This is the same guarantee the ticket's "cut/paste,
not retype" instruction asks for, made structural rather than a promise to be careful:
a line-range slice cannot silently change a character inside a block it does not touch.
The script is not part of this commit (a one-shot tool, not project code); the diff it
produced is what is being reviewed.
## Judgement calls
- **Sub-banner wording is bare `// --- reads ---` / `// --- writes ---`, not prose
matching WP-65's descriptive style.** The ticket asks for ":441/:464-style" banners;
WP-65's actual banners are long, feature-specific paragraphs ("read side only
(recording a decision is WP-65's second half)…"). Inventing five more paragraphs like
that would mean writing new explanatory prose about code this ticket is not meant to
re-explain — CQ-006 is explicit that this is "a structure finding, not a correctness
one," and the ticket itself forbids "no fixed comments beyond the banners this ticket
adds." The file's own top-level banners (`:170` "GET: screen-shaped reads", `:236`
"POST: submits") already establish a bare, label-only banner as a legitimate style in
this exact file — the sub-banners here are that same style, nested one level deeper.
- **Werkvoorraad/Beoordeling/Besluit end up sandwiched between Admin-cases and
zgw/notificaties, in that order, unmoved.** Moving `GET /admin/audit` and `DELETE
/admin/cases/{id}` up next to `GET /admin/cases` (as instructed) necessarily pushes
everything that used to sit between them — werkvoorraad, beoordeling, besluit,
zgw/notificaties — down, but does not reorder those four relative to each other. They
were not named as mixed by CQ-006 and were not touched beyond their line numbers
changing.
- **`GET /brief/preview` and `POST /uploads` are both `.ExcludeFromDescription()`-marked
(hand-written FE `fetch`/XHR calls, never through the generated client) — moving them
produced zero diff in `swagger.json`.** This is not a coincidence being reported as
one: an excluded endpoint has no OpenAPI operation to reorder in the first place, so
the regenerated pair's diff below is smaller than "every moved route" might suggest —
it only shows the two endpoints that are both documented and reordered relative to
each other (`GET /admin/audit`, `DELETE /admin/cases/{id}`).
- **No handler types, no `Features/` folder, no mediator** — out of mandate per CQ-006's
own text (filed separately as OOM-A) and the ticket's explicit "out of scope" section.
Nothing beyond comments and mapping order changed.
## Base commit
Step zero's warning matched this worktree's actual starting state: `git log --oneline -8`
showed `ae7781e` at HEAD, not `edd20c0`, and `edd20c0 docs(backlog): mark RB-23 done after
merge` was absent from the log entirely — the bad-base lineage named in the ticket. `git
merge refactor/adr-c-006-shared-route-guards` was run, after which `edd20c0` appeared as
`HEAD~0`'s direct ancestor and every RB-01..RB-23 commit was present. All work in this
ticket happened after that merge.
## Verification
**The sorted-route-list diff (the key evidence).** Extracted every `.Map(Get|Post|Put|
Delete)("...")` call from `Program.cs` before and after, sorted each list, and diffed
them:
```
$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs.before-reorder | sort > before.txt
$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs | sort > after.txt
$ diff before.txt after.txt
$ echo "exit=$?"
exit=0
$ wc -l before.txt after.txt
47 before.txt
47 after.txt
```
Empty diff, same count (47 `api.Map*` calls — the two `app.MapGet` health probes are
outside the `/api/v1` group and were never in scope for this reorder; they were untouched
either way). The set of routes is provably unchanged.
**`.Gate(...)` count, before/after, by wrapper name:**
```
3 .Gate("Beoordelen")
4 .Gate("CasesAdmin")
1 .Gate("FlagsAdmin")
6 .Gate("OrgAdmin")
2 .Gate("StamdataAdmin")
```
Identical in both directions — no gate call was added, removed, or renamed.
**Per-route eyeball check of every route that changed position, per RB-12's stated
limitation** (the route-table test only proves a `.Gate(...)` marker is present, not that
it still names the wrapper the handler body actually calls):
| Route | Moved | `.Gate(...)` after | Wrapper actually called inside the handler | Match |
| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------- | ------------------------------------------ | ----- |
| `GET /admin/audit` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => ...)` | yes |
| `DELETE /admin/cases/{id}` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => { ... })` | yes |
| `GET /admin/org-template/{subOrgId}/preview` | Brief section → Org-templates section | `OrgAdmin` | `OrgAdmin(ctx, () => { ... })` | yes |
| `POST /uploads` | within Document-upload, past the three reads | _(none — allow-listed, ownership-scoped)_ | — | n/a |
| `GET /brief/preview` | within Brief, up beside `GET /brief` | _(none — allow-listed, ownership-scoped)_ | — | n/a |
| `GET /uploads/{documentId}/content` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a |
| `GET /uploads/status` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a |
Five routes were deliberately relocated by this ticket; two more shifted position only as
a byproduct of `POST /uploads` moving past them (their own order relative to each other
is unchanged). All three gated routes among these were checked by eye against the
handler body they wrap, not just against `RouteInventoryTests`' marker check — all three
match.
**`RouteInventoryTests`:**
```
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 770 ms
```
Both `Every_mapped_route_is_authz_gated_or_on_the_named_allow_list` and
`Every_gate_marker_names_a_known_admin_wrapper` pass.
**Full backend suite:** `dotnet test --filter "Category!=Integration"` — **262/262
passing**, plus the one known, pre-existing, container-dependent failure
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`),
which does not run under `npm run ci` and reproduces on a clean tree with no OpenZaak
container running — not this ticket's bug.
**`dotnet build`** (both projects): 0 warnings, 0 errors. **`dotnet format
BigRegister.slnx --verify-no-changes`**: clean.
**No new test was added.** Per the ticket's Definition of Done: this is a zero-semantic-
change commit, and §3c's pre-existing 97.4% line / 84.8% branch coverage of `Program.cs`
is the regression net CQ-006 itself names. Nothing about this diff needs a new test to be
trustworthy — a passing pre-existing suite plus an empty sorted-route diff is stronger
evidence for "nothing changed" than a new test asserting the same thing would be.
## The regenerated pair
`npm run gen:api` was run after the reorder. It produced a diff in both
`backend/swagger.json` (2 hunks) and `libs/shared/src/infrastructure/api-client.ts` (5
hunks) — both **pure reordering, zero content change**. Confirmed by sorting every line of
each file (before vs. after) and diffing the sorted output: empty in both cases. The only
two OpenAPI paths that moved position in the document are `/api/v1/admin/audit` and
`/api/v1/admin/cases/{id}` — the two documented (non-`ExcludeFromDescription`) endpoints
this ticket actually reordered relative to their OpenAPI-document neighbours; the
generated client's `audit()`/`cases()` methods and their `process*` helpers moved by the
same amount, unchanged in every other respect (parameters, return types, status-code
branches, JSDoc). Both regenerated files are committed alongside `Program.cs`, per the
ticket's explicit instruction: "if the only change is ordering inside swagger.json, say
so explicitly and commit the regenerated pair rather than leaving CI's drift job to
fail."
**`npm run ci`**: every job through "backend dependency audit" passed before this
ticket's files were committed; the one job that legitimately failed pre-commit was "api-
client drift" (`git diff --exit-code` against the not-yet-committed regenerated files —
expected, since that step compares the working tree to `HEAD`, and `HEAD` still had the
pre-reorder client at that point). After committing, `npm run ci` was re-run to confirm a
clean, fully green result against the committed tree — see the final PASS/exit-code
reported in this ticket's closing message.
## What a reviewer should check
This diff is too large to read top-to-bottom without guidance. The fastest way to review
it with confidence:
1. **Trust the sorted-route diff, not a manual read of every hunk.** The "Verification"
section above shows the set of `HTTP METHOD + path` strings is byte-identical before
and after. If you want to reproduce it yourself: check out this commit's parent,
extract the same `grep -oE` pattern from both revisions of `Program.cs`, sort, diff.
2. **Spot-check the five per-route table entries above**, not the whole file — those are
the only routes whose position (and, for three of them, gate-vs-handler match)
actually matters for this ticket's correctness claim.
3. **Diff `git show <this-commit> -- backend/src/BigRegister.Api/Program.cs` with
whitespace-insensitive word diff** (`git diff -w --color-words`) if you want to
confirm no character inside a moved handler body changed — the line-range-slicing
approach in "Design" above makes this a formality rather than a real risk, but it is
cheap to re-check.
4. **Do not expect Werkvoorraad/Beoordeling/Besluit/zgw-notificaties to have moved
position relative to each other** — only their absolute line numbers shifted, as a
side effect of the admin-cases block growing above them.
5. **The regenerated `swagger.json`/`api-client.ts` diff is expected and pre-verified as
ordering-only** (sorted-file diff is empty) — it does not need a second manual read.
@@ -0,0 +1,117 @@
# RB-20 — route `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`, surface the error
Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-002 ·
`00-baseline.md` BL-007 · `99-backlog.md` RB-20 · SIGN-OFF: consolidation approved
2026-08-27, HALT lifted
## What was wrong
`ApplicationsStore.cancel` and `AdminCasesStore.delete` both owned an optimistic write next
to their `RemoteData` read, and both reached `ApplicationsAdapter` directly instead of going
through `runSubmit` (the fold + Idempotency-Key mint every other mutation in the repo uses,
including `createSubmitChangeRequest` in the same folder). The failure path was a bare
`catch { this.state.set(before); }`: a failed cancel or delete rolled the row back, but the
user saw no message at all — no `ActionState`, no ProblemDetails `detail`, nothing. The
`Idempotency-Key` on the wire was also a fresh UUID per HTTP attempt (minted by
`api-client.provider.ts`'s default), not the per-logical-submit key `runSubmit` promises —
harmless today only because `Program.cs` happens to ignore the header outside the `Submit`
helper (CQ-005's note).
## What changed
CQ-002's option (a) — the smallest fix, applied identically to both stores. No new command
factory, no adapter split (CQ-002's own "Not filed" note reserves that split for option (b),
which this ticket does not take).
| File | Change |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/ssp/src/app/registratie/application/applications.store.ts` | `cancel` now calls `runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED)`; added a private `error` signal, exposed read-only as `lastError`. On failure: roll back AND `this.error.set(r.error)`. On the next attempt, the error is cleared before the call so a stale message never survives a fresh action. |
| `apps/ssp/src/app/registratie/application/applications.store.spec.ts` (new) | 4 specs: load+parse, optimistic cancel, roll-back-and-surface-error on failure, stale-error-clears-on-next-attempt. No spec file existed for this store before RB-20. |
| `apps/ssp/src/app/registratie/application/admin-cases.store.ts` | Same shape as `applications.store.ts`: `delete` through `runSubmit`, `error`/`lastError` signal pair. |
| `apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts` | Existing "rolls back … when the delete fails" spec extended to also assert `lastError()`; one new stale-error-clears spec added. |
| `apps/ssp/src/app/registratie/ui/dashboard.page.ts` | One `@if (cancelError(); as err) { <app-alert type="error">{{ err }}</app-alert> }` above the aanvragen list, mirroring `brief.page.ts`'s `lastError` rendering. `cancelError` is a `computed(() => this.apps.lastError())`. |
| `apps/ssp/src/app/registratie/ui/admin-cases.page.ts` | Same `@if (store.lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }`, placed above `<app-async>` inside the `canManage()` branch (`store` was already `protected`, so no new exposure needed). |
`applications.adapter.ts` (`cancel`, `deleteAny`) is **unchanged** — the fix is entirely in
the two stores, which now wrap the existing thin adapter calls in `runSubmit` at the call
site, exactly as `createSubmitChangeRequest` wraps `ChangeRequestAdapter.changeRequest`. The
adapter methods still return a bare `Promise<void>`; `runSubmit` is what folds that into a
`Result`.
Neither UI change introduces a new user-facing string: the rendered text is either the
existing `SUBMIT_FAILED` constant (`@@submit.failed`, already translated in
`messages.en.xlf` since RB-17) or, when the backend sends one, a ProblemDetails `detail`
string carried verbatim from the server — never a new `$localize` id. `messages.en.xlf` did
not need a new `<target>`.
## The tests, and their red failures
Both specs assert `store.lastError()` after a rejected adapter call, which only the fix can
satisfy — the old bare `catch { this.state.set(before) }` never touched an error signal, so
`lastError()` stayed `null` forever.
**Verified red without the fix** (an `Edit` undo of the store method, not `git checkout`, so
the rest of the change — imports, the other store, the UI, the specs — stayed in place):
- `applications.store.ts`: reverted `cancel` to `try { await this.adapter.cancel(id); } catch
{ this.state.set(before); }`. Reran `ng test ssp --include applications.store.spec.ts`:
2 of 4 failed —
`rolls back the removal and surfaces the error when the cancel fails` and
`clears a stale error on the next cancel attempt`, both with
`AssertionError: expected null to be 'Het indienen is niet gelukt. Probeer het later opnieuw.'`.
The other two specs (load, optimistic-cancel-success) stayed green, as expected — they
don't touch the error path. Re-applied the fix (`Edit` back to the `runSubmit` version);
reran: 4/4 green.
- `admin-cases.store.ts`: same procedure on `delete`. Reran
`ng test ssp --include admin-cases.store.spec.ts`: 2 of 4 failed with the identical
`expected null to be '...'` shape. Reverted to the fix; reran: 4/4 green.
## Judgement calls
- **Signal naming**: private backing field `error`, public readonly `lastError` — matching
the name `BriefStore`/`OrgTemplateStore` already expose for exactly this purpose (CQ-002's
own citation), rather than inventing a new name per store.
- **Error cleared at the start of each write**, not only on success, so a second cancel/delete
attempt after a failure doesn't leave a stale banner up if the retry itself is still in
flight. Covered by the "clears a stale error on the next attempt" spec in each file.
- **No `ActionState`/`SaveState` pair** (the fuller shape `BriefStore` uses for busy-state and
save-state together) — CQ-002 explicitly scoped option (a) to "one `error` signal", and
neither store needs a busy indicator: the row already disappears optimistically the instant
the click happens, so there is nothing for a spinner to cover.
- **UI placement**: one alert per page, above the list the mutated row belongs to, using the
same `@if (x(); as err) { <app-alert type="error">{{ err }}</app-alert> }` shape as
`brief.page.ts` — composition of an existing atom, no new building block (CLAUDE.md §2).
- **`applications.adapter.ts` left untouched, on purpose** — CQ-002's "Not filed" note ties
the read/write file split to option (b) only; taking option (a) means this ticket changes
no adapter code at all, matching the ticket's own framing ("(a) touches 2 files plus a UI
line each").
## Ticket accuracy
CQ-002's description matched the code as found: both stores' `cancel`/`delete` reached the
adapter directly with a bare `catch { this.state.set(before); }`, no `Result`, no error
channel — no discrepancy to flag.
## Residuals (not this ticket)
- RB-18 (key `IdempotencyStore` on `{SubjectId}:{idemKey}`) is unaffected: `cancel`/`delete`
now mint a key through `runSubmit` like every other mutation, so it lands on the same
write-only call set RB-18 already targets.
- RB-21 (extract `createDraftSync`'s read half) is a separate CQRS-light finding in the same
context, untouched by this ticket.
## Verification
`npm run ci` (foreground, `timeout: 600000`): **green**`✔ local CI passed`. Lint,
typecheck, `dep:check` (342 + 226 modules, 0 violations), `format:check`, `check:tokens`,
`check:seam`, tests (ssp 263/263 — 5 more than the pre-RB-20 258, from the new/extended
specs above — behandelportal 37/37, shared 138/138, beheer 23/23), `ng build --localize`
(both apps), `npm audit` (0 vulnerabilities), backend `dotnet format --verify-no-changes` +
`dotnet test --filter "Category!=Integration"` (260/260 — this filter is what keeps the
known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent test, which needs a live
OpenZaak container, out of `npm run ci` entirely; it is a standing caveat, not introduced by
this change, and not exercised by this run), backend dependency audit (0 vulnerable
packages), `gen:snippets` / `gen:behaviour-spec` / `gen:api` drift checks all clean once the
regenerated `behaviour-spec.mdx` was staged alongside the code (the local gate's
`git diff --exit-code` compares the working tree to the index, so it is clean once the file
is staged — this is the documented pre-commit behaviour from RB-17's note, not a defect).
@@ -0,0 +1,119 @@
# RB-21 — extract the read half of `createDraftSync` into `find-concept.ts`
Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-001 ·
`00-baseline.md` §4a (`createDraftSync` 143 lines, the largest function in the repo), §9
(`fn > 40` threshold) · `99-backlog.md` RB-21
## What was wrong
`createDraftSync` (`apps/ssp/src/app/registratie/application/draft-sync.ts`) was registered
as a command factory but owned three read paths (`load`, `findConcept`, and the read half of
`resume`) mixed into the same function as the write path (`ensureId`, `flush`, `submit`,
`reset`). CQ-001 named three pieces of shared mutable closure state — `id`, `ensuring`,
`resumeGate` — as load-bearing: `resumeGate` exists only so the write path (`ensureId`) can
wait for the read path (`resume`) to finish. That coupling is genuine and stays in place.
## What changed
CQ-001's proposal, applied as a pure move, no redesign.
| File | Change |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/ssp/src/app/registratie/application/find-concept.ts` (new) | `findConcept(adapter, type)` and `loadConcept(adapter, id)` — free functions taking `ApplicationsAdapter`, no `inject()`. `loadConcept` returns a `LoadedConcept` union (`{tag:'concept', draft}` \| `{tag:'not-concept'}`) instead of the boolean-shaped branching the inline version had. |
| `apps/ssp/src/app/registratie/application/find-concept.spec.ts` (new) | Direct spec, no TestBed — a fake `ApplicationsAdapter` object passed straight to the functions. |
| `apps/ssp/src/app/registratie/application/draft-sync.ts` | Removed the inline `findConcept` closure and the body of `load`; both now call the free functions. `createDraftSync` keeps `id`, `ensuring`, `resumeGate`, and the whole write path, unchanged. |
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `find-concept.spec.ts` describe blocks. |
`createDraftSync` shrank from 187 lines (`export function createDraftSync` to its closing
brace, HEAD~1) to 169 lines. The whole file went from 236 to 216 lines.
The two call sites that used the old inline `findConcept()` now pass the adapter and type
explicitly:
```ts
// ensureId's 409-recovery catch (WP-35)
const existing = await findConcept(adapter, deps.type);
```
```ts
// resume(), no ?aanvraag in the URL
const existing = await findConcept(adapter, deps.type);
```
`load` keeps setting the closure `id` and calling `applyResume` (both closure-dependent), but
delegates the actual read to `loadConcept`:
```ts
const load = (linked: string): Promise<void> => {
id = linked;
return loadConcept(adapter, linked).then((result) => {
if (result.tag === 'not-concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(result.draft);
});
};
```
## `draft-sync.spec.ts` — unchanged
`draft-sync.spec.ts` was not edited. It never called `resume()`/`load()` directly — its
coverage is the debounce, `submit()` (including the 409-recovery path, which exercises the
extracted `findConcept` indirectly through `ensureId`'s catch), and `flushPending`. All of
that stayed in `createDraftSync`, so the spec is unchanged and still exercises the wiring
between `createDraftSync` and the two new free functions (the 409-recovery test would fail if
that wiring were wrong). It passed unchanged, 8/8.
## The new spec, and its verified red
`find-concept.spec.ts` covers the branches CQ-001 named:
- `findConcept`: match found → id returned; no match of that type → `undefined`; match found
but not `Concept` status → `undefined`; `adapter.list()` resolves to an unparsable shape
(`parseApplications` fails) → `undefined`; `adapter.list()` rejects → `undefined`.
- `loadConcept`: `Concept` with a draft → `{tag:'concept', draft}`; `Concept` with no draft →
`{tag:'concept', draft:null}`; a non-`Concept` status (e.g. `Ingediend`, submitted) →
`{tag:'not-concept'}`; `adapter.detail()` rejects (unknown/deleted id) →
`{tag:'not-concept'}`.
**Verified red without the fix.** Used `Edit` (not `git checkout`) to invert one condition in
`loadConcept``dto.status.tag !== 'Concept'``dto.status.tag === 'Concept'` — reran `ng
test ssp --include find-concept.spec.ts`. Result: 3 of 9 tests failed —
```
loadConcept > reads the draft off a Concept
AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: { step: 1 } }
loadConcept > reports a missing draft as null
AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: null }
loadConcept > reports not-concept when the id has moved past Concept (submitted)
AssertionError: expected { tag: 'concept', draft: null } to deeply equal { tag: 'not-concept' }
```
Then used `Edit` again to flip the condition back to `!==`, reran the same command: 9/9
green. `findConcept`'s and `loadConcept`'s other branches were not separately mutated — the
inverted condition alone was enough to prove the spec is sensitive to the extraction being
correct, and re-verifying full green after the revert confirmed no collateral change was left
in the file.
## Scope held
- No restructuring of the write path (`ensureId`, `flush`, `submit`, `reset`) — untouched
beyond the two call-site updates shown above.
- `applications.adapter.ts` was not split (CQ-002's "Not filed" note rules that out for this
design; out of scope here regardless).
- `resume()`'s semantics (URL-param precedence, the `resumeGate` release-in-`finally`, the
navigate-to-stamp-the-id side effect) are unchanged — only its two `findConcept()`/`load()`
calls now go through the free functions.
- No wire change, no DTO change, no behaviour change.
## Verification
`npm run ci` (foreground): **green** — lint, typecheck, `dep:check`, `format:check`,
`check:tokens`, `check:seam`, tests (ssp includes `find-concept.spec.ts` 9/9 new,
`draft-sync.spec.ts` 8/8 unchanged), `ng build --localize` (both apps), `npm audit`, backend
`dotnet test` (the known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure
is expected and outside `npm run ci`'s scope), `gen:snippets` drift clean, `gen:behaviour-spec`
drift clean once the regenerated file is committed alongside the code. Full counts are in the
commit's `npm run ci` run — see the session note for the exact step-by-step output.
@@ -0,0 +1,139 @@
# RB-22 — `BriefStore.load()` tolerates a 404, calling `reset()` exactly once
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 ·
`99-backlog.md` RB-22, "Tickets that were rejected and split" · `implementation/rb-17.md`
(the `runResult`/`runSubmit` seam this store already sits on)
This is the **expand** half of an expand/contract pair. RB-23 (backend: `GET /brief` 404s
when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`) ships after this
ticket, in a later merge. Today's backend never 404s `GET /brief`, so this ticket's new
branch is dead code in the running app — provably backend-frontend-safe by construction.
## What was wrong
CQ-007 flags `GET /brief` (`Program.cs:603``BriefStore.GetOrCreate`,
`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a
persisted write, breaking the read/write split every other endpoint respects. The fix is
split across both sides of the seam because the FE must be ready to receive a 404 before
the backend can safely start sending one. This ticket is the FE half: `BriefStore.load()`
(`apps/ssp/src/app/brief/application/brief.store.ts`) had no notion of "no brief exists
yet" — every adapter failure, 404 included, dispatched `BriefLoadFailed` and showed the
generic error banner. `BriefAdapter.load()` (`brief.adapter.ts`) also had no way to tell
the store a failure was specifically an HTTP 404: it folded every failure through the
shared `runResult` helper (RB-17), which keeps only a human-readable string and throws
away the HTTP status.
The ticket read as filed against the current code: `BriefStore.load()` is exactly where
CQ-007 says it is, `BriefAdapter.load()` is exactly the read `runResult` call RB-17 pointed
at it, and nothing about either file was factually wrong. Nothing to flag here.
## What changed
| File | Change |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()`'s error channel becomes `BriefLoadFailure` (`{tag:'notFound'} \| {tag:'error', reason:string}`) instead of a plain `string`. `load()` no longer routes through the shared `runResult` — it does its own try/catch so it can read the thrown value's HTTP `status` before folding it away, via the new local `isHttpNotFound` predicate. Every other method (`save`/`submit`/`approve`/`reject`/`send`/`reset`) is untouched, still on `runSubmit`. |
| `apps/ssp/src/app/brief/application/brief.store.ts` | `load()` branches on `BriefLoadFailure`: `notFound` (and not already recovered) calls the existing `reset()` command directly and applies its result; every other failure (including a repeated `notFound`) dispatches `BriefLoadFailed` as before. Extracted `applyLoadedView` (the success-path body shared by `load()` and the new recovery path) and added `recoverFromMissingBrief`. |
| `apps/ssp/src/app/brief/application/brief.store.spec.ts` | New `describe('BriefStore.load — 404 tolerance (RB-22)')` with the two required cases. Six pre-existing `load:` fakes' explicit `Result<string, BriefView>` return-type annotations updated to `Result<BriefLoadFailure, BriefView>` (they only ever produce the `ok: true` branch, so this is a type-only change); two shared `ok(v)` test helpers that build fakes for both `load` and `save` had their return-type annotation dropped in favour of `as const` inference, since one helper now serves two different error-channel types. |
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the two new `it()` titles. |
No `backend/` file was touched — `Program.cs` and `Data/BriefStore.cs` are RB-23's, per the
ticket's explicit scope.
## How the once-only bound is structural
`BriefStore` gains one field: `private hasRecoveredFromMissingBrief = false`. `load()`
takes the recovery branch only when `r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief`,
and the branch's first statement sets the flag before doing anything else. A second 404 —
whether from a second `load()` call, or in principle from `reset()` itself somehow also
404ing — falls through to the plain `BriefLoadFailed` branch instead, on every subsequent
call, for the life of the store instance. This is a field on the singleton store, not a
comment: nothing in the reachable call graph can flip it back to `false`.
The loop CQ-007's proposed change warns about ("the reset's own load must not be able to
loop") is not merely bounded, it is **structurally absent**: `recoverFromMissingBrief`
calls `this.adapter.reset()` and applies its `BriefView` result directly (the same
`applyLoadedView` the success path uses) — it never calls `this.load()` again. There is no
recursive edge from the recovery path back into `load()` for the once-only flag to have to
stop; the flag exists only to stop a **second, separate** `load()` invocation (e.g. a
caller retrying navigation) from reaching `reset()` again.
## Judgement calls
- **`load()` no longer uses `runResult`, only for this one method.** `runResult`
(`libs/shared/src/application/submit.ts`, RB-17) intentionally keeps only a string —
every other read in the app is fine with that. This is the first read that needs one
more bit (the HTTP status) than `runResult` exposes, so `load()` does its own
try/catch instead, matching `runResult`'s shape (`problemDetail(e, fallback)` on the
non-404 path) but adding the 404 branch first. `libs/shared/src/application/submit.ts`
itself is untouched — changing a shared helper used by many call sites for one adapter's
need was out of scope and unjustified.
- **404 detection reads `(e as {status?:unknown}).status === 404`, not
`SwaggerException.isSwaggerException`.** The generated client throws a plain
`SwaggerException` for `GET /brief` today (no OpenAPI 404 response is declared for it
yet), but throws the parsed `ProblemDetails` object instead for an endpoint whose spec
**does** declare a 404 (both shapes carry a `status` field). Checking `status` alone,
not the `SwaggerException` type, means this predicate keeps working unchanged once
RB-23 regenerates the client with a documented 404 response for `briefGET()` — no
follow-up FE edit needed for detection to keep working.
- **`BriefLoadFailure` is a new exported type, not a sentinel string.** CLAUDE.md's
default reflex is a discriminated union over a second flag; a magic string
(`'__not_found__'`) compared by identity would have kept `load()`'s signature at
`Result<string, BriefView>` and touched fewer test lines, but it is exactly the kind
of stringly-typed control flow the union tool exists to avoid. The touched-test cost
was six type annotations plus two helper signatures, all in the one already-scoped
spec file — judged worth it for the correct shape.
`BriefLoadFailure` is exported.
- **On a repeated 404, the store shows `BRIEF_LOAD_FAILED`** (the same generic banner
text `load()` already used for every other failure), not a distinct "still missing"
message. No new user-facing copy was needed or added, so no new `$localize` id and no
`messages.en.xlf` change — confirmed by diffing for `$localize` occurrences: both
hits in the diff are unchanged context lines, not new additions.
- **`resetDemo()` (the "start over" button) was left untouched**, even though it
duplicates part of the same apply-a-fresh-view logic now factored into
`applyLoadedView`. It also manages `actionState`/`saveState`/`rejectionSnapshot` that
`recoverFromMissingBrief` correctly does not touch (an automatic recovery on first
load is not a user-initiated "start over" action), and refactoring it was not asked
for by this ticket.
## Verification
- **Verified red without the fix.** Temporarily replaced `load()`'s body (via `Edit`,
not `git checkout`) with the pre-fix shape — every failure, `notFound` included,
dispatches `BriefLoadFailed` straight away, no `reset()` call — and reran the spec
file. Both new tests failed:
`expected "vi.fn()" to be called 1 times, but got 0 times` on `reset`, for both "a 404
drives exactly one reset()" and "a second 404 does not drive a second reset()"; the
other 18 tests in the file stayed green. Restored the real fix with a second `Edit`
and reran: all 20 tests in the file green, 30/30 across both touched spec files.
- `npm run ci` (foreground, no background/Monitor): **green**, exit 0 — lint,
typecheck, `dep:check` (341 + 226 modules, 0 violations), `format:check`,
`check:tokens`, `check:seam`, tests (ssp 260/260, behandelportal 37/37, shared
138/138, beheer 23/23 — 458 total), `ng build --localize` (both apps), `npm audit`
(0 vulnerabilities), backend `dotnet test` (260/260 — the known
`OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not
reproduce on this run, matching the standing caveat that it needs a live OpenZaak
container and is not this ticket's bug), backend dependency audit clean, `gen:snippets`
drift clean, `gen:behaviour-spec` drift clean once the regenerated file was staged (the
local gate diffs the working tree against the index, so it necessarily shows a diff
before the file is staged/committed — the same documented, expected behaviour RB-17
recorded, not a defect).
- `npx prettier --check` on every touched file (including the reformatted
`99-backlog.md` table and the regenerated `behaviour-spec.mdx`): clean.
## What RB-23 must do
Once `GET /brief` in `Program.cs` returns a real 404 (no `ProblemDetails` body is
required — `BriefAdapter.load()`'s `isHttpNotFound` only reads the HTTP `status`, not the
response body), and `BriefStore.GetOrCreate` splits into `Get` (query) + the existing
`ResetAndCreate` (already there, already used by `POST /brief/reset`), this ticket's
`notFound` branch stops being dead code and starts running on first-ever page load for any
owner with no persisted brief. Run `npm run gen:api` as part of RB-23 so `briefGET()`
regenerates with a documented `status === 404` branch throwing the parsed `ProblemDetails`
(matching the shape most other endpoints already use) — the detection predicate here
already tolerates that shape and needs no FE follow-up change. Two things worth
re-verifying once RB-23 lands, not fixing preemptively here: first, that the resulting
double round-trip (404, then `reset()`) is an acceptable UX cost on a first visit, per
CQ-007's own framing of this as its least certain finding; second, that this store's
`hasRecoveredFromMissingBrief` field — private to one store instance, reset only by a full
reload — is still the right lifetime for the once-only guard once a real 404 can occur in
production traffic, not only in a test's fake adapter.
@@ -0,0 +1,183 @@
# RB-23 — `GET /brief` 404s when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 ·
`99-backlog.md` RB-23, "Tickets that were rejected and split" · `implementation/rb-22.md`
(the FE **expand** half this ticket **contracts** against)
This is the **contract** half of the RB-22/RB-23 expand/contract pair. RB-22 shipped first
and made `BriefStore.load()` tolerate a 404 by calling `reset()` once, as a no-op against
the (then) still-seeding backend. This ticket is what makes that branch live: `GET /brief`
now 404s when the owner has no brief yet, and the endpoint no longer performs a persisted
write on a read.
## What was wrong
CQ-007 flagged `GET /brief` (`Program.cs:676``BriefStore.GetOrCreate`,
`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a
persisted write, breaking the read/write split every other endpoint respects. The FE
retries GETs automatically (`api-client.provider.ts`, `retry({ count: 2, delay: 500 })`,
GET-only, precisely because GETs are assumed safe), so a transient failure could enter the
create path more than once; `GetOrCreate`'s `lock` prevented a duplicate row today, but the
safety depended on the lock rather than on the endpoint being a query.
The ticket read as filed against the current code: `GetOrCreate` was exactly at
`BriefStore.cs:50`, `GET /brief` called it exactly as described, and `ResetAndCreate`
already existed and was already the sole body of `POST /brief/reset`. One thing the
ticket's own text did not mention: `BriefStore.GetOrCreate` had a **second** call site,
`GET /brief/preview` (`Program.cs:769`, excluded from the OpenAPI doc — a hand-written FE
`fetch`, same seam as uploads). Splitting `GetOrCreate` away necessarily touches that
call site too, or the file does not compile. See "What changed" below — this was a forced
consequence of the split, not a new business decision, and it is reported here rather than
silently worked around.
## What changed
| File | Change |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `GetOrCreate` removed. New `Get(string owner): BriefEntity?` — pure query, `lock`-guarded like every other method in this file for consistency, no write. `ResetAndCreate` is untouched. |
| `backend/src/BigRegister.Api/Program.cs` | `GET /brief`: calls `BriefStore.Get`; returns `Results.NotFound()` when null, `Results.Ok(ToView(ctx, e))` otherwise; declares `.Produces(StatusCodes.Status404NotFound)` (the same bare-404 pattern already used at 17 other call sites in this file). `GET /brief/preview`: same `Get` + 404 treatment — forced by the split (see above), not a scope decision made independently. |
| `backend/src/BigRegister.Api/Data/AppDbContext.cs` | One comment updated (`GetOrCreate's invariant``ResetAndCreate's invariant`) — the unique index on `Owner` it annotates is unchanged. |
| `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` | New `Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner` (the DoD-required test). The `Get()` seeding helper, used by nearly every other test in the file, renamed to `SeedBrief()` and changed to create the brief explicitly via `POST /brief/reset` instead of relying on `GET /brief`'s old side effect. One test renamed (`Get_creates_a_draft_with_expected_sections_locked_and_empty``SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty`) — it asserts on the shape of a freshly created brief, which is now `SeedBrief()`'s job, not `GET`'s. |
| `backend/tests/BigRegister.Tests/PreviewEndpointTests.cs` | Two tests explicitly create the brief (`POST /brief/reset`) before hitting `/brief/preview`, instead of relying on the old `GET /brief` implicit create. |
| `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` | Five call sites (three bare seeding `GetAsync` calls, two `GetFromJsonAsync<BriefViewDto>` calls used as seeding) changed to an explicit `POST /brief/reset` first. One call site (`Sent_brief_keeps_its_pinned_template_after_a_republish`, reading a brief already created and sent by the shared `WalkBriefToSentThenRepublish` helper) needed no change — a brief already exists by the time it runs. |
| `backend/tests/BigRegister.Tests/RouteInventoryTests.cs` | Two `AllowList` reason strings updated (`GetOrCreate``Get`, 404 noted) — documentation text only, not itself a check the test enforces beyond "some reason is on record". |
| `e2e/brief-v2.spec.ts` | One header comment updated to name the current methods and to state explicitly that this spec's own first click ("Opnieuw beginnen (demo)") is fixture setup, not a workaround for the new 404 — see "e2e and seeding paths" below. |
| `libs/shared/src/infrastructure/api-client.ts` | Regenerated (`npm run gen:api`). `briefGET()` gains a `status === 404` branch. See "The generated client" below for the shape it actually took. |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-23's status cell: `open``implemented`. |
No `apps/ssp/src/app/brief/**` file was touched — RB-22's `BriefStore.load()` recovery and
`BriefAdapter.load()`'s `BriefLoadFailure`/`isHttpNotFound` are unchanged, per this
ticket's explicit scope.
## The generated client
RB-22's handoff note predicted `briefGET()` would regenerate "throwing the parsed
`ProblemDetails` (matching the shape most other endpoints already use)". That did not
happen, and the actual result is still correct. `Results.NotFound()` (this ticket's
implementation, and the pattern used at every one of the 17 other bare-404 call sites in
`Program.cs` — none of them use `ProducesProblem`/a typed body) declares a 404 with **no**
response body schema. With nothing to parse into, NSwag emits a generic branch that throws
a plain `SwaggerException` carrying `status: 404` — the same shape `briefGET()` already
threw before this ticket, for the same reason (no declared 404 body). `BriefAdapter.load()`'s
`isHttpNotFound` predicate (`(e as {status?:unknown}).status === 404`) already tolerates
both a `SwaggerException` and a parsed `ProblemDetails`, by design, precisely so this
detail would not matter — RB-22's own comment says as much. No FE follow-up was needed, and
none was made.
## Judgement calls
- **`GET /brief/preview` also moved off `GetOrCreate`, to `Get` + 404.** Not mentioned in
the ticket text, but unavoidable: `GetOrCreate` no longer exists once split, and this
was its only other caller. The alternative — leaving a private, undocumented
`GetOrCreate`-shaped helper only for this one endpoint — would have reintroduced
exactly the GET-writes-on-read pattern CQ-007 is about, in the one place nobody would
think to look for it. Returning 404 there too keeps both `/brief` GETs behaving the
same way. In the running app this is unreachable in practice: the preview button
only renders inside the brief page's `@if (loaded(); as s)` block
(`apps/ssp/src/app/brief/ui/brief.page.ts`), which by construction only shows once
`BriefStore.load()` has already succeeded — including via RB-22's 404-recovery branch.
So a brief always exists by the time a real user can trigger `/brief/preview`; the 404
path there is a defensive consequence of the type split, not a new user-facing
behaviour anyone will hit.
- **`BriefStore.Get` keeps the `lock (_gate)` wrap**, even though a plain SQLite read
does not strictly need the same mutual exclusion a write does. Every other method in
this file, including the pre-existing `ApplicationStore.Get`-style query in the
sibling store, locks unconditionally — matching that convention was judged more
valuable than a lock-free read this ticket did not need to justify removing.
- **Existing test changes create the brief via `POST /brief/reset`, not a new
`BriefStore.Get`/`ResetAndCreate` direct call from the test.** Going through the HTTP
endpoint (as the old `Get()` helper always did) keeps the tests exercising the real
request pipeline (identity resolution, `ToView` mapping) rather than reaching around
it — the same reasoning that already justified an `IClassFixture<TestWebApplicationFactory>`
HTTP-level test suite in the first place.
## e2e and seeding paths
- **`e2e/brief-v2.spec.ts`** is the only e2e spec that reaches `/brief`. It already opens
`/brief?role=drafter` and immediately clicks "Opnieuw beginnen (demo)" (`POST
/brief/reset`) before asserting anything — a deliberate fixture reset, not a
workaround. With this ticket live, the page's first `GET /brief` on the fresh
per-run database (WP-74) now 404s; RB-22's `BriefStore.load()` recovers from that by
calling `reset()` once, so the page still renders correctly, and the spec's own
explicit reset click still runs on top of that (harmless — resetting an
already-fresh brief). No behavioural change to the spec was needed; one comment was
updated to say this explicitly rather than leave it to be re-derived.
- **Storybook**: no `brief.page.stories.ts` exists, and none of the eleven `brief/ui/**`
component stories call `HttpClient`/`fetch`/`ApiClient` — every story supplies data
through component `input()`s, per the house convention (design-system/component
stories are not live-network integration tests). Nothing in Storybook depended on
`GET /brief`'s old seeding behaviour.
## The double round-trip — verdict
CQ-007 named this its least certain point: a first-ever visit to `/brief` now costs a 404
followed by a `reset()` call, instead of one request that both creates and returns the
brief. **Shipped as-is; the cost is acceptable.** Three reasons:
1. **It happens once per browser tab, ever, for one demo entity.** `BriefStore`'s
`hasRecoveredFromMissingBrief` flag (RB-22) makes the 404 unreachable again for the
life of the store instance; a real deployment has one brief per zorgverlener, created
the first time that person ever opens the page. This is not a cost paid on every
page load, or even every session — a page reload still 404s once if the flag reset
with the page, but the underlying row is already there by then, so the _second_ call
in the pair — `reset()` — is now hitting an existing row rather than truly
first-creating one, and returns just as fast as `Get` would have.
2. **An extra round-trip is not an extra spinner.** `BriefStore.load()`'s failure
handling for `notFound` calls `reset()` and applies the result through the same
`applyLoadedView` the success path uses — there is no intermediate "not found" UI
state rendered to the user between the two calls; the page shows its loading state
once, for the combined duration of both requests.
3. **The alternative was rejected, not merely deprioritized.** CQ-007's own
documentation-only alternative — leave `GetOrCreate` in place, just write down that
the GET seeds on first call — was rejected outright by agent 07 in `99-backlog.md`:
"a non-idempotent GET must be visible in the code, not only in a ticket." Given that,
the only way to remove the mixing is some version of this two-call shape; a
single-call alternative would mean either GET creates (the defect) or `POST
/brief/reset` runs unconditionally on load (destructive — it deletes an existing
brief, unacceptable for anyone with real content already saved).
## The once-only guard's lifetime — re-verified
RB-22 flagged this as worth re-checking once a real 404 could occur in production
traffic, not only in a test's fake adapter. Having now made the 404 real: `hasRecoveredFromMissingBrief`
is a private field on `BriefStore`, which is `providedIn: 'root'` — one instance per
browser tab (per CLAUDE.md's "shared cross-page state = one root singleton" convention),
reset only by a full page reload. That lifetime is still correct for what the flag
guards: it exists to stop a _second, separate_ `load()` call in the same tab session from
re-triggering `reset()` (e.g. a caller retrying navigation after the first recovery
already ran) — not to remember "this owner has a brief" across reloads or across owners,
which is the server's job (`BriefStore.Get` returning non-null). A page reload correctly
starts the guard over: the first `load()` after a reload will find the now-existing row
via a plain `GET` (no 404, no `reset()` call at all), so the flag never actually gets
exercised a second time in the reload case either. No FE change was needed or made.
## Verification
- **Verified red without the fix.** Temporarily (via `Edit`, never `git checkout`)
restored `BriefStore.GetOrCreate` alongside the new `Get`, and pointed `GET /brief` in
`Program.cs` back at `GetOrCreate`. Ran the new test alone:
```
BigRegister.Tests.BriefEndpointTests.Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner [FAIL]
Assert.Equal() Failure: Values differ
Expected: NotFound
Actual: OK
```
Restored the real fix with a second `Edit` (removed the temporary `GetOrCreate`,
pointed `GET /brief` back at `Get` + 404) and reran: green.
- Full backend suite after the fix: **262/262 passing**, plus the one known,
pre-existing, container-dependent failure
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
"Connection refused (localhost:8000)") — not this ticket's bug, does not run under
`npm run ci`, reproduces on a clean tree with no OpenZaak container running.
- `npm run gen:api`: the client changed (`libs/shared/src/infrastructure/api-client.ts`,
`briefGET()` gains a `status === 404` branch — 4 lines). Regenerated and committed;
see "The generated client" above for why the shape differs from RB-22's prediction and
why that difference is harmless.
- `npm run ci` (foreground, no background/Monitor): see result below.
## What this ticket did not touch
`apps/ssp/src/app/brief/application/brief.store.ts`, `brief.store.spec.ts`, and
`apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` are unchanged — RB-22's FE logic
was already correct and already tested against exactly this contract, per this ticket's
explicit scope.
@@ -0,0 +1,178 @@
# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 ·
`99-backlog.md` RB-24, "Merges" table row for RB-25/26/27
## What was wrong
`libs/shared/src/upload/` held five files outside the folder-per-layer convention every
other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw
`XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`.
`upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/`
folder. The exception was hard-coded into the enforcement itself:
`.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read
`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the
violation instead of the violation being fixed, which is why the baseline scan reported 0
violations despite this.
## What changed
| From `libs/shared/src/upload/` | To |
| -------------------------------- | ----------------------------------------------------- |
| `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` |
| `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) |
| `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` |
| `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` |
All five moves used `git mv`. `libs/shared/src/upload/` no longer exists.
**Import updates.** 24 consumer files import from `@shared/upload/*` (found with
`grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated
`@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths
were rewritten to the new locations (30 import statements total, some files import more
than one symbol). No export was renamed, no file was split, no logic changed in any of
these 24 files beyond the import path string.
**Within the five moved files**, three had relative imports (`./upload.adapter`,
`./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases:
`upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine`
`@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and
`upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`;
`upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it
and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine`
import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed
pure rename.
**The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule:
`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }``from: { pathNot: '/infrastructure/' }`,
comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further
consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules"
paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only
inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since
leaving it would document a rule that no longer exists.
## A second, real violation the move exposed — fixed, not just reported
Deleting the carve-out did not by itself make `dep:check` pass. A **separate,
pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import
`infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because
before this move the file's path did not contain `/infrastructure/` at all. Three UI
components were injecting `UploadAdapter` directly:
`apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`,
`apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`,
and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically
moved into `infrastructure/`, `dep:check` correctly flagged all three:
```
error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts
error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts
error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts
```
This is judged in-scope to fix, not a second unrelated finding to merely report, for three
reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the
deletion, the move is incomplete, so fix the move rather than restoring the clause."
Second, all three call sites used `UploadAdapter` for exactly one thing —
`.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported,
injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no
injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into
its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts`
already used that pure function directly; the three UI files had independently reinvented
`inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays
inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27
target:
- `libs/shared/src/application/upload-controller.ts` — the object `createUploadController`
returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure
`uploadContentUrl`. Both wizard components already hold a `createUploadController`
instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now
delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself.
- `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects
`UploadAdapter` legitimately — it's application layer) gained one more computed-style
field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now
reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`.
No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the
identical string (the method is a one-line pass-through to the function), and the
`demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method
verbatim.
## Verification
- **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as
a 0-insertion/0-deletion pure rename — no content changed, including its own imports
(both files moved into `domain/` together, so its `./upload.machine` relative import
needed no edit). No spec content changed anywhere in this ticket.
- `npm run dep:check`: **passes for both apps** with the carve-out clause removed —
`✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp),
`✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal).
- `npm run lint`: clean.
- `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing**
(ssp / behandelportal / shared / beheer).
- `npm run build`: both apps build (pre-existing, unrelated warnings about
`/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time —
present before this ticket, vendored assets resolved at serve/deploy time, not a
regression from this move).
- **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`):
the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100%
functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002
predicted landing in a folder the baseline reported at "0% spec reach across 3 files"
(`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which
remain unspecced — that gap is pre-existing and out of this ticket's scope).
## Non-TypeScript references to the old path — findings
Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config
file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js`
files, and `libs/shared/docs/*.mdx`.
- **Storybook config, angular.json, dependency-cruiser app configs**: no reference to
`shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change.
- **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself,
deleted (see above).
- **`docs/reference/architecture/dependencies.md`**: one prose reference to the same
carve-out, corrected in this diff (see above) since it directly describes the rule this
ticket edits.
- **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or
`@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and
`shared/ui/upload/...` by filename/short-path only, never the full old directory path —
both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling
folder).
- **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**:
each carries a handful of `<context context-type="sourcefile">src/app/shared/upload/upload.machine.ts</context>`
/`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor,
informational only (they tell a translator where a string originated; they are not
read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is
`npm run extract-i18n`'s job for the source-locale file and does not touch the
hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the
move plus import updates, not a translation-tooling refresh. They will self-correct
the next time `extract-i18n` runs for an unrelated reason.
- **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**:
several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`,
`06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`)
reference the old path — expected, since most of them describe or cite the violation
this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell
(see below).
## What RB-25/26/27 now find where
- **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`):
`KeepaliveTransport` and `UploadShellService` are both now in
`libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The
token belongs in `application/` alongside them — nothing about the token's shape or
location changes because of this move.
- **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now
`libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected`
callback — the accept/reject decision RB-26 targets — is in
`libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise
unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket —
see above). RB-26 should extend `upload.machine.ts` in its new location; no import path
in that file needs touching beyond what this ticket already did.
- **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure
is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same
file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and
`genericError` are all still exactly where they were, just under `infrastructure/`.
## `npm run ci`
Result and step count reported in the final answer.
@@ -0,0 +1,130 @@
# RB-25 — `UPLOAD_TRANSPORT` injection token replaces `inject(KeepaliveTransport)`
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-003 ·
`99-backlog.md` RB-25, "Merges" table row for RB-25/26/27 · Depends on
`implementation/rb-24.md` (the move that put this file at its current path)
## What was wrong
`libs/shared/src/application/upload-shell.service.ts` defines `export interface
UploadTransport` and documents it as the swap seam for upload transport. It then binds
`private transport: UploadTransport = inject(KeepaliveTransport)` — the concrete class,
which is `@Injectable` but not exported. A spec cannot reference the class to override its
provider, and cannot provide against the interface either, because an interface is not a
DI token. The port existed on paper only.
The consequence TE-003 measures: `upload()`, `delete()`, `cancel()`, and `pollReturning()`
— the methods that translate transport and adapter outcomes into `UploadMsg`s — had no
spec at all. `libs/shared/upload` sat at 52.0% line / 50.0% branch, and
`upload-shell.service.ts` was one of the two unreached non-`ui/` files.
## What changed
One file plus one new spec, exactly as scoped:
- `libs/shared/src/application/upload-shell.service.ts`: added
```ts
export const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT', {
providedIn: 'root',
factory: () => inject(KeepaliveTransport),
});
```
copied verbatim from TE-003's own fix, placed directly under the `KeepaliveTransport`
class it wraps. `UploadShellService.transport` now reads
`inject(UPLOAD_TRANSPORT)` instead of `inject(KeepaliveTransport)`. This is the same
interface-plus-token shape as `SessionPort`/`SESSION_PORT`
(`libs/shared/src/application/session.port.ts`), the repo's one other explicit port.
`KeepaliveTransport` itself is untouched: still a private, unexported `@Injectable`, and
still the default factory's target — a real app gets the exact same singleton instance
it always did.
- `libs/shared/src/application/upload-shell.service.spec.ts` (new): a recording fake
`UploadTransport` (records every `send()` call, exposes `resolveDone`/`rejectDone` per
call so a test drives the returned `Promise` by hand) provided against `UPLOAD_TRANSPORT`,
plus a fake `UploadAdapter` (a plain object with `vi.fn()` for `status`/`deleteDocument`)
provided against the already-exported `UploadAdapter` class. 16 specs across all four
target methods:
- `upload()``UploadQueued` carries the transport's `backgroundSyncAvailable`;
`onProgress``UploadProgress`; a resolved transport → `UploadComplete`; a rejected
transport → `UploadFailed` with the rejection reason; a rejection with the
`UPLOAD_ABORTED` sentinel dispatches nothing.
- `cancel()` — calls the stored cancel function for an in-flight upload and forgets it
(a second `cancel()` on the same id is a no-op); an unknown id is a no-op.
- `delete()``UploadDeleting` then `UploadDeleteComplete` on success;
`UploadDeleteFailed` with the server's `detail` on a ProblemDetails rejection; falls
back to an empty reason when the rejection carries no `detail`.
- `pollReturning()` — skips the adapter call entirely for an empty upload list;
dispatches `BackgroundUploadsReturned` filtered to only the items the server reports
`complete` with a `documentId`; dispatches nothing when nothing has arrived.
No other file changed. `UploadAdapter`, `upload.machine.ts`, and `upload-controller.ts`
are untouched, per the ticket's file-scope fence (RB-26 and RB-28 are concurrently in
adjacent files).
## Verification
- **Coverage, `upload-shell.service.ts`** (`npm run test:coverage` narrowed to `shared`,
read from `coverage/shared/lcov.info`):
| Metric | Before | After |
| --------- | ------ | -------------- |
| Lines | 0% | 88.57% (31/35) |
| Branches | 0% | 85.00% (17/20) |
| Functions | 0% | 87.50% (14/16) |
"Before" is 0% across the board: no spec file for this service existed prior to this
ticket (confirmed by `grep -rln UploadShellService --include=*.spec.ts`, which returns
only the new spec), matching TE-003's "unreached" classification. The remaining
uncovered lines are the `KeepaliveTransport` class body (`send()`, its `inject`) and the
`UPLOAD_TRANSPORT` factory closure itself — both require a real `XMLHttpRequest`/real DI
resolution to exercise and are intentionally out of this ticket's scope: TE-003's fix is
the seam, not a rewrite of the transport it wraps.
- **Red-proof.** Edited `upload()`'s success branch from
`dispatch({ type: 'UploadComplete', localId: req.localId, documentId })` to
`dispatch({ type: 'UploadFailed', localId: req.localId, reason: 'BROKEN-FOR-RED-PROOF' })`,
ran `ng test shared`. Result: 1 failed / 150 passed, with
```
AssertionError: expected "vi.fn()" to be called with arguments: [ { type: 'UploadComplete', …(2) } ]
Received:
1st vi.fn() call: [{ "backgroundSync": false, "localId": "l1", "type": "UploadQueued" }]
2nd vi.fn() call: [{ "localId": "l1", "reason": "BROKEN-FOR-RED-PROOF", "type": "UploadFailed" }]
```
at `upload-shell.service.spec.ts:79` (the `UploadComplete` assertion). Re-applied the
original line with a second edit (not `git checkout`); `git diff` against HEAD shows
only the intended token change — the red edit left no trace. Re-ran: 151/151 green.
- `npm run ci`: result and step count in the final answer.
## Judgement call
- **The fake `UploadAdapter` is a plain object, not a class extending `UploadAdapter`.**
`UploadAdapter` is exported and already usable as a DI token (it always was — TE-003's
gap was specific to `KeepaliveTransport`, not `UploadAdapter`), so `delete()` and
`pollReturning()` (which never touch `this.transport`) were technically fakeable before
this ticket by providing a fake `UploadAdapter`. Nobody had written that spec, though,
and `upload()`/`cancel()` still needed `UPLOAD_TRANSPORT` regardless (they populate and
drain the `inflight` map via `transport.send()`). The spec fakes both seams together so
all four methods are exercised as one coherent suite, per the ticket's own framing
("provide a recording fake transport and assert the message translation in `upload()`,
`delete()`, `cancel()` and `pollReturning()`").
## Handoff to RB-27
RB-27 extracts `uploadOutcome(status, responseText)` out of the XHR closure in
`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` — a different file,
untouched by this ticket. The token makes RB-27's optional half (moving the
`currentScenario()` branch into `KeepaliveTransport.send()`) no easier and no harder than
before: `KeepaliveTransport` is still unexported and its `send()` body is unchanged, one
line (`inject(UploadAdapter)`, `return this.adapter.xhrUpload(req, onProgress)`). If RB-27
takes that optional move, it can inject `UPLOAD_TRANSPORT` in its own spec to assert the
scenario branch without touching this file — the seam is there and provided-in-root, but
RB-27 does not need to change anything here to use it.
## `npm run ci`
Result and step count reported in the final answer.
@@ -0,0 +1,109 @@
# RB-26 — move the accept/reject decision into `planFileSelection` (`upload.machine.ts`)
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-004 ·
`99-backlog.md` RB-26 · Depends on `implementation/rb-24.md` (moved the upload files into
`infrastructure`/`domain`/`application`)
## What was wrong
TE-004: `createUploadController` does three `inject()` calls, registers an `effect()`, and
adds a `window` focus listener, all before it returns. A spec must run inside a `TestBed`
injection context with `UploadAdapter`, `UploadShellService`, and `DestroyRef` all
satisfied to reach anything inside it. What sits behind that cost is real policy:
`onFileSelected` decides, per file, whether to reject it with reason `'multiple'`, reject
it with a `rejectReason` result, or start its upload — a decision over
`(categories, categoryId, files)` with no I/O in it. `rejectReason`, the predicate that
decision calls, was already exported and spec'd; the decision that calls it was not.
## What changed
`libs/shared/src/domain/upload.machine.ts` gains one pure export:
```ts
export function planFileSelection(
state: UploadState,
categoryId: string,
files: { name: string; type: string; size: number }[],
): UploadMsg[];
```
It takes plain `{ name, type, size }` objects, not `File` — a spec needs no DOM. The body
is the old `onFileSelected` decision, moved: an unknown category plans nothing; too many
files for a single-file category plans one `FileRejected` with reason `'multiple'` and
skips the per-file checks; otherwise each file is judged by `rejectReason` and plans
either a `FileRejected` or a `FileSelected` entry, one entry per input file, in order.
`libs/shared/src/application/upload-controller.ts`'s `onFileSelected` now maps `selected:
File[]` to plain candidates, calls `planFileSelection`, and executes the result: a
`FileRejected` entry dispatches as-is; anything else starts the upload for the file at
that same array index (`crypto.randomUUID()`, `files.set()`, `shell.upload()` — the three
things that must stay impure and stay in the controller). No other method changed.
`previewUrlFor` (added by RB-24) is untouched.
## The `localId` placeholder — a deliberate, contained choice
An accepted file's planned `FileSelected` entry carries `localId: ''`. A real id needs
`crypto.randomUUID()`, and the ticket is explicit that call stays in the controller, not
the domain. The controller reads only each entry's `.type` to route it — it dispatches a
`FileRejected` entry verbatim, but for a `FileSelected` entry it discards the entry and
calls `start(categoryId, selected[i])`, which builds its own message with a real id.
The placeholder is therefore never dispatched. This was the only way found to keep the
return type exactly `UploadMsg[]` (as the ticket's own code sketch specifies) while still
letting the plan carry a per-file, order-preserving "start this one" signal — the
`FileRejected` variant carries no file identity (state keys rejections by category only),
so position in the returned array is what the controller uses to find the matching
original `File`. A discriminated `{ kind: 'reject' | 'start'; msg? }` return would avoid
the placeholder but was not built, since the ticket's signature is explicit and the
placeholder design meets it without changing behaviour.
## Behaviour
Same messages, same order, for the same inputs. Tracing all three original branches:
- Unknown category: original returns without dispatching; new code calls `planFileSelection`
(returns `[]`), then `forEach` over an empty array — no dispatch, no start.
- Too many files for a single-file category: original dispatches one `FileRejected`
('multiple') and returns; new code gets a one-entry plan and dispatches that one entry —
`forEach` never reaches indices past the plan's length, so no file starts.
- Per-file loop: original dispatches `FileRejected` or calls `start` for each file, in
order; new code's plan has one entry per file, in the same order, and the controller
dispatches or starts at each index identically.
## Testing
`libs/shared/src/domain/upload.machine.spec.ts` gained a `planFileSelection` describe
block: unknown category (plans nothing), the `'multiple'` batch rejection, a passing
single file against a single-file category, `rejectReason`'s two reject cases (`'type'`,
`'size'`) reached through the plan, the accept case's exact `FileSelected` shape
(including the `localId: ''` placeholder), and a mixed multiple-file case asserting
order (`['FileSelected', 'FileRejected', 'FileSelected']`).
**Proved red before green**, per the ticket's instruction not to use `git checkout`:
temporarily replaced the function body with a stub returning `[]` unconditionally (an
edit, not a revert), ran `ng test shared`, and got:
```
Test Files 1 failed | 23 passed (24)
Tests 6 failed | 139 passed (145)
```
The 6 failures were the `'multiple'` rejection, both `rejectReason` cases, the accepted-
file shape, and the mixed-order case — every outcome that depends on the real branching,
each failing with `expected [] to deeply equal [...]`. The unknown-category case passed
even against the stub, since both the stub and the real implementation return `[]` there
— expected, not a gap, since that branch has no policy to exercise. A second edit restored
the real body; the same run returned to `24 passed / 145 passed`.
## Scope held
No change to `createUploadController`'s construction, the `effect()`, or the `window`
listener — those are RB-25/RB-27's targets (RB-25 is `UploadShellService`, running
concurrently in the same commit window; RB-27 is `upload.adapter.ts`'s XHR closure).
Neither file was touched. The controller's public surface (`previewUrlFor`,
`onFileSelected`, `onRemove`, `onRetry`, `onDelete`, `onChannelChange`) is unchanged in
name and signature, and the organism that calls it (`<app-document-upload>`) needed no
change.
## `npm run ci`
Result and step count reported in the closing message.
@@ -0,0 +1,193 @@
# RB-27 — `uploadOutcome` extracted from the XHR `load` closure
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-005 ·
`99-backlog.md` RB-27, "Merges" table row for RB-25/26/27 · Depends on
`implementation/rb-24.md` (the move that put this file at its current path) and
`implementation/rb-25.md` (handoff paragraph read before deciding the optional half)
## What was wrong
`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` constructs
`new XMLHttpRequest()` directly and attaches its `load` listener inline. The listener
body held the actual decisions: 2xx-vs-not, `JSON.parse` of the response body with a
fallback to a generic error, and (on a non-2xx status) ProblemDetails mapping via the
un-exported `parseError`. None of it is reachable without stubbing the XHR global, so
the interpretation logic had no spec.
TE-005's baseline citation: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57 (5.3% branch)**.
The file was counted "reached" in the module total only because another spec imports
it — essentially nothing in it executed.
## What changed
One function extracted from the `load` listener, in the same file:
```ts
export function uploadOutcome(
status: number,
responseText: string,
): Result<string, { documentId: string }> {
if (status < 200 || status >= 300) return err(parseError(responseText));
try {
return ok({ documentId: JSON.parse(responseText).documentId });
} catch {
return err(genericError());
}
}
```
placed next to `genericError`/`parseError` (below the class, above the dev
`simulateUpload`). It contains exactly the 2xx-vs-not check, the `JSON.parse`-with-
fallback, and the ProblemDetails mapping — the three decisions TE-005 names. The `load`
listener is now a two-line dispatch:
```ts
xhr.addEventListener('load', () => {
const outcome = uploadOutcome(xhr.status, xhr.responseText);
outcome.ok ? resolve(outcome.value) : reject(outcome.error);
});
```
`Result`, `ok`, `err` are imported from `@shared/kernel/fp` (the repo's one `Result`
type, already used the same way by `libs/shared`'s other infrastructure adapters).
`parseError` and `genericError` are untouched — `uploadOutcome` calls them exactly as
the old listener body did, so their own behavior (ProblemDetails detail extraction,
generic fallback) is unchanged.
## Abort-vs-error: left as a separate, smaller concern
TE-005 names abort-vs-error disambiguation in the same sentence as the extraction
target, but its proposed signature — `uploadOutcome(status: number, responseText:
string)` — has no way to express "the request was aborted before any response
arrived." That is a real, structural mismatch, not an oversight to route around:
- `uploadOutcome` runs inside the `load` listener, which fires only when the browser
received a complete HTTP response — it has a `status` and a `responseText` by
construction.
- The `abort` listener fires instead of `load` when `xhr.abort()` was called
client-side. There is no HTTP response at that point — no status, no body — so
folding it into `uploadOutcome`'s signature would mean inventing a fake status (e.g.
`0`) to stand for "not actually a response," which trades one implicit convention for
another and makes the pure function's contract lie about what it receives.
The existing code already expresses this as the smallest form it can take:
```ts
xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError())));
```
one ternary, deciding between two sentinels based on which native event fired and
whether `cancel()` was called first — not on response content. It is not a second
`uploadOutcome`-shaped decision hiding in a closure; it is a one-line dispatch already.
Extracting it into its own named function would add a call site and an import for a
single ternary with no reachable-only-via-DOM logic left inside it. Left in place, as
DoD point 2 allows.
## Spec added, verified red
`libs/shared/src/infrastructure/upload.adapter.spec.ts` (new file) — plain
`describe`/`it`, no `TestBed`, no DOM, no XHR stub, matching the DoD's explicit
"that is the entire point." Five cases:
1. 2xx status with a valid JSON body → `{ ok: true, value: { documentId } }`.
2. 2xx status with an unparseable body → falls back to the generic `UPLOAD_FAILED`
text (the `JSON.parse`-with-fallback branch).
3. Non-2xx status with a ProblemDetails body → the `detail` field, via `parseError`.
4. Non-2xx status with a body that is not ProblemDetails-shaped → falls back to the
generic text.
5. The 200/300 boundary: 299 is success, 300 is not.
`UPLOAD_FAILED`'s text is not exported (unchanged by this ticket), so the spec holds
its own copy of the Dutch string as a local constant with a comment pointing at the
source — the same trade every other spec makes when asserting against `$localize`
constants that never leave their module ($localize`strings are English-first prose
only where the source is`nl`, so this is the source text as written, not a stand-in).
**Red-proof.** Edited `uploadOutcome`'s body down to a single line —
`return ok({ documentId: JSON.parse(responseText).documentId });`, dropping the
status check and the try/catch — with an `Edit` (not `git checkout`). Ran
`ng test shared`. Result: 4 of the 5 new specs failed:
```
SyntaxError: Unexpected token 'o', "not json" is not valid JSON
uploadOutcome libs/shared/src/infrastructure/upload.adapter.ts:168:32
AssertionError: expected { ok: true, value: { …(1) } } to deeply equal { ok: false, …(1) }
- Expected "error": "Document is al aan een aanvraag gekoppeld.", "ok": false,
+ Received "ok": true, "value": { "documentId": undefined },
SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
AssertionError: expected true to be false // Object.is equality
```
(only the plain 2xx-valid-JSON case still passed, as expected of a mutant that always
reports success). Re-applied the real body with a second `Edit`; `git diff` against
HEAD shows only the intended net change — the red edit left no trace. Re-ran:
163/163 green.
## Coverage, `upload.adapter.ts`
| Metric | Before (TE-005 baseline) | After |
| -------- | ------------------------ | ---------------------- |
| Lines | LH 5 / LF 64 (7.8%) | LH 12 / LF 65 (18.5%) |
| Branches | BRH 3 / BRF 57 (5.3%) | BRH 7 / BRF 59 (11.9%) |
(`LF`/`BRF` grew by one line and two branches because `uploadOutcome` is new source;
`npm run test:coverage`'s shared run, `coverage/shared/lcov.info`, narrowed to this
file's `SF:` block.) The jump is real but modest in absolute percentage: `uploadOutcome`
itself is now fully exercised (`FNDA:6,uploadOutcome`, both branches of the status
check hit, both the try and the catch path hit), but the class methods
(`categoriesResource`, `status`, `deleteDocument`, `xhrUpload`'s own body,
`simulateUpload`) remain unreached — they need DI/XHR/timers to test and are
out of this ticket's scope, exactly as TE-005 scopes it ("extract the interpretation,
not the transport").
## Optional scenario-branch move: not taken
TE-005 suggests, as an explicitly optional second half, moving the `currentScenario()`
branch from `xhrUpload` up into `KeepaliveTransport.send()`
(`libs/shared/src/application/upload-shell.service.ts`) so `xhrUpload` becomes
transport-only. RB-25's handoff confirms the seam is available (`KeepaliveTransport`
is still unexported, `send()` is still an unchanged one-liner) but not required.
This ticket does not take that half, for a reason RB-25's handoff does not settle:
the ticket's own **Scope** section restricts this ticket to `upload.adapter.ts` and its
spec only ("RB-24, RB-25, RB-26, RB-28 have all already merged — nothing else in the
upload module is in flight, so you have the folder to yourself"). Moving the scenario
branch requires editing `upload-shell.service.ts` too — exporting `simulateUpload` (or
moving it) out of `upload.adapter.ts` and importing it into the application-layer
`send()` — which is a second file, outside the stated scope. Doing it anyway would also
widen this single-file ticket's diff for an explicitly optional half the ticket itself
says to skip when it "complicates the diff." The dev simulator's behavior is therefore
byte-for-byte unchanged: `xhrUpload` still checks `currentScenario()` first and still
delegates to the untouched `simulateUpload` for `upload-slow`/`upload-fail`, verified by
inspection (the only edit inside `xhrUpload` is the `load`-listener dispatch) and by the
full `shared` suite staying green, including `upload-shell.service.spec.ts`'s existing
scenario-adjacent assertions.
## Verification
- `npm run lint`: clean.
- `npm run dep:check`: unaffected — the only new import is `@shared/kernel/fp`, already
the repo's shared `Result` module, imported the same way by other `libs/shared`
infrastructure adapters (no new import direction).
- `npm test` / `ng test shared`: 163/163, across 26 spec files — 5 of those tests are
the new `upload.adapter.spec.ts`, the other 158 across 25 pre-existing files are
unchanged by this ticket.
- `npm run ci`: result and step count reported in the implementing agent's final answer.
## Batch 5 close-out
Batch 5 (RB-25 through RB-30) is now fully implemented. For `libs/shared/upload`
(moved to its layered home by RB-24) specifically: `upload.machine.ts` (domain) has its
own spec and `planFileSelection` extracted by RB-26; `upload-shell.service.ts`
(application) has a full spec covering `upload()`/`cancel()`/`delete()`/
`pollReturning()` via the `UPLOAD_TRANSPORT` token RB-25 added; `upload-controller.ts`
(application) was already spec'd before this batch; `upload.adapter.ts`
(infrastructure) now has `uploadOutcome` as a pure, spec'd seam, though the class's
HTTP-bound methods (categories/status/delete/the XHR transport itself) remain
untested by design — XHR is the one boundary this batch deliberately does not
abstract, per TE-005's own instruction. End to end, every layer of the upload module
that can hold pure logic now does, and has a spec proving it; what is left uncovered is
exactly the DOM/network edge the module exists to wrap, not logic hiding behind it.
@@ -0,0 +1,180 @@
# RB-28 — `BLOB_PRESENTER` token unlocks the three blob-to-browser success paths
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-006 ·
`99-backlog.md` RB-28
## What was wrong
Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot
meaningfully execute: `StamdataStore.download()`
(`libs/beheer/src/application/stamdata.store.ts`) did `URL.createObjectURL`
`document.createElement('a')``a.click()``URL.revokeObjectURL`;
`BriefStore.previewLetter()` (`apps/ssp/src/app/brief/application/brief.store.ts`) and
`OrgTemplateStore.proefbrief()` (`apps/ssp/src/app/brief/application/org-template.store.ts`)
both did `window.open(URL.createObjectURL(blob), '_blank')`. Because the call was the
last statement of each command, TE-006 recorded the whole success path as effectively
unassertable, and `download()`'s two-clause guard (`if (!s || !this.canDownload())
return;`) as permanently dark on its true branch.
## What changed
One new file, `libs/shared/src/application/blob-presenter.ts`, mirroring the
`SESSION_PORT` token already in that folder — an interface, a production
implementation, and an `InjectionToken`:
```ts
export interface BlobPresenter {
open(blob: Blob): void;
download(blob: Blob, filename: string): void;
}
const realBlobPresenter: BlobPresenter = {
open(blob) {
window.open(URL.createObjectURL(blob), '_blank');
},
download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
};
export const BLOB_PRESENTER = new InjectionToken<BlobPresenter>('BLOB_PRESENTER', {
providedIn: 'root',
factory: () => realBlobPresenter,
});
```
`open()` never revokes the object URL (the tab it opens outlives the call —
`BriefStore.previewLetter`'s original comment already said so and is preserved,
moved onto the token's own doc comment); `download()` does revoke, once the click has
fired. This asymmetry is preserved deliberately, not unified — the two call sites
behaved differently before this ticket and still do.
Each of the three commands now injects `BLOB_PRESENTER` and calls it instead of the DOM
directly:
| File | Before (last statement) | After |
| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `stamdata.store.ts` | `createObjectURL``createElement('a')``click()``revokeObjectURL` (6 lines) | `this.blobPresenter.download(blob, \`${s.table.id}.json\`);` |
| `brief.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` |
| `org-template.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` |
`OrgTemplateStore.previewUrlFor` (added by RB-24, a different seam — a document
content URL for an `<a href>`, not a blob handoff) is untouched.
## Tests added
**`libs/beheer/src/application/stamdata.store.spec.ts`** — a new
`StamdataStore.download (RB-28)` describe block with a recording fake `BlobPresenter`:
1. Does not call the presenter while `canDownload()` is false because nothing is dirty
yet — the guard's previously-dark true branch, first clause.
2. Does not call the presenter while previewing a date, even with a real edit present —
the guard's true branch, second clause.
3. **The success path**, asserting `toJson(...)`'s exact output reaches the file: reads
the recorded blob's text and compares it byte-for-byte against a direct call to
`toJson(store.table()!, store.rows())`, and asserts the filename is
`professions.json`.
**`apps/ssp/src/app/brief/application/brief.store.spec.ts`** — the existing
`BriefStore.previewLetter` describe block's success test previously spied directly on
`window.open`/`URL.createObjectURL` (both already jsdom-spyable, since the properties
exist even though calling them for real throws "not implemented"). It now provides the
recording fake via `BLOB_PRESENTER` and asserts `opened` holds exactly the resolved
blob — the same outcome, reached through the new seam instead of monkey-patching two
global browser objects.
**`apps/ssp/src/app/brief/application/org-template.store.spec.ts`** (new file —
`OrgTemplateStore` had no spec at all before this ticket) — a
`OrgTemplateStore.proefbrief (RB-28)` describe block: the success path (presenter
receives the resolved blob, no error) and the failure path (presenter never reached,
error surfaced). A `Partial<UploadAdapter>` stub with a no-op `categoriesResource`
(status `'idle'`) satisfies the store's constructor effect without touching the
logo-upload sub-state, which these tests do not exercise.
## Verified red without the fix
Broke `StamdataStore.download()` with an `Edit` (not `git checkout`): changed the
filename from `` `${s.table.id}.json` `` to `` `${s.table.id}.csv` ``. Ran the new
success-path spec:
```
AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality
Expected: "professions.json"
Received: "professions.csv"
libs/beheer/src/application/stamdata.store.spec.ts:134:36
```
Re-applied the correct filename with a second `Edit`; the full `stamdata.store.spec.ts`
file (6 tests) went green again.
## Verification
- **`grep` for remaining DOM blob calls** in all three stores —
`grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)"`
zero matches. The only occurrences of those calls anywhere in `apps`/`libs` are inside
`blob-presenter.ts` itself (checked with a second, unscoped grep — no fourth inlined
handoff exists).
- `npm run lint`: clean.
- `npm run dep:check`: unaffected (no new import direction — `libs/shared` still does not
depend on `libs/beheer`; both `libs/beheer` and `apps/ssp/brief` import the new token
from `libs/shared`, never the reverse).
- `npm test` (all four projects): all pass — ssp 276, behandelportal 37, shared 138,
beheer 26 (up from 23; +3 for the new `download()` describe block).
- Coverage, `npm run test:coverage` narrowed per project:
- `libs/beheer/src/application/stamdata.store.ts`**before** BRH 15 / BRF 37
(40.5% branch, confirmed against the current tree, matching TE-006's citation
exactly); **after** BRH 25 / BRF 37 (**67.6% branch**). `libs/beheer/src/application`
has exactly this one file, so the module figure moves the same way.
- `apps/ssp/src/app/brief/application/brief.store.ts`**before** BRH 39 / BRF 72
(54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23
already added branches (the 404-tolerance path) since the finding was written — see
"What TE-006 got wrong" below. **After**: BRH 39 / BRF 72, unchanged — swapping the
global-spy assertions for the injected fake changes how the success branch is
reached in the spec, not whether it is reached; it was already covered before this
ticket (see below).
- `apps/ssp/src/app/brief/application/org-template.store.ts` — no spec existed before
this ticket, so there is no meaningful "before" branch figure for it specifically.
**After**: BRH 17 / BRF 77, including both `proefbrief()` branches newly covered.
- `npm run ci` (foreground, `timeout: 600000`, no background/Monitor): result reported
in the implementing agent's final answer.
## What TE-006 got wrong
TE-006 states: "`brief.store.spec.ts` demonstrates this exactly: it tests
`previewLetter`'s failure case ... and cannot test the success case." This is not
accurate for the code as it stood at the start of this ticket. The spec already had an
`'opens the composed letter in a new tab on success'` test that used
`vi.spyOn(URL, 'createObjectURL')` and `vi.spyOn(window, 'open')` to assert the success
path — jsdom defines both properties (as functions that throw "not implemented" if
actually invoked), so `vi.spyOn` can already replace them, and the pre-existing test
did. That test passed both before and after this ticket's change; this ticket did not
newly unlock `previewLetter`'s success path, it moved an already-passing assertion off
two hand-spied global browser objects and onto the new injectable seam. `git log
--follow -p` on the spec file shows this test dates to the WP-67 monorepo merge, not to
any of RB-22/23/24.
The seam is still worth having: `StamdataStore.download()`'s success path (five
DOM/API calls in a row: `createObjectURL`, `createElement`, `.href`, `.download`,
`.click()`, `revokeObjectURL`) is a materially harder thing to spy on faithfully than a
single `window.open` call, and was in fact still dark before this ticket (no
`download()` test of any kind existed). `OrgTemplateStore.proefbrief()` also had no
spec at all. TE-006's diagnosis (three commands share the same class of problem, one
token fixes all three) is sound; only the specific "cannot test" claim about
`previewLetter` overstates what was true for that one call site. Scope was not reduced
because of this — all three call sites are migrated per the ticket's own instruction to
ship them together rather than half-adopt the seam.
## What this ticket did not touch
`OrgTemplateStore.previewUrlFor` (RB-24) — confirmed present and unchanged at
`org-template.store.ts:78`. `libs/shared/src/application/upload-shell.service.ts`
(RB-25) and `upload-controller.ts` (RB-26) — not read beyond what RB-24's own note
already described, not edited. `libs/shared/docs/behaviour-spec.mdx` — regenerated by
`npm run gen:behaviour-spec` (part of `npm run ci`) to reflect the new/renamed test
names; never hand-edited.
@@ -0,0 +1,125 @@
# RB-29 — Thread `at` through `LetterHtml.ResolveAuto`'s `datum` case
Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-007 ·
`99-backlog.md` RB-29
## What was wrong
`LetterHtml.Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)`
(`backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs`) already took the letter's
instant and used it correctly for the letterhead date
(`sb.Append(Enc(FormatDatumNl(at)))`). The body's `datum` placeholder resolved through
the private `ResolveAuto(string key, string label)`, which ignored `at` and called
`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` instead — a pure `Domain/` rule
class reading the wall clock. `ResolveAuto` is reached only through the private chain
`RenderNode``RenderParagraphs``Render`, so no caller outside this file could pin
the value a test would see.
The ticket read as filed against the current code: `Render`'s signature, the letterhead's
correct use of `at`, and `ResolveAuto`'s `UtcNow` read were all exactly as TE-007
described (line numbers had moved — CC around the file has grown since the finding was
written — but the code shape had not). One thing TE-007 named as the visible symptom
also checked out: `LetterHtmlTests.cs` already declares a
`new PlaceholderDefDto("datum", "Datum", true)` in its golden-file fixture, but no
`RichTextNodeDto` in that fixture's `Sections` actually references the `datum` key in
the body — it is declared but never rendered there, so the existing golden-file test
could not have caught this even if it asserted on dates (which it does not either).
## What changed
| File | Change |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` | `ResolveAuto(string key, string label)``ResolveAuto(string key, string label, string at)`; `"datum" => FormatDatumNl(at)`. `at` threaded down through the two private call sites in the chain: `RenderParagraphs` and `RenderNode` both gained an `at` parameter, passed from `Render`'s own `at`. |
| `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` | New fixture `FixtureBriefWithDatumInBody()` — a minimal brief whose body actually references the `datum` placeholder (the golden fixture never does). Two new `[Fact]`s (see below) plus two small extraction helpers. |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-29 status cell: `open``implemented`. |
`Render`'s own signature is unchanged — TE-007's "zero public API change, zero
call-site change" held exactly. `Program.cs:697`, `:708`, and `BriefStore.cs:120` (the
three callers) needed no edit.
## What the fix looks like
```csharp
private static void RenderParagraphs(
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs,
string at)
{
// ... unchanged body, forwards `at` to RenderNode ...
}
private static void RenderNode(
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs, string at)
{
// ... unchanged body, forwards `at` to ResolveAuto ...
}
private static string ResolveAuto(string key, string label, string at) => key switch
{
"naam_zorgverlener" => SeedData.Registration.Naam,
"big_nummer" => SeedData.Registration.BigNummer,
"datum" => FormatDatumNl(at),
_ => label,
};
```
Both call sites already had `at` in scope (`Render`'s own parameter), so this is a pure
threading change — no new state, no new dependency.
## Tests added
TE-007 named the exact gap: the golden-file fixture declares the `datum` placeholder but
never renders it in the body, so no existing assertion could catch a body/letterhead
mismatch. A new fixture and two focused tests close it:
1. **`Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock`** —
renders `FixtureBriefWithDatumInBody()` with a fixed historical `at`
(`2019-03-14T08:00:00.0000000+00:00`) and asserts the body's rendered paragraph is the
exact string `"14 maart 2019"`. A test using today's date would have passed before
and after the fix and proven nothing — this one pins a date nowhere near "now", so it
fails whenever the resolver reads the wall clock instead of `at`.
2. **`Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at`**
— same fixture and historical `at`, asserts the letterhead `<dd>` date and the body's
rendered `datum` paragraph are equal. This is TE-007's stated payoff: not a shipped
bug today (every current caller passes `Now()` at render time, so the two dates always
coincided even with the bug present), but a latent one — the moment `Render` is ever
called with a historical `at` (re-rendering an archive, back-dating a letter), the
letterhead and body would disagree within a single document. This test is the one
that would have caught that.
Both tests use the repo's one date formatter (`FormatDatumNl`, already used by both call
sites under test) only indirectly, through the literal expected string `"14 maart
2019"` — no second hand-rolled `ToString` format was introduced in the test file either.
## Verification
- **Verified red without the fix.** Reverted only the `ResolveAuto` expression (via
`Edit`, not `git checkout`) back to
`"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))`, leaving the new
tests and the threaded signatures in place. Ran the two new tests:
```
Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock [FAIL]
Assert.Equal() Failure: Strings differ
Expected: "14 maart 2019"
Actual: "27 augustus 2026"
Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at [FAIL]
Assert.Equal() Failure: Strings differ
Expected: "14 maart 2019"
Actual: "27 augustus 2026"
```
Both failures show the body rendering the run's actual wall-clock date (today,
2026-08-27) instead of the pinned historical `at` — the precise defect TE-007
describes. Restored the fix with a second `Edit` and re-ran: all 4 tests in
`LetterHtmlTests` green (2 pre-existing + 2 new).
- `grep -n "UtcNow\|DateTime.Now\|DateTime.Today\|DateTimeOffset.Now"
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` — no matches. No ambient
clock read remains anywhere in the file.
- `npm run ci` (foreground, no background/Monitor): see result reported alongside this
ticket.
## What this ticket did not touch
`LetterHtml.cs`'s overall structure and CC (TE-007 records it at 21, the third-highest
in the backend) are unchanged — reducing that is out of scope for this ticket, per its
own text. `Data/BriefStore.cs` and any `BriefRules.cs` file were not touched — a
concurrent ticket owns that file.
@@ -0,0 +1,210 @@
# RB-30 — extract `BriefStore`'s guards into `Domain/Letters/BriefRules.cs`
Status: **implemented** · 2026-08-27 · Source finding: `02-testability.md` TE-008 ·
`99-backlog.md` RB-30
RB-30 moves the brief workflow's five guard decisions out of `BriefStore` (a
lock-held, DB-opening static store) into a pure `Domain/Letters/BriefRules.cs`, and
adds a free-running unit test file for them. This is a pure extraction: the store
keeps its lock, its `Db.Create()`, its static shape, and every method's signature.
## What was wrong
Five guard clusters in `Data/BriefStore.cs` are pure decisions over `(status tag,
actor role, entity completeness)` — Save, Submit, Send, and the shared Approve/Reject
review path each start with an `if` cascade that is a function of two enums and a
bool. But every one of those `if`s sat inside a method that had already done `lock
(_gate) { using var db = Db.Create(); ... }`, so a spec could not exercise the
decision without a booted host and a real SQLite file. `Domain/Letters/` held only
`LetterHtml.cs` and `OrgTemplateRules.cs`; there was no `BriefRules` class, even
though `Authz.CanActOn` — a pure `Domain/Authorization/` call one line away from
three of the guards — already proved the pattern worked for this exact file.
## What changed
| File | Change |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs` | New. Five pure statics: `CanSave`, `StatusAfterSave`, `RequiredFilled` + `CanSubmit`, `CanSend`, `CanDecide`. All take `BriefStatusDto`/`bool`/`Principal`/`string`, never `BriefEntity` — no persistence type reaches this file. |
| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `Save`, `Submit`, `Send`, and the private `Review` (the Approve/Reject shared path) each replace their inline `if` cascade with one call into `BriefRules`, then branch only on the returned `Outcome`. The private `RequiredFilled(BriefEntity e)` helper is deleted — `BriefRules.RequiredFilled(IReadOnlyList<LetterSectionDto>)` replaces it. Lock, `Db.Create()`, method signatures, and the public `Outcome` enum are all unchanged. |
| `backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs` | New. 29 `[Fact]`/`[Theory]` assertions covering every branch of all five rules — see "Tests added" below. |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-30's status cell: `open``done`. |
## The surface, as built — and where it differs from TE-008's proposal
TE-008 proposed:
```
CanSave(BriefStatusDto status, bool isDrafter) → Outcome
StatusAfterSave(BriefStatusDto) → BriefStatusDto
CanSubmit(status, isDrafter, bool requiredFilled) → Outcome
CanSend(status)
CanDecide(status, Principal, drafterId)
```
The ticket explicitly names this a proposal, not a specification. What was built
matches it almost exactly, with two adjustments forced by the real code:
- **`Outcome` is `BriefStore.Outcome`, not a new type.** `BriefStore` already
exposes a public `enum Outcome { Ok, Forbidden, Conflict }`, and `Program.cs`'s
`BriefResult` switches on it directly across every brief endpoint. TE-008 itself
says: "if `Outcome` does not already exist as a domain concept, use whatever the
sibling rule classes already return" — it does exist, so `BriefRules` returns it
rather than inventing a second result shape. This does mean `Domain/Letters/`
references a type nested in `Api.Data`; the same cross-reference already exists in
this file's neighbor, `LetterHtml.cs` (`using BigRegister.Api.Data;`, for
`BriefEntity`), and in `Authz.cs` (for `BriefStore`'s role-id constants) — both in
the same single-assembly project, so this is a namespace convention, not an
assembly boundary. `Outcome` itself is a plain three-value enum with no EF/ASP.NET
attached, so this does not pull a persistence type into `Domain/`.
- **`CanDecide` takes an explicit `BriefAction action` parameter**, not just
`(status, Principal, drafterId)`. The real guard — `BriefStore.Review` — is one
private method shared by both `Approve` and `Reject`, and it calls
`Authz.CanActOn(action, principal, drafterId)`, which needs to know which action is
being attempted. `BriefRules.CanDecide` composes that existing pure
`Authz.CanActOn` call with the status check, rather than re-implementing the SoD
logic a second time — so the four-eyes rule still has exactly one source of truth.
The `RequiredFilled` predicate is a sixth pure static, not one of the five guards
proper — TE-008 names it separately ("plus the `RequiredFilled(e)` predicate") and it
is built the same way: `RequiredFilled(IReadOnlyList<LetterSectionDto> sections) →
bool`, taking the section list rather than the entity.
## Order and behaviour preserved
Every rule keeps the original check order, which matters because `Outcome.Forbidden`
must outrank `Outcome.Conflict` (a non-drafter or non-entitled caller sees Forbidden
even against an otherwise-invalid status):
- `CanSave`: `!isDrafter` (Forbidden) before the status-tag check (Conflict).
- `CanSubmit`: `!isDrafter` (Forbidden) before `status.Tag != "draft" ||
!requiredFilled` (Conflict).
- `CanDecide`: `!Authz.CanActOn(...)` (Forbidden) before `status.Tag != "submitted"`
(Conflict) — the exact order the old inline check in `Review` used, per its own
comment ("checked BEFORE the status guard").
- `CanSave`'s entity-not-found branch (`e is null → Conflict`) stays inline in
`BriefStore` — it is a persistence fact ("no row for this owner"), not one of the
three business axes TE-008 names (status tag, actor role, entity completeness), so
it was left where it was rather than forced into a rule that would then need to
accept a nullable entity.
## Tests added
`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, 29 assertions, alongside
the seven Domain test files that already existed:
- **`CanSave`** — drafter saves draft/rejected (Ok, `[Theory]`); drafter saves
submitted/approved/sent (Conflict, `[Theory]`); non-drafter saves draft or submitted
(Forbidden both times — proves role beats status).
- **`StatusAfterSave`** — rejected → draft; draft stays draft.
- **`RequiredFilled`** — no sections; an unfilled optional section; a filled required
section; an unfilled required section; one filled + one unfilled required section
(proves one bad section blocks the whole letter).
- **`CanSubmit`** — filled draft (Ok); unfilled draft (Conflict — **the required-filled
gate the ticket explicitly asked for**); non-draft status (Conflict); non-drafter
on a filled draft (Forbidden — role beats completeness).
- **`CanSend`** — approved (Ok); draft/submitted/rejected/sent (Conflict, `[Theory]`).
- **`CanDecide`** — approver decides a submitted letter drafted by someone else, for
both Approve and Reject (Ok, `[Theory]`); a drafter attempting to decide (Forbidden
**the non-drafter denial the ticket asked for**); an approver whose acting id
equals the drafter id, i.e. self-review (Forbidden — the four-eyes/SoD case); an
approver deciding a non-submitted letter (Conflict); an approver who is also the
drafter AND the status is non-submitted (Forbidden, not Conflict — proves the
priority order survived the extraction).
## Verified red without the fix
Inverted `CanSubmit`'s completeness check (`!requiredFilled``requiredFilled`) with
an `Edit`, ran `BriefRuleTests` alone:
```
[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_not_submit_an_unfilled_draft [FAIL]
Assert.Equal() Failure: Values differ
Expected: Conflict
Actual: Ok
[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_submit_a_filled_draft [FAIL]
Assert.Equal() Failure: Values differ
Expected: Ok
Actual: Conflict
Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29
```
Reverted with a second `Edit` (never `git checkout` — that would have discarded the
whole file). Reran: 29/29 green.
## Existing tests — unchanged
`BriefEndpointTests.cs`, `PreviewEndpointTests.cs`, and `OrgTemplateEndpointTests.cs`
(the three host-booting suites that exercise the brief endpoints) needed **no
changes**. Ran together: 32/32 passing, proving the extraction preserved every HTTP
outcome (`Save_is_drafter_only`, `Submit_blocks_on_empty_required_section`,
`Submit_succeeds_when_required_sections_filled`,
`Drafter_cannot_approve_own_letter_but_a_different_reviewer_can`,
`Reject_returns_comments`, `Editing_a_rejected_letter_reopens_it_to_draft`,
`Send_only_from_approved`, and the rest, all unmodified).
## The metric TE-008 cares about: host-booting brief-rule assertions
Before this ticket, the five guard decisions had **zero** free-running unit
assertions. Every branch of every guard was reachable only through the seven
host-booting endpoint test methods above (six of them containing an explicit
`Assert.Equal(HttpStatusCode.Forbidden/Conflict, ...)`, each paying a full
`TestWebApplicationFactory` host boot plus a real SQLite round-trip, run serially
process-wide because of `[assembly: DisableTestParallelization]`).
After this ticket:
- **0 → 29** free-running unit assertions covering these branches
(`BriefRuleTests.cs`, `dotnet test --filter FullyQualifiedName~BriefRuleTests`
completes in **~120 ms**, no host, no SQLite file).
- **7 → 7** host-booting endpoint tests, unchanged. They stay — they are now the
proof that `BriefStore` wires `BriefRules`'s answer to the right HTTP status, not
the only place the business decision itself is checked. That split (wiring proven
at the integration layer, decision logic proven at the unit layer) is the seam
TE-008 argued for.
- New branches this ticket made assertable that the endpoint suite never covered
directly: the SoD self-review case (`An_approver_may_not_decide_a_letter_they_drafted_themselves`)
and the Forbidden-beats-Conflict priority ordering for both `CanSave`/`CanSubmit`
(role checked first) and `CanDecide` (entitlement checked first) — these existed as
implicit behaviour in the original `if` cascades but had no assertion pinning them
before RB-30.
## What was not extracted
Nothing — all five guards named in TE-008, plus the `RequiredFilled` predicate, moved
cleanly. None needed the `DbContext`: each was already a function of values already
resident on the in-memory `BriefEntity` (its `Status`, `Sections`, `DrafterId`), never
of a query against the database itself.
## Scope respected
- `Domain/Letters/LetterHtml.cs` was not touched (a concurrent agent owns it).
- `BriefEntity.ToDto()` was not touched — its CC 16 is a separate, out-of-scope
finding per the ticket.
- `Data/Db.cs`'s static-store decision and `TestWebApplicationFactory`'s serialized-test
position were not challenged; the store's lock, `Db.Create()`, and public shape are
byte-for-byte the same as before this ticket, other than the `if` cascades moving
out.
## Verification
- `dotnet build`: 0 warnings, 0 errors.
- `dotnet test --filter FullyQualifiedName~BriefRuleTests`: 29/29, ~120 ms.
- `dotnet test --filter FullyQualifiedName~BriefEndpointTests|...PreviewEndpointTests|...OrgTemplateEndpointTests`:
32/32, unchanged.
- Full backend suite: **291/292 passing**, plus the one known, pre-existing,
container-dependent failure
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
"Connection refused (localhost:8000)") — not this ticket's bug, does not run under
`npm run ci`, reproduces on a clean tree with no OpenZaak container running.
- `npm run ci` (foreground, no background/Monitor): see the commit message / session
report for the exit code and step count.
## What this ticket did not touch
No frontend file was touched — the brief workflow's status machine is server-
authoritative, and the FE's own pure reducer (mirroring these same transitions for
UX) was already out of this ticket's scope. No file outside `backend/Data/BriefStore.cs`,
`backend/Domain/Letters/BriefRules.cs`,
`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, and `99-backlog.md` was
changed.
@@ -0,0 +1,155 @@
# RB-31 — replay real messages in the four hand-rolling machine specs
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-010 ·
`99-backlog.md` RB-31
RB-31 replaces four hand-rolled state-literal fixtures with `given(reduce, initial)`
replays, per ADR-0006 §2 ("no object is built directly; a fixture is the result of
running real `Msg`s through the real `reduce`"). This is a fixture-construction change
only. No `*.machine.ts` production file was touched.
## What was wrong
Four machine specs built their starting `Answering`/`Invullen`/`Editing`/`loaded` state
with a local object-literal helper instead of replaying messages:
- `intake.machine.spec.ts``answering(answers, cursor, scholingThreshold)` hardcoded
`errors: {}`. `intake.testing.ts` (exporting `givenIntake`) already existed next to
it and was already correct, but was imported only by `intake.acceptance.spec.ts`.
- `registratie-wizard.machine.spec.ts``invullen(draft, cursor)` hardcoded `errors: {}`
and `upload: initialUpload`.
- `besluit.machine.spec.ts``editingWith(besluit, toelichting)` hardcoded `errors: {}`.
- `brief.machine.spec.ts``loaded(status, sections)` built the `'loaded'` tag object
directly (no `errors` field on this union, so this one did not hardcode `errors: {}`,
but it still skipped the reducer).
## What changed
| File | Change |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts` | Removed the local `answering(...)` helper. Every fixture is now built with the existing `givenIntake` (imported from `intake.testing.ts`), matching `intake.acceptance.spec.ts`'s own style. |
| `apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts` | New. One-liner: `export const givenRegistratieWizard = given(reduce, initial)`. |
| `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` | Removed the local `invullen(...)` helper and the now-unused `validAdres`/`validDraft`/`Draft`/`initialUpload` fixtures. Added module-level replay helpers (`toAdresValid`, `toBeroepStep`, `toBeroepStepWithDiploma`, `toControleStep`, `toIndienen`, `toFullDraftAtCursor0`) built from `givenRegistratieWizard` + `reduce`, reused across every `describe` block (the file's pre-existing `reduce (message-driven happy path)` block already had three of these, scoped locally; they are now module-level and shared, removing the duplication). |
| `apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts` | New. One-liner: `export const givenBesluit = given(reduce, initial)`. |
| `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts` | Removed the local `editingWith(...)` helper. Every fixture is now built with `givenBesluit` (or, for the empty-draft case, the machine's own `initial` — see below). |
| `apps/ssp/src/app/brief/domain/brief.testing.ts` | New. One-liner: `export const givenBrief = given(reduce, initial)`. |
| `apps/ssp/src/app/brief/domain/brief.machine.spec.ts` | Rewrote the `loaded(...)` helper to replay a real `BriefLoaded` message through `givenBrief` instead of building the `'loaded'` tag object directly. Also converted one further inline `BriefState` literal in the "deep-copies content" test to the same replay (same anti-pattern, same file, not named individually by the finding's evidence list but visibly the same shape — see "Beyond the letter of the finding" below). |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-31's status cell: `open``implemented`. |
## Message sequence used per machine
### `intake.machine.spec.ts`
Every fixture in this file has `cursor` 0, 1, or 2 and the default or an overridden
`scholingThreshold`. All are built as direct sequences of `SetAnswer`/`Next`/`SetPolicy`
through `givenIntake`, mirroring `intake.acceptance.spec.ts`'s own explicit style (no new
generic wrapper was added — the finding's own resolution is "wire the spec to
`givenIntake`", not "invent a second helper"). Representative sequences:
- Cursor 0, plain answers (most tests): `givenIntake({SetAnswer buitenlandGewerkt}, {SetAnswer uren}, ...)`.
- Cursor 0 with an overridden threshold: adds a trailing `{tag:'SetPolicy', scholingThreshold: N}`.
- Cursor 1 ("editing an answer leaves the cursor fixed"): `SetAnswer buitenlandGewerkt=ja`,
`SetAnswer land`, `SetAnswer buitenlandseUren`, `Next` (buitenland step now valid ->
cursor 1), then the edit under test.
- Cursor 2 ("gaNaarStap jumps back..."): the above sequence continued with
`SetAnswer uren=4160`, `Next` (werk step valid -> cursor 2).
No drift found here: `intake.testing.ts` already existed correctly, and every one of the
nine cursor/threshold combinations the old literal used turned out to be reachable by a
real message sequence.
### `registratie-wizard.machine.spec.ts`
- `toAdresValid()` = `PrefillAdres(straat, postcode, woonplaats)`, `SetCorrespondentie('post')`
— cursor 0, matches the old `invullen(validAdres)`.
- `toBeroepStep()` = `reduce(toAdresValid(), Next)` — cursor 0 -> 1, no diploma. Matches
`invullen(validAdres, 1)`.
- `toBeroepStepWithDiploma()` = `reduce(toBeroepStep(), KiesDiploma('d1','Arts',[]))`
cursor 1, diploma set. Matches `invullen(validDraft, 1)`.
- `toControleStep()` = `reduce(toBeroepStepWithDiploma(), Next)` — cursor 1 -> 2. Matches
`invullen(validDraft, 2)`.
- `toFullDraftAtCursor0()` = `PrefillAdres`, `SetCorrespondentie('post')`, `KiesDiploma(...)`,
never advancing the cursor — matches `invullen(validDraft)` (cursor 0). `SetField`/
`SetCorrespondentie`/`KiesDiploma` carry no cursor gate, so setting every field before
ever pressing `Next` is a genuinely reachable cursor-0 state with a complete draft.
- `invullen({})` (five call sites) is exactly the machine's own `initial` value
(`{tag:'Invullen', draft:{antwoorden:{}}, cursor:0, errors:{}, upload:initialUpload}`)
— replaced with `initial` directly, no message needed.
### `besluit.machine.spec.ts`
- `editingWith('')` is exactly `initial` (`draft:{besluit:'',toelichting:''}`) — replaced
with `initial` directly.
- `editingWith('Afwijzen')` / `editingWith('Goedkeuren')` = `givenBesluit({SetField besluit})`.
- `editingWith('Afwijzen', ' niet erkend ')` = `givenBesluit({SetField besluit=Afwijzen}, {SetField toelichting=' niet erkend '})`.
- Every `Submitting`/`Failed` fixture is now `givenBesluit({SetField besluit}, {Submit})`
composed further with `reduce(..., {SubmitFailed}/{Retry}/{Reset})`.
### `brief.machine.spec.ts`
- `loaded(status, sections)` = `givenBrief({tag:'BriefLoaded', brief: briefWith(status, sections), availablePassages: lib, decisions})`.
This is a 1:1 replacement: the `'BriefLoaded'` reducer case sets exactly
`{tag:'loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions}`
— the same three fields the old literal built by hand, with the same values. No drift.
## Drift found
Two tests in `registratie-wizard.machine.spec.ts` asserted against a cursor value the
real reducer cannot reach:
- `'validateAll keeps only the answers to the questions that applied'` built
`invullen(validAdres, 2)` then called `kiesDiploma(...)` on it — i.e. a wizard already
at cursor 2 (`controle`) with **no diploma chosen yet**. That is impossible by replay:
advancing past `beroep` (cursor 1 -> 2) requires `validateStep('beroep', ...)` to pass,
which requires `diplomaId` and `beroep` to already be set. The literal encoded a state
the reducer can never produce.
- `'requires a declared beroep + all maximal questions before submit'` had the same
problem: `invullen(validAdres, 2)` then `kiesHandmatig(...)`, which leaves `beroep`
`undefined` — again a cursor-2 state that could never have been reached via `Next`.
In both cases the cursor value is not actually load-bearing for the test: `submit()`
calls `validateAll(s.draft, s.upload)`, which validates every step regardless of
`s.cursor`. Both tests were re-pointed at the reachable **cursor-1** equivalent
(`toBeroepStep()` then `kiesDiploma`/`kiesHandmatig`), with an inline `// DRIFT (see
rb-31.md)` comment at each site. No assertion changed — both tests still check the same
`submit(...)` outcome on the same field values; only the now-irrelevant cursor number
in the starting fixture moved from an unreachable 2 to a reachable 1.
No other named state, across any of the four machines, turned out to be unreachable.
## Beyond the letter of the finding
`brief.machine.spec.ts`'s `'BesluitSelected deep-copies content...'` test built a second,
separate `BriefState` literal inline (not through the `loaded(...)` helper the finding
cited) — same anti-pattern, same file, not itself named in ADR-C-010's evidence list.
Since it sits inside one of the four files already being brought into line, and the fix
is the identical one-line `BriefLoaded` replay, it was converted too rather than left as
a residual violation in a file this ticket otherwise fixed. No other spec, in any other
file, was touched.
## `intake.acceptance.spec.ts` — confirmed unaffected
`intake.testing.ts` and its `givenIntake` export were not modified. The acceptance spec
still imports and uses `givenIntake` exactly as before; it was not read or edited by
this ticket beyond confirming (by running it) that it still passes.
## Verification
- `npx ng test ssp`: 44 test files, 276 tests, all passing (includes
`intake.machine.spec.ts`, `intake.acceptance.spec.ts`,
`registratie-wizard.machine.spec.ts`, `brief.machine.spec.ts`, and every other ssp
spec, unmodified ones included).
- `npx ng test behandelportal`: 6 test files, 37 tests, all passing (includes
`besluit.machine.spec.ts`).
- `npx eslint` on all seven touched/added files: clean.
- `npx prettier --check` on all seven touched/added files: clean (one file needed
`--write` once, then verified clean).
- `npm run ci`: see the commit message / session report for the exit code and step
count.
## What this ticket did not touch
No `*.machine.ts` reducer or production domain file was changed — every fixture change
is confined to the four `*.spec.ts` files and the three new `*.testing.ts` files listed
above. No other machine spec (including `change-request.machine.spec.ts`, which the
finding notes already honours the idiom inline) was touched.
@@ -0,0 +1,86 @@
# RB-32 — add the missing `language-switcher` row to the CIBG gap register
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md`
ADR-C-008 · `99-backlog.md` RB-32 · Depends on
`implementation/adr-c-007.md` (the same file, left one row short on purpose,
filed forward as this ticket)
## What was wrong
ADR-0003 §Consequences' final bullet requires every `// CIBG-GAP EXTENSION:`
marker in code to have a row in `libs/shared/docs/cibg-gaps.mdx`, "so it's
auditable rather than silently drifting." `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`) but the register table had no row for it.
## Verified before editing
```
grep -rln "CIBG-GAP EXTENSION" apps libs --include=*.ts | wc -l # 9
```
Nine files carry the marker: `debug-state`, `language-switcher`, `wizard-shell`,
`application-link`, `placeholder-chip`, `rich-text-editor`, `skeleton`,
`spinner`, `status-badge`. The register table had 8 rows, and `language-switcher`
was the one missing — matching ADR-C-008's finding exactly, re-verified rather
than trusted from the finding's own snapshot (per this ticket's DoD point 1, and
per `adr-c-007.md`'s own note that it left this exact row for a later ticket).
## What changed
One row added to `libs/shared/docs/cibg-gaps.mdx`'s register table, matching the
existing rows' two-column shape (component name, closest CIBG concept, reason —
wording taken from the component's own marker comment, not invented):
| Component | Closest CIBG concept | Why hand-rolled |
| ------------------- | -------------------- | -------------------------------------------------------------------------------------------------- |
| `language-switcher` | Taal instellen | No vendored Huisstijl class ships for it; a small hand-rolled surface built from the token bridge. |
No other row was touched. The 8 existing rows were each re-checked against their
component's current marker comment while the file was open; none needed a change.
## Optional CI script: taken
ADR-C-008 flags a ~10-line `grep -l CIBG-GAP | diff`-style script folded into
`check:tokens` as an explicitly optional second step. It is genuinely small and
fits the existing script's shape, so `scripts/check-tokens.sh` gained one more
guard after its existing hardcoded-colour check:
```bash
gap_register='libs/shared/docs/cibg-gaps.mdx'
markers=$(grep -rl 'CIBG-GAP EXTENSION' apps libs --include='*.component.ts' | xargs -n1 dirname | xargs -n1 basename | sort -u)
rows=$(grep -oP '^\| `\K[^`]+' "$gap_register" | sort -u)
missing=$(comm -23 <(echo "$markers") <(echo "$rows"))
if [ -n "$missing" ]; then
echo "$missing"
echo "FAIL: CIBG-GAP EXTENSION marker(s) with no row in $gap_register"
exit 1
fi
echo 'OK: every CIBG-GAP EXTENSION marker has a cibg-gaps.mdx row'
```
The marker's component directory basename (`dirname` of the flagged file,
`.component.ts` files only) is compared against the table's backtick-quoted
first column, extracted with `grep -oP`. This matches for all 9 current
markers, including the two rows with a parenthetical suffix
(`` `wizard-shell` (error summary only) ``, `` `application-link` (non-navigating
row) ``) — the regex stops at the closing backtick, before the parenthetical.
**Verified working, not just written.** Added a scratch component
(`libs/shared/src/ui/scratch-gap-test/scratch-gap-test.component.ts`, a single
`// CIBG-GAP EXTENSION:` line plus a dummy export) with no matching row. Ran
`bash scripts/check-tokens.sh`: failed with exit 1, printing `scratch-gap-test`
and the expected `FAIL:` line. Deleted the scratch file and its directory (`rm`,
not `git checkout` — it was never tracked). Re-ran the script: passed, exit 0.
`git status` confirms no trace of the scratch file remains.
## Verification
- `npm run check:tokens` (includes the new guard): passes — 9 markers, 9 rows,
after the register row was added.
- `npm run ci`: result and step count reported in the implementing agent's final
answer.
- No code changed outside `libs/shared/docs/cibg-gaps.mdx`,
`scripts/check-tokens.sh`, `99-backlog.md`, and this note — a doc-only ticket
plus its optional, self-verifying guard script.
@@ -0,0 +1,100 @@
# RB-33 — `unwrapOk`: adopt or delete
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-011 ·
`99-backlog.md` RB-33
## Decision: delete
The ticket names this "adopt or delete", not "adopt", and asks for the judgment call, not
the default. I deleted `unwrapOk`.
## Why delete, not adopt
`unwrapOk` (`libs/shared/src/testing/value-object.ts`) has had zero consumers across the
whole codebase since ADR-0006 shipped it, except its own definition and one sentence in
`libs/shared/docs/testing.mdx`. I verified this before changing anything:
```
grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx
libs/shared/docs/testing.mdx:92: ...unwrapOk(parseX(raw))...
libs/shared/src/testing/value-object.ts:9:export function unwrapOk<E, T>(...)
libs/shared/src/testing/value-object.ts:11: throw new Error(`unwrapOk: ...`);
```
The one call site the finding names,
`apps/ssp/src/app/registratie/application/submit-change-request.spec.ts`, still has the
exact hand-rolled guard the finding quotes:
```ts
const telefoon = parseTelefoonnummer('0612345678');
if (!telefoon.ok) throw new Error('fixture phone should parse');
```
I also checked whether any other spec has the same shape, in case the finding's "one call
site" undercounted the real duplication:
```
grep -rln "if (!.*\.ok)\s*throw" apps libs --include=*.spec.ts
apps/ssp/src/app/registratie/application/submit-change-request.spec.ts
```
Only this one file, anywhere. There is no cast (`'x' as Telefoonnummer`) to close off
either — the spec already calls the real `parseTelefoonnummer` and checks `.ok` before
touching `.value`. ADR-0006 §3's actual requirement ("never a cast") is already met by the
inline code, with or without the helper.
Weighing it honestly:
- **For adopt:** it is a one-line change, and the ADR's own worked example literally shows
this exact call. Doing it would make the finding's "zero adopters" claim technically
false.
- **For delete:** a helper that gains its _only_ real-codebase consumer by an agent adding
that one call site as an act of ticket compliance is not organic adoption — it is
manufacturing a usage to justify keeping the file. `unwrapOk` has sat available, exported,
and documented since ADR-0006 (well before this session) without a single spec reaching
for it on its own. One caller, forever, is not "removing duplication" (the stated point
of a shared test helper) — there is no duplication with only one occurrence. The inline
guard is also arguably clearer here: its error message (`'fixture phone should parse'`)
names the actual fixture, where `unwrapOk`'s generic message
(`unwrapOk: expected ok, got error: ...`) does not.
Delete wins: it removes dead, unadopted code and its stale doc reference, changes no
runtime behaviour anywhere, and costs nothing to reverse if a second real need for this
idiom shows up later (three lines, trivial to re-add against actual duplication instead of
a single hypothetical site).
## What changed
| File | Change |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `libs/shared/src/testing/value-object.ts` | Deleted. Its only export, `unwrapOk`, is what this ticket removes; the file had nothing else in it. |
| `libs/shared/docs/testing.mdx` | Rewrote the sentence that named `unwrapOk` and the deleted file's path. It now states the same rule in plain terms — call the real `parse*` and check `.ok`, never a cast — and keeps the `RemoteData` half of the sentence pointing at `remote-data.ts` (unchanged, still in use). |
| `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` | **Not touched.** Its inline guard already satisfies ADR-0006 §3; this is the "delete" branch, so the fixture-construction behaviour stays exactly as it was. |
| `99-backlog.md` | RB-33's status cell: `open``implemented`. |
## What this ticket did not touch
`docs/reference/architecture/0006-test-data-builders.md` (the ADR itself) still shows
`unwrapOk` in its worked example and decision table. That is deliberate: RB-33 is a code
ticket, not one of the five ADR-fix tickets that need architect sign-off
(`06-adr-conformance.md`'s "ADR-fix tickets" section). The ADR's illustrated pattern
("call the real parser, unwrap through a checked path, never a cast") is still the correct
principle — this ticket only removes one now-unused concrete implementation of it, which
the inline guard in `submit-change-request.spec.ts` already satisfies without the named
helper. Amending the ADR's own text is out of this ticket's scope and is left for a future
ADR-fix ticket if one is ever raised. The finding document (`06-adr-conformance.md`) and the
historical WP-70/WP-71 backlog notes that mention `unwrapOk` are left as-is — they are
records of what was true when written, not living code.
No other file in `libs/shared/src/testing/` was touched (`expect-tag.ts`, `machine.ts`,
`remote-data.ts` are all unrelated and still have real consumers).
## Verification
- `grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx` — zero occurrences.
- `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` — unchanged file,
still passes (see `npm run ci` result below).
- No new test added. The ticket is a deletion of unused code plus a doc-sentence rewrite;
the surviving inline guard in the spec is exercised the same way it always was, by the
spec's three existing `it` blocks.
- `npm run ci` (foreground): see the session report for the exit code and step count.
@@ -0,0 +1,255 @@
#!/usr/bin/env node
// Baseline scanner for 00-baseline.md: duplication % and C# cyclomatic complexity.
// Both are the same crude text scan, so they share one file.
//
// ponytail: line-window hashing, not token-based like jscpd, and regex method
// detection that does not understand C# expression-bodied members or nested
// lambdas. Deterministic and zero-install, which is what a *baseline* needs.
// Upgrade path: jscpd for duplication, a Roslyn analyzer for C# complexity —
// only if a ticket's before/after needs more precision than "did it move".
//
// Usage: node baseline-scan.mjs [--dup] [--complexity] (default: both)
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, relative } from 'node:path';
const ROOT = new URL('../../../../..', import.meta.url).pathname.replace(/\/$/, '');
const SKIP_DIR =
/(^|\/)(node_modules|dist|coverage|bin|obj|\.angular|\.git|storybook-static.*|TestResults|public|openzaak)$/;
const SKIP_FILE = /(api-client\.ts|\.Designer\.cs|AppDbContextModelSnapshot\.cs)$/;
const SCAN = ['apps', 'libs', 'backend/src', 'backend/tests', 'e2e'];
const WINDOW = 6; // duplicate = >= 6 consecutive normalized lines seen elsewhere
function walk(dir, out = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e);
if (SKIP_DIR.test(p)) continue;
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|cs)$/.test(p) && !SKIP_FILE.test(p)) out.push(p);
}
return out;
}
// Module attribution: the unit the baseline table reports on.
function moduleOf(rel) {
let m;
if ((m = rel.match(/^apps\/(ssp|behandelportal)\/src\/app\/([^/]+)\//)))
return `${m[1] === 'ssp' ? 'ssp' : 'bhp'}/${m[2]}`;
if (rel.startsWith('apps/ssp/')) return 'ssp/root';
if (rel.startsWith('apps/behandelportal/')) return 'bhp/root';
if ((m = rel.match(/^libs\/(shared|beheer)\/src\/([^/]+)\//))) return `libs/${m[1]}/${m[2]}`;
if (rel.startsWith('libs/')) return rel.split('/').slice(0, 2).join('/');
if (rel === 'backend/src/BigRegister.Api/Program.cs') return 'backend/Program.cs';
if ((m = rel.match(/^backend\/src\/BigRegister\.Api\/([^/]+)\//))) return `backend/${m[1]}`;
if (rel.startsWith('backend/tests/')) return 'backend/tests';
if (rel.startsWith('e2e/')) return 'e2e';
return 'other';
}
const files = SCAN.flatMap((d) => {
try {
return walk(join(ROOT, d));
} catch {
return [];
}
}).map((p) => ({ abs: p, rel: relative(ROOT, p) }));
// --- Duplication ------------------------------------------------------------
// Normalize away formatting/comments, hash every WINDOW-line run, and mark a run
// duplicated when its hash occurs in more than one place.
function duplication() {
const norm = new Map(); // rel -> [{line, text}]
for (const f of files) {
const kept = [];
readFileSync(f.abs, 'utf8')
.split('\n')
.forEach((raw, i) => {
const t = raw
.replace(/\/\/.*$/, '')
.replace(/\s+/g, ' ')
.trim();
if (t.length > 3) kept.push({ line: i + 1, text: t });
});
norm.set(f.rel, kept);
}
const seen = new Map(); // hash -> [{rel, idx}]
for (const [rel, lines] of norm)
for (let i = 0; i + WINDOW <= lines.length; i++) {
const h = createHash('sha1')
.update(
lines
.slice(i, i + WINDOW)
.map((l) => l.text)
.join('\n'),
)
.digest('hex');
(seen.get(h) ?? seen.set(h, []).get(h)).push({ rel, idx: i });
}
const dupLines = new Map(); // rel -> Set(line)
const clones = new Map(); // "relA|relB" -> count
for (const occ of seen.values()) {
if (occ.length < 2) continue;
for (const { rel, idx } of occ) {
const set = dupLines.get(rel) ?? dupLines.set(rel, new Set()).get(rel);
for (let k = 0; k < WINDOW; k++) set.add(norm.get(rel)[idx + k].line);
}
const pair = [...new Set(occ.map((o) => o.rel))].sort();
if (pair.length > 1) {
const key = pair.slice(0, 2).join(' | ');
clones.set(key, (clones.get(key) ?? 0) + 1);
}
}
const per = new Map(); // module -> {total, dup}
for (const [rel, lines] of norm) {
const mod = moduleOf(rel);
const e = per.get(mod) ?? per.set(mod, { total: 0, dup: 0 }).get(mod);
e.total += lines.length;
e.dup += dupLines.get(rel)?.size ?? 0;
}
console.log('## Duplication (normalized lines, window=' + WINDOW + ')\n');
console.log('| Module | Sig. lines | Duplicated | % |');
console.log('|---|---:|---:|---:|');
let T = 0,
D = 0;
for (const [mod, e] of [...per].sort((a, b) => b[1].dup / b[1].total - a[1].dup / a[1].total)) {
T += e.total;
D += e.dup;
console.log(`| ${mod} | ${e.total} | ${e.dup} | ${((100 * e.dup) / e.total).toFixed(1)}% |`);
}
console.log(`| **TOTAL** | **${T}** | **${D}** | **${((100 * D) / T).toFixed(1)}%** |`);
console.log('\n### Top clone pairs (distinct duplicated windows)\n');
for (const [pair, n] of [...clones].sort((a, b) => b[1] - a[1]).slice(0, 15))
console.log(`- ${n} ×${pair}`);
}
// --- C# cyclomatic complexity (approximate) ---------------------------------
// Two numbers per file. FILE CC (sum of decision points) is exact enough to trust.
// Per-METHOD CC uses depth-aware regex detection and is the approximate one.
const BRANCH =
/\bif\s*\(|\bwhile\s*\(|\bfor\s*\(|\bforeach\s*\(|\bcase\s+|\bcatch\s*[({]|\?\?|&&|\|\||\?\.|\bwhen\s+/g;
const TYPE_DECL =
/^\s*(?:\[[^\]]*\]\s*)*(?:public|private|internal|protected|static|sealed|abstract|partial|file|\s)*\b(?:class|record|struct|interface|enum|namespace)\b/;
const NOT_A_CALL =
/^(if|for|foreach|while|switch|catch|using|lock|return|throw|new|await|yield|else|do|fixed|checked)$/;
// A member: optional attrs/modifiers, a return type, a name, then `(`.
const SIG =
/^\s*(?:\[[^\]]*\]\s*)*(?:(?:public|private|internal|protected|static|async|override|virtual|sealed|partial|extern|new|unsafe)\s+)*[\w<>,\[\]?.]+\s+(\w+)\s*(?:<[^>()]*>)?\s*\(/;
function branchesIn(line) {
return (line.replace(/\/\/.*$/, '').match(BRANCH) ?? []).length;
}
function csComplexity() {
const rows = [];
const fileCc = [];
for (const f of files.filter((f) => f.rel.endsWith('.cs'))) {
const lines = readFileSync(f.abs, 'utf8').split('\n');
let depth = 0,
cur = null,
total = 1,
code = 0;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
const bare = l.replace(/\/\/.*$/, '');
const b = branchesIn(l);
total += b;
if (bare.trim().length > 1 && !/^\s*(\/\/|\/\*|\*)/.test(l)) code++;
const opens = (bare.match(/{/g) ?? []).length;
const closes = (bare.match(/}/g) ?? []).length;
if (cur) {
cur.lines++;
cur.cc += b;
// Expression-bodied member: `=> expr;` with no block of its own.
if (cur.depth === null && /=>/.test(bare)) {
if (/;\s*$/.test(bare) && opens === closes) {
rows.push(cur);
cur = null;
} else if (opens > closes) cur.depth = depth;
} else if (cur.depth === null && opens > closes) cur.depth = depth;
else if (cur.depth !== null && depth + opens - closes <= cur.depth) {
rows.push(cur);
cur = null;
}
} else if (!TYPE_DECL.test(l)) {
const m = SIG.exec(bare);
// `Name(` must not be a call/keyword, and the line must not be a statement.
if (
m &&
!NOT_A_CALL.test(m[1]) &&
!/^\s*(var|return|await)\b/.test(bare) &&
!/;\s*$/.test(bare.replace(/=>.*/, ''))
)
cur = {
file: f.rel,
name: m[1],
line: i + 1,
cc: 1 + b,
lines: 1,
depth: opens > closes ? depth : null,
};
}
depth += opens - closes;
}
if (cur) rows.push(cur);
fileCc.push({ file: f.rel, cc: total, code });
}
const p = (arr, q) =>
arr.slice().sort((a, b) => a - b)[Math.min(arr.length - 1, Math.floor(q * arr.length))] ?? 0;
const perFile = new Map();
for (const r of fileCc) {
const mod = moduleOf(r.file);
(perFile.get(mod) ?? perFile.set(mod, []).get(mod)).push(r);
}
console.log('\n\n## C# complexity — per module\n');
console.log(
'| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 method CC | CC>10 |',
);
console.log('|---|---:|---:|---:|---:|---:|---:|---:|');
const perMethod = new Map();
for (const r of rows) {
const mod = moduleOf(r.file);
(perMethod.get(mod) ?? perMethod.set(mod, []).get(mod)).push(r);
}
for (const [mod, fs] of [...perFile].sort(
(a, b) => Math.max(...b[1].map((r) => r.cc)) - Math.max(...a[1].map((r) => r.cc)),
)) {
const ms = perMethod.get(mod) ?? [];
const ccs = ms.map((r) => r.cc);
console.log(
`| ${mod} | ${fs.length} | ${fs.reduce((a, r) => a + r.cc, 0)} | ${Math.max(...fs.map((r) => r.cc))} | ` +
`${ms.length} | ${ccs.length ? Math.max(...ccs) : 0} | ${p(ccs, 0.9)} | ${ms.filter((r) => r.cc > 10).length} |`,
);
}
console.log('\n### C# files by CC (top 12)\n');
console.log('| File CC | Code lines | File |');
console.log('|---:|---:|---|');
for (const r of fileCc.sort((a, b) => b.cc - a.cc).slice(0, 12))
console.log(`| ${r.cc} | ${r.code} | ${r.file} |`);
console.log('\n### C# methods over CC 10 (approximate detection)\n');
console.log('| CC | Lines | Method | Location |');
console.log('|---:|---:|---|---|');
for (const r of rows.filter((r) => r.cc > 10).sort((a, b) => b.cc - a.cc))
console.log(`| ${r.cc} | ${r.lines} | \`${r.name}\` | ${r.file}:${r.line} |`);
const all = rows.map((r) => r.lines);
console.log(
`\nMethod-length distribution (n=${rows.length}): p50 ${p(all, 0.5)}, p90 ${p(all, 0.9)}, p99 ${p(all, 0.99)}, max ${Math.max(...all)}`,
);
}
const args = process.argv.slice(2);
const all = args.length === 0;
if (all || args.includes('--dup')) duplication();
if (all || args.includes('--complexity')) csComplexity();
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Sets up the multi-agent refactoring backlog workspace.
# Run from the root of the target repo: bash setup.sh
set -euo pipefail
WORK_DIR="./refactor-backlog"
AGENTS_SRC="$(dirname "$0")/agents"
PROTOCOL="$AGENTS_SRC/_persistence-protocol.md"
echo "Setting up $WORK_DIR ..."
mkdir -p "$WORK_DIR/implementation"
mkdir -p "$WORK_DIR/final-prompts"
# 1. Initialize _status.md
cat > "$WORK_DIR/_status.md" << 'EOF'
# Agent run status
| Agent | Status | Last module processed | Last updated | Notes |
|---|---|---|---|---|
| baseline | not_started | - | - | |
| readability | not_started | - | - | |
| testability | not_started | - | - | |
| ddd-hexagonal | not_started | - | - | |
| cqrs-light | not_started | - | - | |
| bdd | not_started | - | - | |
| adr-conformance | not_started | - | - | |
| bio2-compliance | not_started | - | - | |
| consolidation | not_started | - | - | |
EOF
echo " created _status.md"
# 2. Initialize empty output files with headers
for f in 00-baseline 01-readability 02-testability 03-ddd-hexagonal \
04-cqrs-light 05-bdd 06-adr-conformance 07-bio2-compliance; do
cat > "$WORK_DIR/${f}.md" << EOF
## Scope: [to be filled by agent]
## Status: not_started
## Last updated: -
## Depends on: [see agent prompt]
## ---
EOF
done
touch "$WORK_DIR/99-backlog.md"
echo " initialized 8 phase-1 output files + 99-backlog.md"
# 3. Assemble final prompts: inject persistence protocol into each agent prompt
# at the "[Insert contents of _persistence-protocol.md here...]" marker.
for src in "$AGENTS_SRC"/*.prompt.md; do
name="$(basename "$src")"
out="$WORK_DIR/final-prompts/$name"
awk -v protofile="$PROTOCOL" '
/\[Insert contents of _persistence-protocol\.md here/ {
while ((getline line < protofile) > 0) print line
close(protofile)
next
}
{ print }
' "$src" > "$out"
echo " assembled final-prompts/$name"
done
echo ""
echo "Done. Structure:"
echo " $WORK_DIR/_status.md (run tracker — all agents not_started)"
echo " $WORK_DIR/00-baseline.md ... 07-bio2-compliance.md (empty, agent-writable)"
echo " $WORK_DIR/99-backlog.md (empty, Consolidation writes here)"
echo " $WORK_DIR/implementation/ (Phase 3 notes land here)"
echo " $WORK_DIR/final-prompts/ (ready-to-dispatch prompts, protocol"
echo " already merged in — no manual copy-paste)"
echo ""
echo "Next: dispatch final-prompts/00-baseline.prompt.md first (blocks everything),"
echo "then the 7 Phase 1 prompts in parallel, then 08-consolidation.prompt.md,"
echo "then per-ticket 09-implementation.prompt.md (fill in TICKET-ID each time)."