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>
40 KiB
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-108derivescanEdit/canApprove/canReject/canSend/canRevealBigNummerpurely fromBriefState.loaded.decisions, with an explicit comment at line 31 ("this store never computes them itself"). Same shape inapps/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:52takesscholingThresholdas a parameter;SCHOLING_THRESHOLD_DEFAULT(line 43) survives only as the offline fallback the ADR sanctions, wired throughapplication/intake-policy.store.ts:18-19. No hardcoded1000is 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>, anddep:check'sui-not-infrastructurerule 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.
findreturns nothing forpublic/mock/dashboard-view.json,public/mock/intake-policy.json, orsrc/app/herregistratie/contracts/intake-policy.dto.ts.apps/ssp/public/mock/does not exist. The surviving contract file moved toapps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts(WP-67'ssrc/→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:apigenerateslibs/shared/src/infrastructure/api-client.ts(2372 lines, NSwag) and CI drift-checks it.
- There is a real backend:
- Baseline citation: baseline §2 (
Program.cs940 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", citeapps/ssp/src/app/registratie/{contracts/dashboard-view.dto.ts,infrastructure/dashboard-view.adapter.ts}andGET /api/v1/dashboard-view/GET /api/v1/intake/policyas the endpoints, and reduce §"Out of scope" to the two items still genuinely open (theBigProfileStoreoptimistic-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.tsinjectsApiClient(line 3 import, line 56inject(ApiClient)) and opens a rawXMLHttpRequestat line 118 — i.e. it is a genuine network adapter — yet sits in a top-levelupload/folder, not underlibs/shared/src/infrastructure/. Its siblingupload.machine.tsis an Elm-style reducer sitting outside anydomain/folder (the only one of 9 machines to do so). The exception is hard-coded into the enforcement itself:.dependency-cruiser.base.js:105readsfrom: { 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 adomain/folder; outlierlibs/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/, andupload-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/domaincurrently 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/uploadhas 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-clientdirectly. Crucially, the surviving hand-written contract documents its own obsolescence:dashboard-view.dto.ts:10-11says "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-writtencontracts/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 4contracts/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 bynpm run gen:api, drift-checked in CI) is the wire contract and the single source of truth; a hand-writtencontracts/*.dto.tsis warranted only where codegen does not reach — a hand-rolledfetch/XHR endpoint, or a shape the generator types too loosely — and in either case the hand-written file must still import nothing. Theparse*trust boundary ininfrastructure/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
Sessionthe day a second actor arrives"; §Consequences — "The one concrete FE change when actor #2 lands isSession → Principalin theauthcontext"; §"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=*.tsreturns 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.tsis byte-identical to the SSP's and still readsinterface 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:31isasync login(bsn: string), rendersintro="Log in op uw persoonlijke BIG-register omgeving.", and callsSessionStore.login(bsn)→auth/infrastructure/digid.adapter.ts:14, which returnsok({ 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-19stampsX-Medewerker/X-Rollenonto every/api/v1/request. It never touchesSession.
- Baseline citation: BL-002 (
ssp/auth211/211 significant lines duplicated;session.store.ts39 windows,login-form.component.ts35,login.page.ts23) — 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 → Principalas ADR-0002 §3 specifies. Inapps/behandelportal: replaceSessionwith themedewerkervariant, replaceDigidAdapterwith aMedewerkerAdapterthat resolvesMEDEWERKER_ID+currentRollen()(the valuesmedewerker.tsalready holds) into aPrincipal, and makelogin.page.tsan SSO-stand-in entry rather than a BSN form. Inapps/ssp: thezorgverlenervariant. This makes the twoauthcontexts 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/behandelportalexists 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-appandshared-no-beheerrules run atseverity: errorwith 0 violations. An architectural decision that CI enforces is not "Proposed". The other five ADRs are allAccepted, 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 (11severity: errorrules, 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 /mecapabilities,Domain/Authz.cs). The one bullet that stays is real authentication. Leave theSession → Principalbullet 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
ProposedADR 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
Principaland the login flow ("the two groups authenticate differently"); §Consequences namesauth.guard.tsandsession.store.tsonly 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 inlibs/shared. - Evidence (verified):
diff -ru apps/ssp/src/app/auth apps/behandelportal/src/app/authreports 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.tsis entirely actor-agnostic:authGuard(lines 8-12) reads onlySessionStore.isAuthenticated()andRouter;capabilityGuard(lines 25-33) adds onlyAccessStore.can(capability)andwhenReady()— andAccessStorealready lives inlibs/shared/src/application. Both apps' routes redirect to the same/loginand/dashboardpaths (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.ts36 windows andauth.guard.ts21 windows, i.e. 57 of the 211 duplicated lines, the single largest block aftersession.store.ts. - Proposed resolution: move
authGuard/capabilityGuard+auth.guard.spec.tstolibs/shared/src/application/(or alibs/shared/src/routing/), injectingSESSION_PORTinstead of the app-localSessionStore. One small widening is needed:libs/shared/src/application/session.port.ts:9-12currently exposes onlysession: Signal<{naam: string} | null>andlogout()— addreadonly isAuthenticated: Signal<boolean>(or have the guard derive it fromsession() !== null, which bothSessionStores already do atsession.store.ts:40). Each app keeps a two-line re-export at@auth/auth.guardso itsapp.routes.tsis untouched. - Explicitly NOT proposed, and why. No ticket to merge
session.store.ts,session.ts,digid.adapter.ts,login-form.component.tsorlogin.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 thePrincipalseam 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 (landPrincipal) → re-measure. My expectation is that post-ADR-C-004 the residualssp/auth↔bhp/authduplication 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.alertand.navbar, soapp-alertis 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.htmlandapps/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-alertclaim is factually wrong about the current code.libs/shared/src/ui/alert/alert.component.ts:13-16documents 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 noCIBG-GAPmarker — 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.
- Every path moved in WP-67:
- 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
.alertexample 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 —
skeletonandspinnerare the cleanest (both in the register, both genuinely absent from the vendored build) — and note that.alertwas subsequently resolved onto the vendored.feedbackclasses, 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-GAPmarker (debug-state,language-switcher,wizard-shell,application-link,placeholder-chip,rich-text-editor,skeleton,spinner,status-badge); the register table atlibs/shared/docs/cibg-gaps.mdx:26-33has 8 rows.libs/shared/src/layout/language-switcher/language-switcher.component.ts:7-9carries 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/layoutis a cruised module with Ca 22). - Proposed resolution: add the
language-switcherrow (CIBG concept: "Taal instellen"; reason: no vendored class in this build). Optionally add the CI script the register declines — a ~10-linegrep -l CIBG-GAP | diffinscripts/folded intocheck:tokenswould 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.csis 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 likeOrgTemplateStore, same single-gate idiom." It exposesSet(key, enabled)writingdb.FeatureFlags, surfaced through the admin page/beheer/functies(apps/behandelportal/src/app/app.routes.ts:44-50, gated bycapabilityGuard('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, andIsEnabledfails 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
OrgTemplateStoreandFeatureFlagStore; baseline §3c (backend/Stamdata96.8% line / 71.7% branch;backend/Data99.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) andFeatureFlagStore(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).grepforOpenZaakZaakSource|OpenZaakDocumentSourceoutsideZgw/returns only the two DI registrations inProgram.cs:67,69and eight explanatory comments — no ZGW shape reaches a consumer. - The config-switched port pair is exactly as decided:
Program.cs:59-84readsZgw:Enabledand binds eitherOpenZaakZaakSource/OpenZaakDocumentSource(viaAddHttpClient, with the WP-60 15s timeouts) orLocalZaakSource/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 sameApplicationSummaryDtois 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:465confirms "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/Zgwis 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.csexists 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
RemoteDatanamed constructors —libs/shared/src/testing/remote-data.tsexists. - §5 e2e actors/seed-refs —
e2e/support/actors.tsexists; no page-object layer was added. - No
'x' as BrandedTypecast appears in any spec (grepforas Postcode|as Uren|as BigNummer|as Bsnin*.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
Msgs through the realreduce") and the Decision table's "Pure reducer / state machine (frontend)" row: Do not — "A literal returning{ tag: 'Editing', ... }by hand"; plus §Consequences ("a hardcodederrors: {}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.tsexists in the same folder, exportsgivenIntake, and is the ADR's own quoted example (§2's code block isexport const givenIntake = given(reduce, initial)) — yetgivenIntakeis imported only byintake.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', …})) andapps/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:6honours the idiom but declaresgiven(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/herregistratie70.9% line / 67.8% branch andssp/brief75.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.tsnext to each of the four machines (registratie-wizard,brief,besluit, plus adopt the existingintake.testing.ts), each a one-linerexport 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.tsis 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):
unwrapOkis defined inlibs/shared/src/testing/value-object.tsand referenced nowhere else inapps/orlibs/exceptlibs/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-9readsconst telefoon = parseTelefoonnummer('0612345678'); if (!telefoon.ok) throw new Error('fixture phone should parse');— semantically identical tounwrapOk(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/testingis one of the few modules at 100% reach, so a helper here that nothing imports is measurably dead weight, not merely unexercised); baseline §3alibs/shared/testing3 files, 100% line. - Proposed resolution: one-line change in
submit-change-request.spec.tsto callunwrapOk. 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.