docs: refactoring-backlog workspace — baseline + 3 Phase 1 agents

Runs the multi-agent refactoring-backlog pipeline in docs/project/
refactor-backlog-setup/ up to and including three of the seven Phase 1
agents.

00-baseline.md establishes the metrics every later agent must cite, using
only tooling already in the repo (vitest lcov, coverlet cobertura, ESLint's
core `complexity` rule at threshold 0 for a full distribution, depcruise
--metrics). Duplication and C# complexity had no tooling, so
tools/baseline-scan.mjs adds a deterministic ~200-line text scan rather
than a new dependency; the approximations are labelled as such.

Headline: FE 75.1% line coverage but only over the 98 of 220 source files a
spec loads; BE 97.6% line / 79.6% branch; 0 layering violations; 7.1%
duplication; 25 of 2085 TS functions over CC 10.

Then 02-testability, 04-cqrs-light and 06-adr-conformance (27 findings).
01/03/05 were skipped deliberately — the baseline shows little for them to
find; 07 (BIO2) and 08 (consolidation) are still open.

Each agent corrected a baseline observation of mine, and in every case the
error was in something derived rather than measured:

- BL-007 counted ~13 adapter "mutations" from the `runSubmit` helper name;
  5 of those call sites are reads. It also missed 3 real mutations that
  reach the raw ApiClient and never return a Result.
- BL-002 diagnosed the 100%-duplicated auth folders as ADR-0002's
  divergence prediction failing. It never had a chance to fail: §3's
  `Principal` union was never built.
- BL-004 named libs/shared/domain and libs/beheer/contracts as coverage
  gaps; both are pure type declarations where 0% is unimprovable.

All three corrections are recorded inline in 00-baseline.md §10, so agent
08 does not inherit the bad numbers.

.prettierignore excludes the agent prompt directories — reflowing their
markdown would edit the prompt text itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-26 16:44:32 +02:00
co-authored by Claude Opus 5
parent 0ad02e651a
commit 664a43bf2d
36 changed files with 3488 additions and 0 deletions
@@ -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,9 @@
## Scope: [to be filled by agent]
## Status: not_started
## Last updated: -
## Depends on: [see agent prompt]
## ---
@@ -0,0 +1,13 @@
# 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 | not_started | - | - | |
| 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 | not_started | - | - | |
| 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 | not_started | - | - | |
| 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 | not_started | - | - | |
| consolidation | not_started | - | - | |
@@ -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,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,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();