Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
41 KiB
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:
- BL-004's carve-out is honoured. No finding is filed against a
ui/(orlayout/component) file for lacking a Vitest spec. 66 Storybook stories + the a11y addon are the house strategy (CLAUDE.md §5), not a gap. - "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. - 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 callslocalStorage.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 thelocalStorageglobal 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 boundaryparse*adapters. - Baseline citation. §3a:
ssp/auth42.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 | nullinauth/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 toparseStoredSession(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/authcarries the identical function; the seam lands twice, once per app. That is correct, not duplication to fix — ADR-0002 / CLAUDE.md §1 makeauthdeliberately 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 anawait fetch(...)on the globalfetch(L24). There is no injected transport. To assert that a{ bigNummer: 42 }response is rejected, a spec must stubglobalThis.fetch; the boundary itself is not callable. Every otherparse*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-fetchadapters:letter-preview.adapter.ts:56-62(errorMessage) andorg-template.adapter.ts:82-89(the proefbrief error mapping) — both un-exported, both unreachable without afetchstub. - Baseline citation. §3b:
ssp/brief42% spec reach (11 of 26 files) — the lowest of any non-zero context outsidebhp/behandeling; §3a 75.3% line / 68.8% branch. All threefetchadapters are among the 15 unreached files, and none of them is aui/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 becomesreturn res.ok ? parseRevealed(await res.json().catch(() => null)) : err(...). Same move forerrorMessagein the other two adapters (already separate functions; they only needexport+ a spec). No transport abstraction, noHttpClientmigration — the hand-writtenfetchstays, 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::trailForis a pure exported function with a subtle parent-walk and adelete trail[last].linkmutation, and has no spec. No blocker — it is directly callable, and its siblinglanguage-switcher/locale-links.tsis the spec'd proof. Missing test, not blocked test.route-focus.tsis a 20-lineENVIRONMENT_INITIALIZERwrapping aRoutersubscription andafterNextRender. 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-24and:35 - What blocks unit testing. The file defines
export interface UploadTransportand documents it as the swap seam ("swapping it in touches only this interface", L10-11). It then binds it asprivate transport: UploadTransport = inject(KeepaliveTransport)(L35) — the concrete class, which is@Injectablebut 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 ofupload(),delete(),cancel()andpollReturning()— the code that translates transport outcomes intoUploadMsgs — is reachable only through a realXMLHttpRequest. The port exists on paper and does nothing. - Baseline citation. §3a:
libs/shared/upload52.0% line / 50.0% branch — the worst line coverage of any module except the twoauthrows. §3b: 50% reach, and per the lcov file list the two unreached files areupload-shell.service.tsandupload-controller.ts— neither is aui/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) }), theninject(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), aneffect()registration (L31), andwindow.addEventListener('focus', onFocus)(L45). It must therefore run inside aTestBedinjection context withUploadAdapter,UploadShellServiceandDestroyRefall satisfied — andUploadShellServiceis 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 emitFileRejectedwith reason'multiple',FileRejectedwith arejectReasonresult, or to start an upload — a decision over(categories, categoryId, files)with no I/O in it. - Baseline citation. §3a
libs/shared/upload52.0% / 50.0%; §3b 50% reach with this file among the two unreached. §4a additionally records the module'smax CC 27and the repo's only two >75-line functions includereduceUpload(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[]toupload.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 thecrypto.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.parseof the body with a fallback (L132-136), ProblemDetails mapping via the un-exportedparseError(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 oncurrentScenario()at L115 and returns asetTimeout-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 plusparseError. The listener becomes a two-line dispatch into it. Optionally (same ticket, still small) move thecurrentScenario()branch from L115 up intoKeepaliveTransport.send()— the seam TE-003 makes usable — soxhrUploadis transport only. Do not abstractXMLHttpRequest: the file documents why XHR is required (progress events + cancellation, whichfetchcannot give) and that reason still holds. - Effort S for
uploadOutcomealone, 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; alsoapps/ssp/src/app/brief/application/brief.store.ts:230andapps/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()doesURL.createObjectURL→document.createElement('a')→a.click()→URL.revokeObjectURL;BriefStore.previewLetter()andOrgTemplateStore.proefbrief()both dowindow.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.tsdemonstrates this exactly: it testspreviewLetter's failure case (which returns at the!r.okguard) and cannot test the success case. Indownload()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/application40.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 confirmsstamdata.store.tsis that row: LH 46 / LF 70, BRH 15 / BRF 37. On the brief side, §3assp/briefis 68.8% branch andbrief.store.tsmeasures BRH 32 / BRF 64 — exactly 50%. - Minimal seam. One small injectable in
libs/shared/src/application, mirroring theSESSION_PORTtoken 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 (includingtoJson(...)'s output actually reaching the file, which today is only tested one level down inbeheer/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.tsis 30 lines ofinterfaceandtypedeclarations with zero executable statements and, by design, zero imports (it is the wire seam). Likelibs/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/parseRowsall 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'sdatumplaceholder resolves throughResolveAuto, which ignoresatand callsFormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))(L138).ResolveAutoisprivate static, reached only viaRenderNode←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:26declares anew PlaceholderDefDto("datum", "Datum", true)in the fixture and no assertion anywhere in the file checks what it renders to. This is a pureDomain/rule class reaching for ambient state, which is precisely the purity §7 credits the folder with. - Baseline citation. §3c:
backend/Domain82.0% branch — the third-weakest branch axis (BL-005). §4b:Domain/Letters/LetterHtml.csfile CC 21, the third-highest-CC file in the backend afterProgram.cs(78) andData/ApplicationStore.cs(27). - Minimal seam. Thread the parameter that already exists:
ResolveAuto(string key, string label, string at)→"datum" => FormatDatumNl(at), passingatdown throughRenderParagraphs/RenderNode(both private, both already inRender's call chain withatin scope). Two signature changes, one expression change, zero public API change, zero call-site change. Then assert the rendereddatumagainst a fixed expected string inLetterHtmlTests. - Secondary benefit, stated conservatively. This is not a shipped bug today — every
caller (
Program.cs:697,:708,BriefStore.cs:120) passesNow()at render time, so the two dates coincide. It becomes one the momentRenderis called with a historicalat(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 theRequiredFilled(e)predicate - What blocks unit testing. Each guard is a pure decision over
(status tag, actor role, entity completeness)— e.g.SavereturnsForbiddenif!isDrafter,Conflictunless the status isdraftorrejected, and reopens arejectedletter todraft;Submitadditionally requiresRequiredFilled. But each sits inside a method that has already donelock (_gate) { using var db = Db.Create(); ... }, so exercising any of them requires a booted host and a real SQLite file. There is noBriefRulesclass:Domain/Letters/contains onlyLetterHtml.csandOrgTemplateRules.cs. The pattern is visibly half-applied —Authz.CanActOnat L163 is a pureDomain/call, sitting one line away from three guards that are not. TheSaveguard'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/Data75.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.csfile CC 17, and itsToDtoat CC 16 is the highest-CC non-Program.csmethod in the backend. §5:backend/Data7.7% duplication. - Minimal seam. Add
Domain/Letters/BriefRules.cswith 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, itsDb.Create(), its static shape and its signature; only theifcascade moves. This extends the pattern §7 already records forSubmissionRules/BeoordelingRules/OrgTemplateRules/DocumentRules, and adds atests/Domain/BriefRuleTests.csalongside the seven that exist. - Explicitly NOT proposed: changing the static-store shape.
Data/Db.cs:6-12documents the static, non-DI store decision, andtests/TestWebApplicationFactory.cs:1-12states 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.ConnectionStringis 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 ofBriefStoremoves 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.
ByProgramis astatic readonly IReadOnlyDictionarywhose initializer runsMappings.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. theActiveOncall 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/Stamdata96.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.csfile 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(...);thenpublic static readonly IReadOnlyDictionary<string,string> ByProgram = ByProgramOn(DateOnly.FromDateTime(DateTime.Today));. Zero call-site changes —DiplomaRules.ProfessionForandAll()keep usingByProgram.StamdataValidationTestsgains the ability to assert both validity-window branches against authored future/expired rows. - This extends an existing pattern in the same folder.
StamdataTable.cs:63already does exactly this —Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows(), withonas a parameter — andStamdataFile.ActiveOn(van, tot, on)(:36) is already clock-free.Professions.csis the one caller that swallows the parameter. - Effort S. Independently shippable; additive only.
backend/tests
No findings. 39 files, 4 253 lines, 241 green tests, every backend source file
reached (§3c). The suite already carries the fixtures a unit lane needs
(Builders/AanvraagBuilder.cs, ZgwStubHandler.cs, TestWebApplicationFactory with
per-class throwaway SQLite files).
The one structural observation is not a defect to fix here:
[assembly: CollectionBehavior(DisableTestParallelization = true)]
(TestWebApplicationFactory.cs:12) serializes the entire suite because
Db.ConnectionString is a process-global. The repo reached that decision deliberately
and documented the race it prevents. Rather than reopen it, TE-008 and TE-009 reduce how
much needs to run in that serialized lane. Note also §4b's outlier in this folder: a
293-line test method at CC 20
(CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back,
OpenZaakZaakSourceTests.cs) — a test-readability item for agent 01, not a testability
seam.
Summary
| ID | Title | Module | Blocker | Baseline | Effort | 1 deploy |
|---|---|---|---|---|---|---|
| TE-001 | SessionStore.restore() reads localStorage inline |
ssp/auth + bhp/auth | hidden I/O in a field initializer; guard is module-private | §3a 42.9%/46.2% (worst line); file LH 2/20, BRH 3/13 | S ×2 | yes |
| TE-002 | Trust boundary hidden inside a global-fetch method |
ssp/brief | un-exported shape validation behind await fetch |
§3b 42% reach; §3a 68.8% branch | S | yes |
| TE-003 | UploadTransport port declared, concrete class injected |
libs/shared/upload | inject(KeepaliveTransport); class not exported |
§3a 52.0%/50.0%; file unreached (§3b, non-ui/) |
S | yes |
| TE-004 | createUploadController injects + binds window at call time |
libs/shared/upload | 3× inject(), effect(), addEventListener before returning |
§3a 52.0%/50.0%; file unreached (§3b, non-ui/) |
S | yes |
| TE-005 | xhrUpload interprets responses inside an XHR closure |
libs/shared/upload | new XMLHttpRequest() hard-coded; dev simulator shares the method |
file LH 5/64 (7.8%), BRH 3/57 (5.3%) | S–M | yes |
| TE-006 | Blob-to-browser handoff inlined in 3 commands | libs/beheer + ssp/brief | window.open / a.click() as the last statement of each command |
§3a beheer/application 40.5% branch (worst); brief.store 50% | S–M | yes |
| TE-007 | LetterHtml resolves datum from UtcNow, not from at |
backend/Domain | ambient clock in a private resolver inside a pure rule class | §3c Domain 82.0% branch; §4b file CC 21 | S | yes |
| TE-008 | Brief transition rules live inside DB-opening store methods | backend/Data | 5 pure guards behind lock + Db.Create() |
§3c Data 75.5% branch (BL-005); §4b CC 17, ToDto CC 16 |
M | yes |
| TE-009 | Professions.ByProgram freezes valid-time at type-load |
backend/Stamdata | static readonly + DateTime.Today; both branches unreachable |
§3c Stamdata 71.7% branch (BL-005) | S | yes |
Modules with no findings: ssp/registratie · ssp/herregistratie · ssp/showcase+shell+root · bhp/behandeling · bhp/shell+root · libs/shared/{domain, application, infrastructure, ui, layout, kernel, testing} · libs/beheer/{domain, infrastructure, ui, contracts} · backend/Program.cs · backend/Zgw · backend/Contracts · backend/tests.
Baseline rows recommended for closure as false gaps: libs/shared/domain (0% reach,
3 files) and libs/beheer/contracts (0% reach, 1 file) — both named in BL-004 as genuine
gaps; both contain only type declarations and no executable statement.
Cross-references, not owned here: BL-001 complexity (agent 01) · BL-002 auth
duplication (agent 06) · BL-003 Program.cs structure (agents 03/04) · BL-006 backend
architecture enforcement (agent 03) · BL-007 write-side placement (agent 04) · BL-008
coverageExclude · BL-009 no coverage ratchet — which means none of the findings above
can be verified as "improved" by CI alone; verify against 00-baseline.md's numbers ·
BL-010 libs/shared/upload layer placement (TE-003/004/005 all land inside that
carve-out and do not resolve it) · BL-011 suite flakiness under parallel load.