From 664a43bf2d05df38c258337b35a4bad227614933 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 16:44:32 +0200 Subject: [PATCH 01/61] =?UTF-8?q?docs:=20refactoring-backlog=20workspace?= =?UTF-8?q?=20=E2=80=94=20baseline=20+=203=20Phase=201=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .prettierignore | 4 + docs/project/refactor-backlog-setup/README.md | 63 ++ .../agents/00-baseline.prompt.md | 22 + .../agents/01-readability.prompt.md | 15 + .../agents/02-testability.prompt.md | 14 + .../agents/03-ddd-hexagonal.prompt.md | 16 + .../agents/04-cqrs-light.prompt.md | 15 + .../agents/05-bdd.prompt.md | 14 + .../agents/06-adr-conformance.prompt.md | 16 + .../agents/07-bio2-compliance.prompt.md | 24 + .../agents/08-consolidation.prompt.md | 35 + .../agents/09-implementation.prompt.md | 33 + .../agents/_persistence-protocol.md | 25 + .../refactor-backlog/00-baseline.md | 547 +++++++++++++++ .../refactor-backlog/01-readability.md | 9 + .../refactor-backlog/02-testability.md | 641 ++++++++++++++++++ .../refactor-backlog/03-ddd-hexagonal.md | 9 + .../refactor-backlog/04-cqrs-light.md | 543 +++++++++++++++ .../refactor-backlog/05-bdd.md | 9 + .../refactor-backlog/06-adr-conformance.md | 582 ++++++++++++++++ .../refactor-backlog/07-bio2-compliance.md | 9 + .../refactor-backlog/99-backlog.md | 0 .../refactor-backlog/_status.md | 13 + .../final-prompts/00-baseline.prompt.md | 46 ++ .../final-prompts/01-readability.prompt.md | 39 ++ .../final-prompts/02-testability.prompt.md | 38 ++ .../final-prompts/03-ddd-hexagonal.prompt.md | 40 ++ .../final-prompts/04-cqrs-light.prompt.md | 39 ++ .../final-prompts/05-bdd.prompt.md | 38 ++ .../06-adr-conformance.prompt.md | 40 ++ .../07-bio2-compliance.prompt.md | 48 ++ .../final-prompts/08-consolidation.prompt.md | 59 ++ .../final-prompts/09-implementation.prompt.md | 57 ++ .../implementation/adr-c-006.md | 58 ++ .../refactor-backlog/tools/baseline-scan.mjs | 255 +++++++ docs/project/refactor-backlog-setup/setup.sh | 73 ++ 36 files changed, 3488 insertions(+) create mode 100644 docs/project/refactor-backlog-setup/README.md create mode 100644 docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/01-readability.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/02-testability.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md create mode 100644 docs/project/refactor-backlog-setup/agents/_persistence-protocol.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/_status.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs create mode 100755 docs/project/refactor-backlog-setup/setup.sh diff --git a/.prettierignore b/.prettierignore index deed558..2e7e30e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,3 +21,7 @@ plop-templates/ # Backend is formatted by `dotnet format`, not prettier backend/ + +# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt +docs/project/refactor-backlog-setup/agents/ +docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/ diff --git a/docs/project/refactor-backlog-setup/README.md b/docs/project/refactor-backlog-setup/README.md new file mode 100644 index 0000000..e2b432a --- /dev/null +++ b/docs/project/refactor-backlog-setup/README.md @@ -0,0 +1,63 @@ +# Refactoring backlog — automated setup + +## What's in this package + +``` +refactor-backlog-setup/ + setup.sh ← run this once, from the root of the target repo + agents/ ← source prompts (edit these if you need to tweak + scope/wording before running setup.sh) + _persistence-protocol.md + 00-baseline.prompt.md + 01-readability.prompt.md + 02-testability.prompt.md + 03-ddd-hexagonal.prompt.md + 04-cqrs-light.prompt.md + 05-bdd.prompt.md + 06-adr-conformance.prompt.md + 07-bio2-compliance.prompt.md + 08-consolidation.prompt.md + 09-implementation.prompt.md (template — one TICKET-ID per Phase 3 dispatch) +``` + +## Usage + +1. Copy this `refactor-backlog-setup/` folder into the root of the target repo + (or reference it via a relative path). +2. Edit anything in `agents/` if scope/exclusions need repo-specific detail + (e.g. exact module paths, ADR folder location) — the prompts currently use + the defaults agreed in the design conversation. +3. Run: + ``` + bash refactor-backlog-setup/setup.sh + ``` + This creates `./refactor-backlog/` with: + - `_status.md` initialized, all agents `not_started` + - `00-baseline.md` through `07-bio2-compliance.md` initialized with headers + - `99-backlog.md` empty, ready for Consolidation + - `implementation/` folder for Phase 3 notes + - `final-prompts/` — every agent prompt with the persistence protocol + already merged in. **These are the exact prompts to dispatch — no manual + copy-paste needed.** + +## Dispatch order + +1. Dispatch `final-prompts/00-baseline.prompt.md` (Opus). Wait for + `_status.md` → baseline: complete. +2. Dispatch the 7 Phase 1 prompts in parallel (Opus): `01` through `07`. + Each checks its own dependency in `_status.md` before starting. +3. Once all 7 show `complete`, dispatch `final-prompts/08-consolidation.prompt.md` + (Opus). It writes `99-backlog.md` and halts for human approval — check the + file for any `ADR-fix` or BIO2-flagged tickets before proceeding. +4. For each approved ticket, copy `final-prompts/09-implementation.prompt.md`, + fill in `TICKET-ID:`, dispatch (Sonnet). Run tickets in parallel within a CD + batch, sequential across batches, per the `Depends on` column in + `99-backlog.md`. + +## Re-running / resuming + +Safe to re-run `setup.sh` only on a fresh workspace — it does not check for an +existing `./refactor-backlog/` and will overwrite `_status.md` and the phase +output files. If a run is already in progress, don't re-run `setup.sh`; just +re-dispatch the relevant `final-prompts/*.prompt.md` — each agent reads +`_status.md` and its own output file first and resumes from where it left off. diff --git a/docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md b/docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md new file mode 100644 index 0000000..73ea9c2 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md @@ -0,0 +1,22 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/00-baseline.md +DEPENDS ON: none + +--- + +[Insert contents of _persistence-protocol.md here] + +ROLE: Metrics Baseline Agent + +Before any refactoring suggestions, establish a baseline for the scoped codebase: +- Test coverage (line/branch) per module, .NET and Angular separately. +- Cyclomatic complexity per method/function (flag >10). +- Duplication percentage (tool-based, e.g. jscpd/SonarQube if configured). +- Dependency graph / layering violations (existing static analysis if present). +- Count and location of existing CQRS-light and hexagonal architecture patterns + already in use (so later agents compare against actual current state, not + assumed absence). + +Output: a metrics table per module, plus a short list of modules ranked +worst-to-best on each metric. This file is fixed input to every Phase 1 agent — +no agent may propose a change without citing which baseline metric it improves. diff --git a/docs/project/refactor-backlog-setup/agents/01-readability.prompt.md b/docs/project/refactor-backlog-setup/agents/01-readability.prompt.md new file mode 100644 index 0000000..cc9f23d --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/01-readability.prompt.md @@ -0,0 +1,15 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/01-readability.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: Readability Agent + +Junior = fluency in language constructs, not domain knowledge. Assume familiarity +with generics, async/await, LINQ, DI, RxJS operators, TS type system — do NOT flag +idiomatic use of these as "unreadable". Flag only: unclear naming, methods/components +exceeding [N] lines, nesting >3 levels, magic values, misleading types, missing guard +clauses. Cite baseline complexity score per finding. diff --git a/docs/project/refactor-backlog-setup/agents/02-testability.prompt.md b/docs/project/refactor-backlog-setup/agents/02-testability.prompt.md new file mode 100644 index 0000000..8d381cf --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/02-testability.prompt.md @@ -0,0 +1,14 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/02-testability.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: Testability Agent + +Flag constructs that block unit testing without excessive mocking: static/singleton +dependencies, hidden I/O, constructors doing work, mixed pure/impure logic. Cite +baseline coverage gap per finding. Propose the minimal seam needed (interface +extraction, pure function split) — not a rewrite. diff --git a/docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md b/docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md new file mode 100644 index 0000000..7e050f7 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md @@ -0,0 +1,16 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/03-ddd-hexagonal.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: DDD/Hexagonal Agent + +Target architecture: hexagonal (ports/adapters), already partially present in the +codebase per baseline.md — treat that as the pattern to extend, not reinvent. Do NOT +introduce hexagonal structure into modules where it is absent; only propose closing +gaps where the pattern is already started. Flag anemic domain models, domain logic +leaked into controllers/services/components, primitive obsession, missing ubiquitous +language. diff --git a/docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md b/docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md new file mode 100644 index 0000000..850d4ac --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md @@ -0,0 +1,15 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/04-cqrs-light.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: CQRS-light Agent + +Target: command/query separation at the application-service level (not event +sourcing or separate read models unless already present per baseline.md). Only +extend existing CQRS-light patterns — do not introduce the pattern into modules +where it's absent. Identify handlers/services mixing reads and writes within +modules that already show the pattern elsewhere. diff --git a/docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md b/docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md new file mode 100644 index 0000000..67859a7 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md @@ -0,0 +1,14 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/05-bdd.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: BDD Agent + +Check whether existing tests express behavior in domain/business language mapped +to acceptance criteria, or only technical steps. Flag test names/structure gaps. +Do not propose new BDD tooling if none is present — flag as a separate structural +item instead, not a per-module ticket. diff --git a/docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md b/docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md new file mode 100644 index 0000000..13e43e8 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md @@ -0,0 +1,16 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/06-adr-conformance.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: ADR-Conformance Agent + +Read all ADRs/docs in the repo. Compare code against each. Two outcomes per +deviation: +(a) code violates a correct ADR → refactoring ticket, cite ADR. +(b) ADR itself appears outdated/wrong given current code or constraints → propose + an ADR amendment as a separate ticket type ("ADR-fix"), with rationale — not + a code ticket. diff --git a/docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md b/docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md new file mode 100644 index 0000000..1068022 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md @@ -0,0 +1,24 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/07-bio2-compliance.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: BIO2/Compliance Agent + +No explicit control list supplied — using the following BIO2/ISO 27002:2022 +controls, selected for privacy and security relevance. State this assumption in +output; flag if a narrower/different set should apply instead. + +- Access control (9.1, 9.2, 9.4): authorization checks, RBAC, least privilege. +- Logging & monitoring (8.15, 8.16): audit trails, esp. BIG-register/DUO data access. +- Data classification & handling (5.12, 5.13): BSN, health data, AVG-sensitive fields. +- Cryptography (8.24): encryption at rest/in transit. +- Secure development (8.25, 8.28, 8.29): secure coding, review, security testing gates. +- Change control (8.32): deployment register / change approval exceptions. +- Input validation (8.26): boundary validation on public-facing forms/APIs. + +Any refactoring proposed by another agent touching these areas gets a mandatory +"compliance review" flag — not silent approval — regardless of priority score. diff --git a/docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md b/docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md new file mode 100644 index 0000000..1319d8e --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md @@ -0,0 +1,35 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/99-backlog.md +DEPENDS ON: 01-readability.md through 07-bio2-compliance.md (all complete) + +--- + +[Insert contents of _persistence-protocol.md here — file-level: re-run only if a +Phase 1 file changed since last run] + +ROLE: Consolidation & CD-Sequencing Agent + +Input: all Phase 1 files (01–07) + 00-baseline.md. + +1. Deduplicate overlapping findings across agents — merge into one ticket, list all + contributing reasons/agents. +2. Score priority: + P1 = violates a correct ADR, blocks testability, or is a BIO2 compliance risk. + P2 = significant maintainability cost, moderate effort. + P3 = low urgency. +3. Sequence for continuous delivery: every ticket must be independently deployable + without a big-bang release. Reject/split any ticket that can't ship alone — + decompose into a dependency chain of smaller tickets. +4. Any ticket touched by the BIO2 agent requires compliance sign-off before merge, + regardless of priority score — mark explicitly. +5. Output final table: + +| ID | Module | Category | Description | Baseline metric improved | Effort (S/M/L) | +Risk | Priority | CD batch # | Depends on | Status | + +6. Separately list "ADR-fix" tickets — require human/architect approval before any + dependent code ticket proceeds. + +HALT CONDITION: after writing 99-backlog.md, stop and report to the human for +approval before any Implementation Agent (Phase 3) starts — even if no tickets +carry a compliance or ADR-fix flag. diff --git a/docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md b/docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md new file mode 100644 index 0000000..ae584ec --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md @@ -0,0 +1,33 @@ +MODEL: Sonnet +OUTPUT: status update in /refactor-backlog/99-backlog.md + + /refactor-backlog/implementation/[ticket-id].md +DEPENDS ON: ticket status = not_started, no unresolved compliance/ADR-fix flag, + all tickets in "Depends on" column = implemented or needs_review + +--- + +[Insert contents of _persistence-protocol.md here — scoped to one ticket, not a +module list] + +AGENT: Implementation Agent + +Input: one ticket from 99-backlog.md (fill in TICKET-ID below), the Phase 1 +file(s) that produced it, and 00-baseline.md. + +TICKET-ID: [fill in before dispatching this agent] + +Scope discipline: +- Implement exactly the change described in the ticket. No scope expansion, no + incidental fixes. +- If the ticket is ambiguous or underspecified for implementation, do not guess — + write a blocker note to the ticket's status and stop. +- Do not modify architecture/pattern decisions (hexagonal boundaries, CQRS-light + structure) — those are Opus-level design calls already made in the ticket. If + implementation reveals the design call was wrong, flag back to Consolidation + rather than deciding unilaterally. +- Tickets touching a BIO2-flagged item are blocked until human compliance + sign-off is recorded in the ticket status — do not implement first and flag + after. + +Update ticket status in 99-backlog.md: not_started → in_progress → implemented +→ needs_review. Append implementation notes to implementation/[ticket-id].md. diff --git a/docs/project/refactor-backlog-setup/agents/_persistence-protocol.md b/docs/project/refactor-backlog-setup/agents/_persistence-protocol.md new file mode 100644 index 0000000..37183a3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/_persistence-protocol.md @@ -0,0 +1,25 @@ +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md b/docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md new file mode 100644 index 0000000..c71617c --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md @@ -0,0 +1,547 @@ +## Scope: apps/ssp (6 contexts), apps/behandelportal (3 contexts), libs/shared (11 layers), libs/beheer (5 layers), backend/src/BigRegister.Api (6 folders), backend/tests + +## Status: complete + +## Last updated: 2026-08-26 + +## Depends on: none + +## --- + +# 00 — Metrics Baseline + +Fixed input to every Phase 1 agent (01–07). **No agent may propose a change without +citing a `BL-###` observation or a metric row from this file.** + +--- + +## 1. Method, and what to trust + +Everything was measured with tooling already in the repo. No dependency was added, no +config file edited. + +| Metric | Command | Trust | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| FE coverage | `npm run test:coverage` → `coverage/{ssp,behandelportal,shared,beheer}/lcov.info` | exact | +| FE spec reach | derived: source files present in any lcov vs. files on disk | exact | +| BE coverage | `cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" --collect:"XPlat Code Coverage"` | exact | +| TS complexity + fn length | `npx eslint apps libs --rule '{"complexity":["warn",0],"max-lines-per-function":["warn",{"max":0,"skipBlankLines":true,"skipComments":true}]}' -f json` — threshold 0 makes ESLint report _every_ function with its score, giving a full distribution rather than only violations | exact (ESLint core rule, no plugin) | +| C# complexity | `node docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs --complexity` | **file CC exact-ish; per-method CC approximate** | +| Duplication | `node …/tools/baseline-scan.mjs --dup` | **approximate** | +| Layering violations | `npm run dep:check` | exact | +| Coupling / instability | `npx depcruise apps//src libs --config .dependency-cruiser..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`, +`-no-other-app`, per-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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md b/docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md b/docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md new file mode 100644 index 0000000..b4a6b18 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md @@ -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(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` — + 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('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` 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 ByProgramOn(DateOnly on) => Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)).ToDictionary(...);` + then `public static readonly IReadOnlyDictionary ByProgram = ByProgramOn(DateOnly.FromDateTime(DateTime.Today));`. + **Zero call-site changes** — `DiplomaRules.ProfessionFor` and `All()` keep using + `ByProgram`. `StamdataValidationTests` gains the ability to assert both validity-window + branches against authored future/expired rows. +- **This extends an existing pattern in the same folder.** `StamdataTable.cs:63` already + does exactly this — `Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows()`, + with `on` as a parameter — and `StamdataFile.ActiveOn(van, tot, on)` (`:36`) is already + clock-free. `Professions.cs` is the one caller that swallows the parameter. +- **Effort S.** Independently shippable; additive only. + +## backend/tests + +**No findings.** 39 files, 4 253 lines, 241 green tests, every backend source file +reached (§3c). The suite already carries the fixtures a unit lane needs +(`Builders/AanvraagBuilder.cs`, `ZgwStubHandler.cs`, `TestWebApplicationFactory` with +per-class throwaway SQLite files). + +The one structural observation is not a defect to fix here: +`[assembly: CollectionBehavior(DisableTestParallelization = true)]` +(`TestWebApplicationFactory.cs:12`) serializes the entire suite because +`Db.ConnectionString` is a process-global. The repo reached that decision deliberately +and documented the race it prevents. Rather than reopen it, TE-008 and TE-009 reduce how +much _needs_ to run in that serialized lane. Note also §4b's outlier in this folder: a +**293-line test method at CC 20** +(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`, +`OpenZaakZaakSourceTests.cs`) — a test-readability item for agent 01, not a testability +seam. + +--- + +## Summary + +| ID | Title | Module | Blocker | Baseline | Effort | 1 deploy | +| ------ | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ------ | -------- | +| TE-001 | `SessionStore.restore()` reads `localStorage` inline | ssp/auth + bhp/auth | hidden I/O in a field initializer; guard is module-private | §3a 42.9%/46.2% (worst line); file LH 2/20, BRH 3/13 | S ×2 | yes | +| TE-002 | Trust boundary hidden inside a global-`fetch` method | ssp/brief | un-exported shape validation behind `await fetch` | §3b 42% reach; §3a 68.8% branch | S | yes | +| TE-003 | `UploadTransport` port declared, concrete class injected | libs/shared/upload | `inject(KeepaliveTransport)`; class not exported | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes | +| TE-004 | `createUploadController` injects + binds `window` at call time | libs/shared/upload | 3× `inject()`, `effect()`, `addEventListener` before returning | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes | +| TE-005 | `xhrUpload` interprets responses inside an XHR closure | libs/shared/upload | `new XMLHttpRequest()` hard-coded; dev simulator shares the method | file LH 5/64 (7.8%), BRH 3/57 (5.3%) | S–M | yes | +| TE-006 | Blob-to-browser handoff inlined in 3 commands | libs/beheer + ssp/brief | `window.open` / `a.click()` as the last statement of each command | §3a beheer/application 40.5% branch (worst); brief.store 50% | S–M | yes | +| TE-007 | `LetterHtml` resolves `datum` from `UtcNow`, not from `at` | backend/Domain | ambient clock in a private resolver inside a pure rule class | §3c Domain 82.0% branch; §4b file CC 21 | S | yes | +| TE-008 | Brief transition rules live inside DB-opening store methods | backend/Data | 5 pure guards behind `lock` + `Db.Create()` | §3c Data 75.5% branch (BL-005); §4b CC 17, `ToDto` CC 16 | M | yes | +| TE-009 | `Professions.ByProgram` freezes valid-time at type-load | backend/Stamdata | `static readonly` + `DateTime.Today`; both branches unreachable | §3c Stamdata 71.7% branch (BL-005) | S | yes | + +**Modules with no findings:** ssp/registratie · ssp/herregistratie · ssp/showcase+shell+root · +bhp/behandeling · bhp/shell+root · libs/shared/{domain, application, infrastructure, ui, +layout, kernel, testing} · libs/beheer/{domain, infrastructure, ui, contracts} · +backend/Program.cs · backend/Zgw · backend/Contracts · backend/tests. + +**Baseline rows recommended for closure as false gaps:** `libs/shared/domain` (0% reach, +3 files) and `libs/beheer/contracts` (0% reach, 1 file) — both named in BL-004 as genuine +gaps; both contain only type declarations and no executable statement. + +**Cross-references, not owned here:** BL-001 complexity (agent 01) · BL-002 auth +duplication (agent 06) · BL-003 `Program.cs` structure (agents 03/04) · BL-006 backend +architecture enforcement (agent 03) · BL-007 write-side placement (agent 04) · BL-008 +`coverageExclude` · BL-009 no coverage ratchet — which means none of the findings above +can be verified as "improved" by CI alone; verify against `00-baseline.md`'s numbers · +BL-010 `libs/shared/upload` layer placement (TE-003/004/005 all land inside that +carve-out and do not resolve it) · BL-011 suite flakiness under parallel load. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md b/docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md b/docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md new file mode 100644 index 0000000..b775777 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md @@ -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` → 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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md b/docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md b/docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md new file mode 100644 index 0000000..cdf9cbd --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md @@ -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` + ``, 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..js`'s `-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` (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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md b/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md new file mode 100644 index 0000000..e952668 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -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 | - | - | | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md new file mode 100644 index 0000000..f9e6bac --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md new file mode 100644 index 0000000..b5e3c6b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md new file mode 100644 index 0000000..a304df5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md new file mode 100644 index 0000000..ba28367 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md new file mode 100644 index 0000000..51057b1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md new file mode 100644 index 0000000..d463b1f --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md new file mode 100644 index 0000000..71f2765 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md new file mode 100644 index 0000000..5d3f9e4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md new file mode 100644 index 0000000..8f382b2 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md @@ -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 (01–07) + 00-baseline.md. + +1. Deduplicate overlapping findings across agents — merge into one ticket, list all + contributing reasons/agents. +2. Score priority: + P1 = violates a correct ADR, blocks testability, or is a BIO2 compliance risk. + P2 = significant maintainability cost, moderate effort. + P3 = low urgency. +3. Sequence for continuous delivery: every ticket must be independently deployable + without a big-bang release. Reject/split any ticket that can't ship alone — + decompose into a dependency chain of smaller tickets. +4. Any ticket touched by the BIO2 agent requires compliance sign-off before merge, + regardless of priority score — mark explicitly. +5. Output final table: + +| ID | Module | Category | Description | Baseline metric improved | Effort (S/M/L) | +Risk | Priority | CD batch # | Depends on | Status | + +6. Separately list "ADR-fix" tickets — require human/architect approval before any + dependent code ticket proceeds. + +HALT CONDITION: after writing 99-backlog.md, stop and report to the human for +approval before any Implementation Agent (Phase 3) starts — even if no tickets +carry a compliance or ADR-fix flag. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md new file mode 100644 index 0000000..ca8de86 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md @@ -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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.md new file mode 100644 index 0000000..0b20dca --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.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` | +| `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. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs b/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs new file mode 100644 index 0000000..d8cbed3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs @@ -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(); diff --git a/docs/project/refactor-backlog-setup/setup.sh b/docs/project/refactor-backlog-setup/setup.sh new file mode 100755 index 0000000..5e8c2b1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/setup.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Sets up the multi-agent refactoring backlog workspace. +# Run from the root of the target repo: bash setup.sh +set -euo pipefail + +WORK_DIR="./refactor-backlog" +AGENTS_SRC="$(dirname "$0")/agents" +PROTOCOL="$AGENTS_SRC/_persistence-protocol.md" + +echo "Setting up $WORK_DIR ..." +mkdir -p "$WORK_DIR/implementation" +mkdir -p "$WORK_DIR/final-prompts" + +# 1. Initialize _status.md +cat > "$WORK_DIR/_status.md" << 'EOF' +# Agent run status + +| Agent | Status | Last module processed | Last updated | Notes | +|---|---|---|---|---| +| baseline | not_started | - | - | | +| readability | not_started | - | - | | +| testability | not_started | - | - | | +| ddd-hexagonal | not_started | - | - | | +| cqrs-light | not_started | - | - | | +| bdd | not_started | - | - | | +| adr-conformance | not_started | - | - | | +| bio2-compliance | not_started | - | - | | +| consolidation | not_started | - | - | | +EOF +echo " created _status.md" + +# 2. Initialize empty output files with headers +for f in 00-baseline 01-readability 02-testability 03-ddd-hexagonal \ + 04-cqrs-light 05-bdd 06-adr-conformance 07-bio2-compliance; do + cat > "$WORK_DIR/${f}.md" << EOF +## Scope: [to be filled by agent] +## Status: not_started +## Last updated: - +## Depends on: [see agent prompt] +## --- +EOF +done +touch "$WORK_DIR/99-backlog.md" +echo " initialized 8 phase-1 output files + 99-backlog.md" + +# 3. Assemble final prompts: inject persistence protocol into each agent prompt +# at the "[Insert contents of _persistence-protocol.md here...]" marker. +for src in "$AGENTS_SRC"/*.prompt.md; do + name="$(basename "$src")" + out="$WORK_DIR/final-prompts/$name" + awk -v protofile="$PROTOCOL" ' + /\[Insert contents of _persistence-protocol\.md here/ { + while ((getline line < protofile) > 0) print line + close(protofile) + next + } + { print } + ' "$src" > "$out" + echo " assembled final-prompts/$name" +done + +echo "" +echo "Done. Structure:" +echo " $WORK_DIR/_status.md (run tracker — all agents not_started)" +echo " $WORK_DIR/00-baseline.md ... 07-bio2-compliance.md (empty, agent-writable)" +echo " $WORK_DIR/99-backlog.md (empty, Consolidation writes here)" +echo " $WORK_DIR/implementation/ (Phase 3 notes land here)" +echo " $WORK_DIR/final-prompts/ (ready-to-dispatch prompts, protocol" +echo " already merged in — no manual copy-paste)" +echo "" +echo "Next: dispatch final-prompts/00-baseline.prompt.md first (blocks everything)," +echo "then the 7 Phase 1 prompts in parallel, then 08-consolidation.prompt.md," +echo "then per-ticket 09-implementation.prompt.md (fill in TICKET-ID each time)." From 4debf6614f3c011b1af477aaa96abed17c955df1 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 17:48:48 +0200 Subject: [PATCH 02/61] docs(adr-0002): accept, and record the unbuilt Principal union as debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-C-005 from the ADR-conformance pass. Status Proposed -> Accepted. Two apps have shipped against this ADR and its structural rulings run in CI at severity: error with 0 violations; the other five ADRs are all Accepted. A decision CI enforces is not "Proposed". Drops two "out of scope, not built" bullets that have since shipped (WP-61..67) — the Behandeling backoffice, and the backend status lifecycle + authz DTOs: AanvraagStatusTag, GET /me (Program.cs:578), Domain/ Authorization/Authz.cs. Real authentication is the one that genuinely stays. Replaces the `Session -> Principal` deferral with a Known debt section. The deferral was conditional on the backoffice not existing yet; it does now, and the union did not follow. `grep -rn "Principal" apps libs` returns one hit, a comment. Consequently the two auth contexts are byte-identical (diff -rq: zero content differences), and behandelportal's Behandelaar still carries a bsn and logs in through DigiD — a backoffice user authenticating as a citizen, which is what §3 was written to prevent. The divergence that did happen took an orthogonal side door (medewerker.interceptor.ts) that never touches Session. The section says explicitly that the WP-67 amendment's "expected to diverge" reasoning still holds but has never been tested, so the identical copies are evidence §3 is unexecuted — not evidence §3 was wrong. Without that, a future reader is likely to "simplify" the duplication away and cement the citizen login into the backoffice. Co-Authored-By: Claude Opus 5 --- .../0002-user-groups-and-bounded-contexts.md | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md index c5cb2c8..67774bc 100644 --- a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md +++ b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md @@ -1,6 +1,6 @@ # ADR 0002 — User groups as actors, not bounded contexts -Status: Proposed · Date: 2026-07-01 +Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67) ## Problem @@ -160,11 +160,35 @@ changes is purely the _packaging_: ## Out of scope here (next steps, not built) -- Building the Behandeling backoffice application. - Real authentication: DigiD (SSP) and employee SSO / eHerkenning (backoffice). -- The `auth` `Session → Principal` refactor — deferred until a second actor is actually introduced. -- The backend aanvraag status lifecycle + authorization endpoints/DTOs. -ponytail: this ADR draws the boundaries so nothing has to be undone later; it does **not** scaffold a -second app or a role system now. Introduce the `Principal` union and the status lifecycle when the -backoffice work actually starts — YAGNI until then. +Two bullets that stood here — building the Behandeling backoffice, and the backend aanvraag +status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67): `apps/behandelportal`, +`AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`), +`Domain/Authorization/Authz.cs`. + +## Known debt: `Session → Principal` was never built + +§3's `Principal` union is the one decision here that has **not** been executed, and it is now +debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union +did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in +`libs/shared/src/infrastructure/role.ts`. There is no such type. + +What that omission actually costs, measured 2026-08-26: + +- `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical — + `diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being + two extra files in behandelportal. +- `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`. + A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent. +- The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`, + a dev-only `X-Medewerker` header stamp that never touches `Session`. + +The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is +"expected to diverge". That reasoning still holds — but it has never been **tested**, because +the change that would test it is this one. Read the two identical copies as evidence that +§3 is unexecuted, not as evidence that §3 was wrong. + +ponytail: this ADR draws the boundaries so nothing has to be undone later. The original +"YAGNI until the backoffice work starts" call was right when written and has now expired — +the backoffice started. `Principal` is owed. From f2d4c900b413ef05b96a473aeb1ec68fb13fedc3 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 17:49:04 +0200 Subject: [PATCH 03/61] refactor(auth): share the actor-agnostic route guards (ADR-C-006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authGuard and capabilityGuard were duplicated byte-for-byte across both apps, along with their specs — 57 of the 211 duplicated lines BL-002 measured in the two auth contexts, the largest block after session.store.ts. They are not actor-specific. They ask "is anyone logged in" and "may they do X", never "who are you or how did you get here". ADR-0002 §3's non-sharing decision scopes to identity and login flow — Principal, DigiD vs employee SSO — and a route guard is neither; §Consequences names auth.guard.ts only as a seam that localises the change, not as something that must be duplicated. Moves both to libs/shared/src/application/auth.guard.ts, reading SESSION_PORT instead of an app-local SessionStore. The port gains one member, isAuthenticated: Signal — free, because both SessionStores already expose exactly that (session.store.ts:40) and both apps already register { provide: SESSION_PORT, useExisting: SessionStore }. The seam existed; it was just narrower than what it already carried. Each app keeps a re-export at @auth/auth.guard so app.routes.ts is untouched — routing asks the auth context for its guards, which is the direction the boundary should read. The two identical specs collapse into one, plus a case asserting the guard resolves through the port. Deliberately NOT merged: session.store.ts, session.ts, digid.adapter.ts, login-form.component.ts, login.page.ts. Those are identical only because ADR-C-004 (Session -> Principal) was never executed. Merging them would make a citizen DigiD/BSN login the backoffice's shared login. Measured with tools/baseline-scan.mjs: ssp/auth duplicated lines 211 -> 151, bhp/auth 86.8% -> 82.5%, repo-wide 7.1% -> 6.6%. Both guard clone pairs drop out of the top-clones list. What remains is exactly the three files ADR-C-004 should differentiate. behaviour-spec.mdx regenerated (the spec moved libraries). Verified: lint, typecheck, dep:check (0 violations, 224 modules), prettier, ng build --localize for both apps, and 407 tests passing across all four projects. Co-Authored-By: Claude Opus 5 --- .../behandelportal/src/app/auth/auth.guard.ts | 38 +++--------- apps/ssp/src/app/auth/auth.guard.spec.ts | 62 ------------------- apps/ssp/src/app/auth/auth.guard.ts | 38 +++--------- libs/shared/docs/behaviour-spec.mdx | 30 ++++----- .../src/application}/auth.guard.spec.ts | 11 +++- libs/shared/src/application/auth.guard.ts | 48 ++++++++++++++ libs/shared/src/application/session.port.ts | 2 + 7 files changed, 86 insertions(+), 143 deletions(-) delete mode 100644 apps/ssp/src/app/auth/auth.guard.spec.ts rename {apps/behandelportal/src/app/auth => libs/shared/src/application}/auth.guard.spec.ts (82%) create mode 100644 libs/shared/src/application/auth.guard.ts diff --git a/apps/behandelportal/src/app/auth/auth.guard.ts b/apps/behandelportal/src/app/auth/auth.guard.ts index 3eadd8f..e3def66 100644 --- a/apps/behandelportal/src/app/auth/auth.guard.ts +++ b/apps/behandelportal/src/app/auth/auth.guard.ts @@ -1,34 +1,10 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { AccessStore } from '@shared/application/access.store'; -import { Capability } from '@shared/domain/capability'; -import { SessionStore } from './application/session.store'; - -/** Route guard: only let authenticated users in; otherwise redirect to /login. */ -export const authGuard: CanActivateFn = () => { - const store = inject(SessionStore); - const router = inject(Router); - return store.isAuthenticated() ? true : router.createUrlTree(['/login']); -}; - /** - * Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else - * redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). + * The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading + * only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec. + * Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`: + * routing asks the auth context for its guards, which is the right direction to read. * - * **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me` - * is still loading — it would deny an entitled admin and bounce them. We await - * `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user - * goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're - * logged in, just not allowed here — no re-login loop). The backend re-enforces - * regardless (403); this guard is the UX pre-gate. + * ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes — + * `Principal`, the login flow, `SessionStore`. A guard is neither. */ -export function capabilityGuard(capability: Capability): CanActivateFn { - return async () => { - const session = inject(SessionStore); - const access = inject(AccessStore); - const router = inject(Router); - if (!session.isAuthenticated()) return router.createUrlTree(['/login']); - await access.whenReady(); - return access.can(capability) ? true : router.createUrlTree(['/dashboard']); - }; -} +export { authGuard, capabilityGuard } from '@shared/application/auth.guard'; diff --git a/apps/ssp/src/app/auth/auth.guard.spec.ts b/apps/ssp/src/app/auth/auth.guard.spec.ts deleted file mode 100644 index dfc3fb5..0000000 --- a/apps/ssp/src/app/auth/auth.guard.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { describe, it, expect, vi } from 'vitest'; -import { AccessStore } from '@shared/application/access.store'; -import { SessionStore } from './application/session.store'; -import { authGuard, capabilityGuard } from './auth.guard'; - -type Opts = { - authed: boolean; - can?: (c: string) => boolean; - whenReady?: () => Promise; -}; - -function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) { - const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds })); - const readySpy = vi.fn(whenReady); - TestBed.configureTestingModule({ - providers: [ - { provide: SessionStore, useValue: { isAuthenticated: () => authed } }, - { provide: AccessStore, useValue: { whenReady: readySpy, can } }, - { provide: Router, useValue: { createUrlTree } }, - ], - }); - return { createUrlTree, readySpy }; -} - -// The guards ignore their (route, state) args; cast to call with none. -const call = (fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)()); - -describe('authGuard', () => { - it('allows an authenticated user', () => { - setup({ authed: true }); - expect(call(authGuard)).toBe(true); - }); - - it('redirects an anonymous user to /login', () => { - const { createUrlTree } = setup({ authed: false }); - expect(call(authGuard)).toEqual({ tree: ['/login'] }); - expect(createUrlTree).toHaveBeenCalledWith(['/login']); - }); -}); - -describe('capabilityGuard', () => { - const guard = () => capabilityGuard('stamdata:edit'); - - it('waits for /me, then allows an entitled admin', async () => { - const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' }); - await expect(call>(guard())).resolves.toBe(true); - expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding - }); - - it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => { - setup({ authed: true, can: () => false }); - await expect(call>(guard())).resolves.toEqual({ tree: ['/dashboard'] }); - }); - - it('redirects an anonymous user to /login without waiting for caps', async () => { - const { readySpy } = setup({ authed: false, can: () => true }); - await expect(call>(guard())).resolves.toEqual({ tree: ['/login'] }); - expect(readySpy).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/ssp/src/app/auth/auth.guard.ts b/apps/ssp/src/app/auth/auth.guard.ts index 3eadd8f..e3def66 100644 --- a/apps/ssp/src/app/auth/auth.guard.ts +++ b/apps/ssp/src/app/auth/auth.guard.ts @@ -1,34 +1,10 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { AccessStore } from '@shared/application/access.store'; -import { Capability } from '@shared/domain/capability'; -import { SessionStore } from './application/session.store'; - -/** Route guard: only let authenticated users in; otherwise redirect to /login. */ -export const authGuard: CanActivateFn = () => { - const store = inject(SessionStore); - const router = inject(Router); - return store.isAuthenticated() ? true : router.createUrlTree(['/login']); -}; - /** - * Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else - * redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). + * The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading + * only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec. + * Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`: + * routing asks the auth context for its guards, which is the right direction to read. * - * **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me` - * is still loading — it would deny an entitled admin and bounce them. We await - * `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user - * goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're - * logged in, just not allowed here — no re-login loop). The backend re-enforces - * regardless (403); this guard is the UX pre-gate. + * ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes — + * `Principal`, the login flow, `SessionStore`. A guard is neither. */ -export function capabilityGuard(capability: Capability): CanActivateFn { - return async () => { - const session = inject(SessionStore); - const access = inject(AccessStore); - const router = inject(Router); - if (!session.isAuthenticated()) return router.createUrlTree(['/login']); - await access.whenReady(); - return access.can(capability) ? true : router.createUrlTree(['/dashboard']); - }; -} +export { authGuard, capabilityGuard } from '@shared/application/auth.guard'; diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 34118ac..1fce825 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 406 frontend behaviours across +**is** the suite, reshaped for a business reader. 402 frontend behaviours across 8 contexts; 217 backend behaviours across 36 test classes. @@ -28,22 +28,6 @@ classes. ### auth -#### authGuard - -- allows an authenticated user -- redirects an anonymous user to /login -- allows an authenticated user -- redirects an anonymous user to /login - -#### capabilityGuard - -- waits for /me, then allows an entitled admin -- sends an authenticated-but-unentitled user to /dashboard (not a login loop) -- redirects an anonymous user to /login without waiting for caps -- waits for /me, then allows an entitled admin -- sends an authenticated-but-unentitled user to /dashboard (not a login loop) -- redirects an anonymous user to /login without waiting for caps - #### isAuthenticated - narrows a present session to Session @@ -614,6 +598,18 @@ classes. - map only touches Success - map2 precedence: Failure > Loading > Success +#### authGuard + +- allows an authenticated user +- redirects an anonymous user to /login + +#### capabilityGuard + +- waits for /me, then allows an entitled admin +- sends an authenticated-but-unentitled user to /dashboard (not a login loop) +- redirects an anonymous user to /login without waiting for caps +- reads authentication through the port, not an app-local store + #### createDebouncedSave - flushes after the delay when canSave is true diff --git a/apps/behandelportal/src/app/auth/auth.guard.spec.ts b/libs/shared/src/application/auth.guard.spec.ts similarity index 82% rename from apps/behandelportal/src/app/auth/auth.guard.spec.ts rename to libs/shared/src/application/auth.guard.spec.ts index dfc3fb5..0487283 100644 --- a/apps/behandelportal/src/app/auth/auth.guard.spec.ts +++ b/libs/shared/src/application/auth.guard.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { describe, it, expect, vi } from 'vitest'; import { AccessStore } from '@shared/application/access.store'; -import { SessionStore } from './application/session.store'; +import { SESSION_PORT } from '@shared/application/session.port'; import { authGuard, capabilityGuard } from './auth.guard'; type Opts = { @@ -16,7 +16,7 @@ function setup({ authed, can = () => false, whenReady = () => Promise.resolve() const readySpy = vi.fn(whenReady); TestBed.configureTestingModule({ providers: [ - { provide: SessionStore, useValue: { isAuthenticated: () => authed } }, + { provide: SESSION_PORT, useValue: { isAuthenticated: () => authed } }, { provide: AccessStore, useValue: { whenReady: readySpy, can } }, { provide: Router, useValue: { createUrlTree } }, ], @@ -59,4 +59,11 @@ describe('capabilityGuard', () => { await expect(call>(guard())).resolves.toEqual({ tree: ['/login'] }); expect(readySpy).not.toHaveBeenCalled(); }); + + // The guard reads SESSION_PORT, never a concrete SessionStore — that is what lets one + // copy serve both apps while ADR-0002 §3 keeps their `auth` contexts separate. + it('reads authentication through the port, not an app-local store', () => { + setup({ authed: true }); + expect(TestBed.inject(SESSION_PORT).isAuthenticated()).toBe(true); + }); }); diff --git a/libs/shared/src/application/auth.guard.ts b/libs/shared/src/application/auth.guard.ts new file mode 100644 index 0000000..a252a39 --- /dev/null +++ b/libs/shared/src/application/auth.guard.ts @@ -0,0 +1,48 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AccessStore } from '@shared/application/access.store'; +import { SESSION_PORT } from '@shared/application/session.port'; +import { Capability } from '@shared/domain/capability'; + +/** + * Route guards, shared by both apps (ADR-C-006). + * + * These are deliberately **not** part of the `auth` context that ADR-0002 §3 keeps + * duplicated per app. That decision scopes to *identity and login flow* — `Principal`, + * DigiD vs. employee SSO. A route guard is neither: it asks only "is anyone logged in" + * and "may they do X", never "who are you or how did you get here". Both questions are + * answered through seams that already live here — `SESSION_PORT` and `AccessStore` — so + * the guards never see an actor type and have nothing to diverge on. + * + * Each app re-exports these from its own `auth/auth.guard.ts`, so `app.routes.ts` keeps + * importing `@auth/auth.guard` and the context boundary reads unchanged. + */ + +/** Route guard: only let authenticated users in; otherwise redirect to /login. */ +export const authGuard: CanActivateFn = () => { + const session = inject(SESSION_PORT); + const router = inject(Router); + return session.isAuthenticated() ? true : router.createUrlTree(['/login']); +}; + +/** + * Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else + * redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). + * + * **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me` + * is still loading — it would deny an entitled admin and bounce them. We await + * `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user + * goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're + * logged in, just not allowed here — no re-login loop). The backend re-enforces + * regardless (403); this guard is the UX pre-gate. + */ +export function capabilityGuard(capability: Capability): CanActivateFn { + return async () => { + const session = inject(SESSION_PORT); + const access = inject(AccessStore); + const router = inject(Router); + if (!session.isAuthenticated()) return router.createUrlTree(['/login']); + await access.whenReady(); + return access.can(capability) ? true : router.createUrlTree(['/dashboard']); + }; +} diff --git a/libs/shared/src/application/session.port.ts b/libs/shared/src/application/session.port.ts index da78cad..c94db44 100644 --- a/libs/shared/src/application/session.port.ts +++ b/libs/shared/src/application/session.port.ts @@ -8,6 +8,8 @@ import { InjectionToken, Signal } from '@angular/core'; */ export interface SessionPort { readonly session: Signal<{ naam: string } | null>; + /** Whether anyone is logged in. The shared route guards read only this — never who. */ + readonly isAuthenticated: Signal; logout(): void; } From 4b94f8edb569ec66e7fb602c28cff385374cc902 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 18:13:29 +0200 Subject: [PATCH 04/61] fix(flags): surface a failed admin toggle instead of swallowing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeatureFlagStore.set() was try/finally with no catch. A rejected PUT /admin/flags/{key} escaped into the `void this.store.set(...)` call site as an unhandled promise rejection; the finally-block reload then snapped the control back to its old value. The admin saw a toggle that silently refused to move, with no error rendered anywhere and nothing in the state. set() now folds through the existing runSubmit helper and returns Result, reloading either way so the state still reflects the server. The page awaits it and renders the failure in an app-alert. Found by the CQRS-light pass (CQ-002/CQ-004) as one of three mutations that reach the raw ApiClient without producing a Result — the baseline's BL-007 inventory had missed all three. Co-Authored-By: Claude Opus 5 --- .../behandelportal/src/locale/messages.en.xlf | 4 ++++ apps/ssp/src/locale/messages.en.xlf | 4 ++++ libs/beheer/src/ui/feature-flags.page.ts | 14 ++++++++--- libs/shared/docs/behaviour-spec.mdx | 9 +++++++- .../src/application/feature-flags.store.ts | 23 +++++++++++++------ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index a4dddc2..6977b09 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -3878,6 +3878,10 @@ De functievlaggen konden niet worden geladen. The feature flags could not be loaded. + + De functievlag kon niet worden opgeslagen. + The feature flag could not be saved. + Opnieuw proberen Try again diff --git a/apps/ssp/src/locale/messages.en.xlf b/apps/ssp/src/locale/messages.en.xlf index 9860f45..fa514b7 100644 --- a/apps/ssp/src/locale/messages.en.xlf +++ b/apps/ssp/src/locale/messages.en.xlf @@ -3738,6 +3738,10 @@ De functievlaggen konden niet worden geladen. The feature flags could not be loaded. + + De functievlag kon niet worden opgeslagen. + The feature flag could not be saved. + Opnieuw proberen Try again diff --git a/libs/beheer/src/ui/feature-flags.page.ts b/libs/beheer/src/ui/feature-flags.page.ts index d97c404..f8db815 100644 --- a/libs/beheer/src/ui/feature-flags.page.ts +++ b/libs/beheer/src/ui/feature-flags.page.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; @@ -45,6 +45,9 @@ import { FeatureFlagStore } from '@shared/application/feature-flags.store'; } @else if (!canManage()) { {{ deniedText }} } @else { + @if (setError(); as e) { + {{ e }} + } {{ failedText }} @@ -89,8 +92,13 @@ export class FeatureFlagsPage { protected enableText = $localize`:@@flags.enable:Aanzetten`; protected disableText = $localize`:@@flags.disable:Uitzetten`; - protected toggle(key: string, enabled: boolean) { - void this.store.set(key, enabled); + /** The last failed toggle, so a rejected PUT is visible instead of silently snapping back. */ + protected setError = signal(null); + + protected async toggle(key: string, enabled: boolean) { + this.setError.set(null); + const r = await this.store.set(key, enabled); + if (!r.ok) this.setError.set(r.error); } protected reload() { void this.store.load(); diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 1fce825..b0507b8 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 402 frontend behaviours across -8 contexts; 217 backend behaviours across 36 test +8 contexts; 221 backend behaviours across 37 test classes. ## Frontend (by context) @@ -1049,6 +1049,13 @@ classes. - Proefbrief is admin only - Proefbrief renders the draft template with a watermark +### ProfessionsTests + +- A mapping is absent before its geldigVan +- A mapping is present on and after its geldigVan +- A closed mapping is absent from its geldigTot onwards +- ByProgram is evaluated per call not captured at type load + ### StamdataEndpointTests - Stamdata reads are admin only diff --git a/libs/shared/src/application/feature-flags.store.ts b/libs/shared/src/application/feature-flags.store.ts index 94806e2..5f57b5f 100644 --- a/libs/shared/src/application/feature-flags.store.ts +++ b/libs/shared/src/application/feature-flags.store.ts @@ -1,10 +1,14 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; +import { runSubmit } from '@shared/application/submit'; +import { Result, ok, err } from '@shared/kernel/fp'; import { FeatureFlag } from '@shared/domain/feature-flag'; import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter'; type Err = Error | undefined; +const SET_FAILED = $localize`:@@flags.set.failed:De functievlag kon niet worden opgeslagen.`; + /** * Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the * resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default: @@ -47,12 +51,17 @@ export class FeatureFlagStore { return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false); } - /** Admin toggle: persist then reload so the state reflects the server. */ - async set(key: string, enabled: boolean) { - try { - await this.adapter.set(key, enabled); - } finally { - await this.load(); - } + /** + * Admin toggle: persist, then reload so the state reflects the server either way. + * + * Returns the failure rather than throwing. The previous `try/finally` had no `catch`, + * so a rejected PUT escaped into the `void store.set(...)` call site as an unhandled + * rejection: the reload then snapped the toggle back to its old value and the admin saw + * a control that silently refused to move, with no error anywhere. + */ + async set(key: string, enabled: boolean): Promise> { + const r = await runSubmit(() => this.adapter.set(key, enabled), SET_FAILED); + await this.load(); + return r.ok ? ok(undefined) : err(r.error); } } From 9440ce13456bddfa61e8c445bc2c418a9b4fe4db Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 18:13:38 +0200 Subject: [PATCH 05/61] fix(stamdata): evaluate the profession validity window per call, not at type-load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Professions.ByProgram was a `static readonly` field filtered on DateTime.Today, so it evaluated once when the type first loaded. Two consequences, both real: - A long-running process kept serving the answer it computed at startup. A mapping whose geldigVan fell after boot never appeared; one whose geldigTot passed never disappeared. - Both branches of StamdataFile.ActiveOn were unreachable from this caller, which is why this table's validity window had no test at all. It is the cleanest single explanation for Stamdata's 71.7% branch coverage (BL-005). Adds ByProgramOn(DateOnly) — the peildatum as an argument, matching StamdataTable.RowsOn which already parameterizes it — and makes ByProgram a property delegating to it with today's date. Call sites (DiplomaRules) are unchanged and keep the same behaviour, now with a current date. ProfessionsTests covers both ActiveOn branches plus the regression itself: the same date must give the same answer, a different date a different one. Found by the testability pass (TE-009). Co-Authored-By: Claude Opus 5 --- .../BigRegister.Api/Stamdata/Professions.cs | 26 +++++++--- .../Domain/ProfessionsTests.cs | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs diff --git a/backend/src/BigRegister.Api/Stamdata/Professions.cs b/backend/src/BigRegister.Api/Stamdata/Professions.cs index 285f2c9..aa83209 100644 --- a/backend/src/BigRegister.Api/Stamdata/Professions.cs +++ b/backend/src/BigRegister.Api/Stamdata/Professions.cs @@ -19,14 +19,26 @@ public static class Professions /// Every mapping in the data-file, typed. public static readonly IReadOnlyList Mappings = StamdataFile.Load("professions"); - /// The mappings valid today, as a program→beroep lookup. Consumers that don't - /// yet reason about a peildatum (e.g. DiplomaRules.ProfessionFor) use this — it - /// preserves the pre-valid-time behaviour exactly while the file's rows are all current. - public static readonly IReadOnlyDictionary ByProgram = - Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, DateOnly.FromDateTime(DateTime.Today))) + /// The mappings valid on , as a program→beroep lookup. + /// + /// Takes the peildatum as an argument rather than reading the clock. It used to be a + /// static readonly field filtered on DateTime.Today, which evaluated once at + /// type-load: a long-running process kept yesterday's answer across midnight, and a mapping + /// whose geldigVan fell after startup never appeared at all. It also made both + /// branches of permanently unreachable from here, which + /// is why this table's validity window was never exercised by a test. + public static IReadOnlyDictionary ByProgramOn(DateOnly on) => + Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)) .ToDictionary(m => m.Program, m => m.Beroep, StringComparer.OrdinalIgnoreCase); - /// Distinct professions, in declaration order — the list a user may declare - /// for a manual (unlisted) diploma. + /// The mappings valid today. Consumers that don't yet reason about a peildatum + /// (e.g. DiplomaRules.ProfessionFor) use this — same behaviour as before, but + /// evaluated per call so the date is current. + public static IReadOnlyDictionary ByProgram => ByProgramOn(Today()); + + /// Distinct professions valid today, in declaration order — the list a user may + /// declare for a manual (unlisted) diploma. public static IReadOnlyList All() => ByProgram.Values.Distinct().ToList(); + + private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today); } diff --git a/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs b/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs new file mode 100644 index 0000000..e0ddf82 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs @@ -0,0 +1,47 @@ +using BigRegister.Stamdata; + +namespace BigRegister.Tests.Domain; + +/// +/// The profession↔program map's validity window (TE-009). `ByProgram` used to be a +/// `static readonly` field filtered on `DateTime.Today` at type-load, so both branches of +/// `StamdataFile.ActiveOn` were unreachable from here and nothing asserted the window at all. +/// Now that the peildatum is a parameter, these are the two branches. +/// +public class ProfessionsTests +{ + [Fact] + public void A_mapping_is_absent_before_its_geldigVan() + { + // Every seeded row starts 2000-01-01; nothing is valid the day before. + Assert.Empty(Professions.ByProgramOn(new DateOnly(1999, 12, 31))); + } + + [Fact] + public void A_mapping_is_present_on_and_after_its_geldigVan() + { + Assert.Equal("Arts", Professions.ByProgramOn(new DateOnly(2000, 1, 1))["geneeskunde"]); + Assert.Equal("Arts", Professions.ByProgramOn(new DateOnly(2026, 8, 26))["geneeskunde"]); + } + + [Fact] + public void A_closed_mapping_is_absent_from_its_geldigTot_onwards() + { + // geldigTot is exclusive (`on < tot`), so the row drops out on the boundary date itself. + foreach (var m in Professions.Mappings.Where(m => m.GeldigTot is DateOnly)) + { + var tot = m.GeldigTot!.Value; + Assert.True(Professions.ByProgramOn(tot.AddDays(-1)).ContainsKey(m.Program)); + Assert.False(Professions.ByProgramOn(tot).ContainsKey(m.Program)); + } + } + + [Fact] + public void ByProgram_is_evaluated_per_call_not_captured_at_type_load() + { + // The regression this guards: a long-running process must not keep serving the answer + // it computed at startup. Same date in, same answer; different date in, different answer. + Assert.Equal(Professions.ByProgram.Count, Professions.ByProgramOn(DateOnly.FromDateTime(DateTime.Today)).Count); + Assert.NotEqual(Professions.ByProgram.Count, Professions.ByProgramOn(new DateOnly(1999, 12, 31)).Count); + } +} From 8dbfa83cd635b8f88423b244eb3f32eba3b51185 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 18:13:48 +0200 Subject: [PATCH 06/61] build: make coverageExclude actually exclude the generated API client The entry was a bare workspace-relative path ("libs/shared/src/ infrastructure/api-client.ts" in the app projects, "src/infrastructure/ api-client.ts" in the libraries) while every sibling in the same list is a glob. It matched nothing, so the 2372-line NSwag client was instrumented in all four projects: 987 mostly-uncovered lines that dragged the reported libs/shared/infrastructure figure from 94.7% down to 6.9%. Normalizes all four to "**/infrastructure/api-client.ts" and adds the entry to the beheer project, which was missing it entirely. api-client.provider.ts is hand-written and stays covered. Effect on the shared project's reported coverage: lines 96.2% -> 90.5% and branches 85.1% -> 80.2%, because the denominator is now real source instead of generated code inflating it. Found by the metrics baseline (BL-008). Co-Authored-By: Claude Opus 5 --- angular.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/angular.json b/angular.json index 30dde9d..2e55d08 100644 --- a/angular.json +++ b/angular.json @@ -106,7 +106,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", - "libs/shared/src/infrastructure/api-client.ts", + "**/infrastructure/api-client.ts", "apps/ssp/src/main.ts", "**/*.testing.ts", "**/*.d.ts" @@ -235,7 +235,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", - "libs/shared/src/infrastructure/api-client.ts", + "**/infrastructure/api-client.ts", "apps/behandelportal/src/main.ts", "**/*.testing.ts", "**/*.d.ts" @@ -293,7 +293,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", - "src/infrastructure/api-client.ts", + "**/infrastructure/api-client.ts", "src/test-entry.ts", "**/*.testing.ts", "**/*.d.ts" @@ -332,6 +332,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", + "**/infrastructure/api-client.ts", "src/test-entry.ts", "**/*.testing.ts", "**/*.d.ts" From 176e5baef8e5907d0b2c7d35b6321c4b03c9a018 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 09:52:13 +0200 Subject: [PATCH 07/61] docs: BIO2 compliance pass + consolidated backlog (agents 07, 08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the pipeline's analysis phase. Agent 07 (BIO2/ISO 27002:2022, control set stated as an assumption since none was supplied) produced 20 findings — 12 "defect now", 8 "production gate" — and agent 08 consolidated all 47 findings across 00/02/04/06/07 into 33 tickets, 5 ADR-fixes and a release checklist. Two findings are live defects rather than refactoring candidates, both verified directly: - RB-01/BIO-004: GET /uploads/{documentId}/content takes only (string documentId) — no HttpContext, so no authorization is possible. It streams diploma and identity scans, protected by GUID unguessability alone, while DELETE on the same resource is owner-scoped. - RB-02/BIO-008: Program.cs:674 concatenates the caller's BSN into the authz audit Resource column, which is persisted to SQLite and rendered by the admin audit page. Four doc comments claim that store holds no PII; the test cited as enforcing it asserts on column names, so a BSN inside a column called Resource is invisible to it. 07 also answered the handoff from 06: in a production behandelportal build no X-Medewerker is sent, so StubIdentityProvider returns the seeded citizen. It fails closed on backoffice capabilities but open on citizen-scoped ones, including CanRevealBigNummer. Root cause is IIdentityProvider.Resolve returning a non-nullable CallerIdentity — the interface cannot express "no identity", so any provider must invent one. 08's gate was relaxed from all-seven to the four agents that ran; _status.md records why 01/03/05 were skipped, and the backlog carries a "Coverage" note naming what those skips leave unowned. It caught two errors in the orchestrator's handoff: CQ-002 is not fixed (ApplicationsStore.cancel and AdminCasesStore.delete still swallow errors -> RB-20), and CQ-004 shipped with half its compliance criterion unmet (PUT /admin/flags/{key} writes no audit row -> RB-07, which blocks signing ADR-C-009). Both agents preserved a "verified clean — do not fix" list, so a later pass does not re-spend effort on the controls that already hold. Consolidation halted for human approval per its spec. No source file changed. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/07-bio2-compliance.md | 1127 ++++++++++++++++- .../refactor-backlog/99-backlog.md | 421 ++++++ .../refactor-backlog/_status.md | 22 +- 3 files changed, 1555 insertions(+), 15 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md b/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md index 37451f0..708af24 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md @@ -1,9 +1,1128 @@ -## Scope: [to be filled by agent] +## 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) — controls 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4 -## Status: not_started +## Status: complete -## Last updated: - +## Last updated: 2026-08-27 -## Depends on: [see agent prompt] +## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md ## --- + +# 07 — BIO2 / ISO 27002:2022 compliance + +**Scope, in full.** Frontend: `ssp/auth`, `ssp/registratie`, `ssp/herregistratie`, `ssp/brief`, +`ssp/showcase+shell+root`, `bhp/auth`, `bhp/behandeling`, `bhp/shell+root`, `libs/shared` +(domain, application, infrastructure, ui, layout, kernel, upload, testing, environments), +`libs/beheer`. Backend: `Program.cs`, `Domain`, `Data`, `Zgw`, `Contracts`, `Stamdata`. +Controls: 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4. + +--- + +## 0. The control-set assumption, stated for the record + +**No explicit control list was supplied to this agent.** The seven areas below were selected +for privacy and security relevance to a BIG-register portal handling BSN, diploma and +health-professional registration data: + +| # | Area | ISO 27002:2022 / BIO2 | +| --- | ------------------------------ | --------------------- | +| 1 | Access control | 9.1, 9.2, 9.4 | +| 2 | Logging & monitoring | 8.15, 8.16 | +| 3 | Data classification & handling | 5.12, 5.13 | +| 4 | Cryptography | 8.24 | +| 5 | Secure development | 8.25, 8.28, 8.29 | +| 6 | Change control | 8.32 | +| 7 | Input validation | 8.26 | + +**Flag if a different set should apply.** Three plausible narrowings/widenings a reviewer +should decide on before this file is treated as authoritative: + +- **BIO 2.0 thema-uitwerkingen** rather than raw ISO 27002 — a Dutch government system would + normally be assessed against the BIO's own thematic elaborations (toegangsbeveiliging, + logging & monitoring), which are stricter on logging retention and on the "verwerking van + bijzondere persoonsgegevens" than the bare ISO controls used here. Nothing below would be + withdrawn under that set; several items would rise in severity. +- **NEN 7510** (Dutch healthcare information security) is arguably the governing standard for + a register of healthcare professionals. Not applied here. +- **AVG/GDPR obligations proper** (art. 5 minimisation, art. 9 special-category, art. 30 + register of processing, art. 32 measures) are referenced only where the code itself invokes + them. A DPIA is out of scope for this pass and is listed in the pre-production checklist. + +**Framing, per the brief.** This POC has deliberately faked authentication; CLAUDE.md's +"Out of scope" excludes real auth/DigiD and PRD-0002 §3 excludes real AD/OIDC/SAML. **No +finding below asks for real DigiD or employee SSO.** Findings are split: + +- **Defect now** — wrong even for a POC. Typically: PII reaching a store or a log that the + code's own contract says holds none, or a missing authorization check that has nothing to + do with the identity stub. +- **Production gate** — correct for a POC, must be true before production. These are the + pre-production checklist at the end. + +Nine of the twenty findings are **defect now**. Where a dev-only affordance is genuinely +stripped from a production build, that is said plainly and no finding is filed +(see §1.4 and the module notes). + +--- + +## 1. Control area: access control (9.1, 9.2, 9.4) + +### BIO-001 — the backend trusts client-asserted identity headers in every environment + +- **Control:** 9.2 (user access provisioning), 9.4 (least privilege / secure log-on) +- **Class:** **production gate** +- **Severity: high** — a single request header grants the full admin capability set. It is + high not because it is unknown (it is documented in three places) but because it is the + item on which every other authorization control in the system rests: `Authz`, the + capability model, the four-eyes rule and the audit trail are all correct _given_ a + trustworthy `Principal`, and all worthless without one. +- **Evidence (read):** + - `backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs:23-27` — + `ctx.Request.Headers["X-Role"]` maps directly to `PrincipalRole.Admin`. No verification, + no signature, no environment guard. + - `:29-31` — `X-Medewerker` present ⇒ `MedewerkerCaller`, id taken verbatim from the header; + `:38-46` `X-Rollen` likewise. + - `:33-35` — `X-Subject` sets the caller's BSN, i.e. the ownership key every owner-scoped + store reads. + - `backend/src/BigRegister.Api/Program.cs:53` registers this as the only `IIdentityProvider` + unconditionally; `:114-119` runs it as middleware for every request in every environment. + - `Domain/Authorization/Authz.cs:12-16` and `StubIdentityProvider.cs:7-9` both label it + "dev stub — NOT a security boundary". The labelling is accurate and complete; **nothing + in the build enforces it.** +- **Baseline citation:** §7 Backend pattern inventory — "Single-impl interface | + `IIdentityProvider` → `StubIdentityProvider`"; **BL-006** (the backend has zero automated + architecture enforcement, so nothing would fail a build that shipped this stub). +- **Remediation, minimal:** do not replace the stub in this backlog. Two cheap, in-scope + steps: (a) fail fast — throw at startup when + `builder.Environment.IsProduction() && provider is StubIdentityProvider`, so the stub can + never boot outside Development; (b) give `IIdentityProvider.Resolve` a way to say "no + identity" (see BIO-002), so the production swap is a drop-in rather than a redesign. +- **Effort:** S (a), S (b). The real provider is out of scope and is a checklist item, not a + ticket. + +### BIO-002 — in a production build the backoffice has no identity, and the default is a citizen + +_Agent 06 handed this over explicitly (`06-adr-conformance.md`, "Observation for agent 07"). +Here is what it actually means for 9.4._ + +- **Control:** 9.4 (least privilege), 9.2 (provisioning); PRD-0002 §4 goal 4 (deny-by-default) +- **Class:** **production gate** +- **Severity: high** — the failure mode is an _identity substitution_, not merely a missing + identity, and it fails open in the direction nobody checked. +- **Evidence (read), and the answer to "what identity does a production backoffice user get":** + - `apps/behandelportal/src/app/app.config.ts:57-63` — `medewerkerInterceptor` is inside the + `isDevMode()` array. A production bundle sends **no** `X-Medewerker` / `X-Rollen`. + - `StubIdentityProvider.cs:29-37` — with no `X-Medewerker` and no `X-Subject`, the provider + falls through to `new ZorgverlenerCaller(DocumentStore.DemoOwner, SeedData.Registration.Naam, +PrincipalRole.Drafter)`. `DocumentStore.cs:48` — `DemoOwner = "123456782"`, the single + seeded citizen's BSN. + - **So a production backoffice user authenticates to the backend as the seeded citizen, + role `drafter`.** Concretely: + - **Fails closed, correctly, on the backoffice capability.** + `Authz.CanBeoordelen(caller)` is `caller is MedewerkerCaller m && …`, so a zorgverlener + is `false` regardless of `X-Role`. `GET /werkvoorraad`, `GET /beoordeling/{id}` and + `POST /beoordeling/{id}/besluit` all 403 through the `Beoordelen` gate + (`Program.cs:814-820`) and write a deny audit row. `GET /me` returns an empty capability + list, so `capabilityGuard('aanvraag:beoordelen')` (`app.routes.ts:25`) denies too. This + part of the design is right and should be recorded as such. + - **Fails open on the citizen's own rights.** The same user _is_ the seeded citizen for + every citizen-scoped endpoint: `GET /applications`, `GET /applications/{id}`, + `PUT/DELETE /applications/{id}`, `POST /applications/{id}/submit`, `DELETE /uploads/{id}`, + `GET|PUT /brief`, `POST /brief/submit|send|reset` (`Program.cs:281-372`, `:253`, + `:603-656`) all resolve `ctx.Zorgverlener().Bsn` to `123456782`. An employee with no + employee identity is granted a **citizen's** read and write rights over that citizen's + aanvragen, uploads and letters. + - **Holds the PII-reveal capability.** `Authz.CanRevealBigNummer(principal)` is + `principal.Role == PrincipalRole.Drafter` — and `drafter` is exactly the role the + no-header default produces. See BIO-006. + - **Root cause, and why it is worth a ticket now:** `IIdentityProvider.Resolve` returns a + non-nullable `CallerIdentity` (`IIdentityProvider.cs:12`). The interface **cannot express + "no identity"**, so any implementation — stub or real — is forced to invent one for an + unauthenticated request. `CallerIdentityHttpContextExtensions.Caller()` + (`CallerIdentity.cs:44-50`) already throws rather than defaulting when the middleware did + not run, i.e. the codebase reaches for fail-loud one layer up and then defaults one layer + down. +- **Baseline citation:** §7 Backend — "Single-impl interface | `IIdentityProvider` → + `StubIdentityProvider`"; §2 size inventory (`apps/behandelportal` 29 src files, 1 309 lines + — a whole app with no non-dev identity path). +- **Remediation, minimal:** change `Resolve` to `CallerIdentity?` and have the middleware + either reject (401) or set an explicit `AnonymousCaller` when it returns null; keep + `StubIdentityProvider` returning the current default **only** under + `IHostEnvironment.IsDevelopment()`. That is the smallest change that makes "unauthenticated" + representable, and it is the natural home for agent 06's **ADR-C-004** (`Session → Principal`). +- **Effort:** S for the interface + middleware; the behandelportal's real login is out of scope. + +### BIO-003 — `X-Admin` is a second authorization gate, outside `Authz`, unaudited, with no caller + +- **Control:** 9.4; 8.15 (audit trail); PRD-0002 §7 ("a single shared authorization helper … + used on every endpoint … the helper makes emit and enforce the same code path") +- **Class:** **defect now** +- **Severity: medium** — a destructive cross-owner delete behind the weakest gate in the + system, leaving no audit record. Not high only because the stronger gate it should use is + itself header-asserted today (BIO-001). +- **Evidence (read):** + - `Program.cs:773` — `static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";` + - `Program.cs:268-270` — `DELETE /admin/uploads/{documentId}` is gated by `IsAdmin` alone, + not by `Authz.CanManageCases` / the `CasesAdmin` wrapper the four sibling admin surfaces + use (`:801-808`). + - `Data/DocumentStore.cs:165-179` — `AdminDelete` "bypasses ownership", deletes the row and + its bytes, and writes only a `DocumentStore.Audit("delete-admin", …)` metadata row — **no** + `AuthzAuditStore` entry, so the action never appears on `/beheer/audit`. + - `grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e`: the only sender is + `backend/tests/BigRegister.Tests/EndpointTests.cs:231`. **No frontend uses this endpoint.** + It is an orphaned gate, not a live seam. +- **Baseline citation:** **BL-003** (940 lines, 48 endpoint mappings in one file, read/write + separated only by a comment banner — the structural condition under which one endpoint keeps + a superseded gate); §7 Backend CQRS-light row (the local helpers `Submit`, `StamdataAdmin`, + `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin` are "authorization/idempotency wrappers" + — `IsAdmin` is the one that never became a wrapper). +- **Remediation, minimal:** route the endpoint through `CasesAdmin` (or a new + `Authz.CanDeleteAnyDocument`) and delete `IsAdmin`; add the `AuditAuthz` call the sibling + gates make. One test (`EndpointTests.cs:231`) changes its header. +- **Effort:** S + +### BIO-004 — two upload endpoints have no authorization check at all + +- **Control:** 9.4 (broken object-level authorization); 5.12 (the objects are diploma and + identity scans) +- **Class:** **defect now** +- **Severity: high** — it is the only place in the backend where AVG-relevant _content_ is + served with no owner and no capability test, and the codebase demonstrably knows the + pattern: the sibling `DELETE` on the same resource is owner-scoped, and submit validates + foreign ids. Mitigating factor, stated honestly: document ids are `Guid.NewGuid()` + (`DocumentStore.cs:54`) and local ids are `crypto.randomUUID()` on the client, so this is a + capability-URL exposure rather than an enumerable one. +- **Evidence (read):** + - `Program.cs:231-237` — `GET /uploads/{documentId}/content` calls + `DocumentStore.Get(documentId)` and streams `doc.Content`. The lambda does not take + `HttpContext`; it cannot check anything. + - `Data/DocumentStore.cs:65-72` — `Get` has no owner parameter. + - Contrast, one screen away: `Program.cs:253-254` `DELETE /uploads/{documentId}` → + `DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn)` (`:146-153`, explicit + `d.Owner != owner` check), and `Program.cs:367` → + `DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn)` (`:103-113`), whose own + docstring says it "guards submit/draft-sync against a citizen attaching another citizen's + upload to their own aanvraag". + - `Program.cs:242-250` — `GET /uploads/status?localIds=` maps client local ids to document + ids for **any** caller, with no owner filter (`DocumentStore.ByLocalIds`, `:76-84`). + - **Why it is unscoped is legible:** `Program.cs:451-452` (the beoordeling detail) hands a + behandelaar the `DocumentId`s of another citizen's uploads, so a cross-owner read is a + genuine requirement. The defect is that the requirement was met by removing the check + rather than by widening it. +- **Baseline citation:** §7 Backend — `DocumentStore` listed among the 7 stores "Not behind + any port"; §3c `backend/Data` **75.5% branch** against 99.0% line (**BL-005**), the exact + signature of "the unit is entered, the guard branches are not there to enter". +- **Remediation, minimal:** take `HttpContext` in both lambdas and allow when + `doc.Owner == ctx.Caller().SubjectId` **or** `Authz.CanBeoordelen(ctx.Caller())` **or** + `Authz.CanManageCases(Authz.ResolvePrincipal(ctx))`; 404 (not 403) otherwise, per PRD-0002 + §8's "avoid resource-existence enumeration". Same predicate for `/uploads/status`. +- **Effort:** S + +### BIO-005 — `POST /registrations` links arbitrary document ids with no ownership check + +- **Control:** 9.4; 8.26 (unvalidated request field driving a state change) +- **Class:** **defect now** +- **Severity: medium** — integrity/availability, not confidentiality: the caller cannot read + another citizen's document, only permanently mark it `Linked`, which blocks that citizen + from ever deleting it (`DocumentStore.DeleteOwned` returns `Linked`, `Program.cs:257-259`). +- **Evidence (read):** + - `Program.cs:187-190` — `POST /registrations` passes `req.Documents` straight to `Submit`. + - `Program.cs:919-925` (`Submit`) — `DocumentStore.Link(documents.Where(…).Select(d => d.DocumentId!))` + and `DocumentStore.Audit("post-delivery", …)`. Neither is owner-scoped; + `DocumentStore.Link` (`Data/DocumentStore.cs:129-141`) takes no owner at all. + - Contrast `Program.cs:367`, the newer submit path, which rejects foreign ids before linking. + - **The endpoint has no frontend caller.** `grep` over `apps`/`libs` for the generated + client's `registrations` method returns nothing outside `api-client.ts` itself; only + `/change-requests` is still called (`registratie/infrastructure/change-request.adapter.ts:17`). +- **Baseline citation:** **BL-003** (48 endpoint mappings in one 940-line file — the condition + under which a guard added on one submit path is not added to the other); §7 Backend + CQRS-light row, which names `Submit` as one of the cross-cutting wrappers. +- **Remediation, minimal:** either delete the endpoint (it is dead, and WP-72 already removed + its siblings), or add the one `ForeignIds` guard the other submit path uses. Deleting is + smaller and removes the surface entirely. +- **Effort:** S + +### BIO-006 — the PII-reveal capability belongs to the default role, and its step-up is a client-asserted constant + +- **Control:** 9.4 (least privilege); PRD-0002 §5d ("the server re-checks the environment + attribute before permitting") +- **Class:** **production gate** (the role mapping is a documented POC choice) with one + **defect-now** sub-item (the step-up check is a no-op as wired) +- **Severity: medium** +- **Evidence (read):** + - `Domain/Authorization/Authz.cs` — `CanRevealBigNummer(principal) => principal.Role == PrincipalRole.Drafter`. + - `StubIdentityProvider.cs:23-27` — the `_ =>` arm of the role switch is `Drafter`. So the + **absence** of any role header yields the role that holds the PII reveal. `roles-and-access.md` + states this as "`drafter` … the only role that may reveal a BSN" without noting that it is + also the default. + - `Program.cs:668-681` — the reveal requires `canReveal && ctx.Request.Headers["X-Step-Up"] == "true"`. + - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26` — the client sends + `'X-Step-Up': 'true'` **unconditionally, as a literal**. The precondition is therefore + satisfied by every call that reaches the endpoint; it constrains nothing. The endpoint's + own comment at `Program.cs:665` calls it "stubbed here as the X-Step-Up header", which is + accurate, but the FE side turns the stub into a constant rather than into a gesture-gated + value. +- **Baseline citation:** §7 Backend — `Domain/.../Authz.cs` listed among the pure rule classes + with co-located tests; §3b `ssp/brief` **42% spec reach** (11 of 26 files), with + `reveal-bignummer.adapter.ts` among the 15 unreached and **not** a `ui/` file, so BL-004's + Storybook carve-out does not cover it. +- **Remediation, minimal:** (a) send `X-Step-Up` only from the confirm handler, not from the + adapter's literal — one line, and it makes the stub behave like the control it stands in + for; (b) record in `roles-and-access.md` that `drafter` is the default role, so the + least-privilege consequence is visible; (c) production gate: bind the reveal to an app-overlay + attribute rather than the coarse role, as PRD-0002 §5c already says ("Role-based in the POC; + a real system resolves it from the app overlay independent of role"). +- **Effort:** S for (a)+(b); the overlay is a checklist item. + +### BIO-013 — the seeded-citizen endpoints ignore the caller entirely + +- **Control:** 9.4 (row-level scoping); 5.12 +- **Class:** **production gate** — explicitly acknowledged as unbuilt in PRD-0002 §9 P2 + ("Row-level scoping (§5b) still unbuilt") +- **Severity: medium** — with one seeded citizen it is invisible; the moment a second identity + exists (which `?subject=` already creates in e2e) every citizen reads the seeded citizen's + BRP address, birthdate and registration. +- **Evidence (read):** `Program.cs:135-155` — `GET /dashboard-view`, `GET /notes`, + `GET /brp/address`, `GET /duo/diplomas` take no `HttpContext` and return `SeedData.Registration` + / `SeedData.Person` / `SeedData.BrpAddress` regardless of the resolved caller. The + identity middleware runs (`:114-119`) and its result is discarded. +- **Baseline citation:** §7 Backend — the read side is "screen-shaped reads. Decisions are + computed here"; **BL-003** (all 48 mappings in one file). §3c `Program.cs` 97.4% line — + these endpoints are covered, so the gap is by design, not by omission. +- **Remediation, minimal:** none proposed for the POC. The checklist item is: every read that + returns person data must take the caller and scope on it, and the acceptance test is a + second seeded citizen who cannot see the first's data. +- **Effort:** M (out of this backlog) + +### BIO-018 — `IdempotencyStore` is keyed on a client-supplied string with no caller scoping, TTL or bound + +- **Control:** 9.4; 8.26 +- **Class:** **defect now** +- **Severity: low** — the cached values are only a `ReferentieResponse` or a ProblemDetails, so + a cross-caller replay leaks a reference number, not personal data. Filed because it is an + unscoped shared cache in an access-control path and the fix is trivial. +- **Evidence (read):** `Data/IdempotencyStore.cs:11-27` — a process-global + `Dictionary` keyed on the header alone; the file's own `ponytail:` comment + concedes "no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a + memory leak at scale". `Program.cs:901-909` reads and writes it with the raw header value, + never composed with the caller's `SubjectId`. +- **Baseline citation:** §7 Backend — `IdempotencyStore` listed among the 7 stores "Not behind + any port"; agent 02's `backend/Data` note ("the only store that is purely in-memory with no + `Reset()` and no TTL … shared by every test class in the process"). +- **Remediation, minimal:** key on `$"{ctx.Caller().SubjectId}:{idemKey}"`. One line, and it + also removes the cross-test-class bleed agent 02 flagged. +- **Effort:** S + +--- + +## 2. Control area: logging & monitoring (8.15, 8.16) + +### BIO-007 — only _denied_ authorization decisions are audited; successful admin and approval actions are not + +- **Control:** 8.15 (logging), 8.16 (monitoring activities); PRD-0002 §8 ("Audit log of + authorization-relevant events — **denials, PII reveals, approvals/rejections**, step-up, + break-glass") +- **Class:** **defect now** +- **Severity: medium** — the queryable trail the product ships as its audit surface + (`/beheer/audit`) cannot answer "who changed this", only "who was turned away". For a + register whose integrity is the product, that is the wrong half. +- **Evidence (read) — every `AuditAuthz` call site, checked:** + - `Program.cs:783` `OrgAdmin` → `allowed: false`. `:794` `StamdataAdmin` → `false`. + `:805` `CasesAdmin` → `false`. `:817` `Beoordelen` → `false`. `:827` `FlagsAdmin` → `false`. + All five gates audit **only** the denial branch; the allow branch calls `action()` and + returns. + - The one exception is `Program.cs:674` (the reveal), which passes the real `allowed`. + `:539` audits the NRC notification. `:856` audits a ZGW divergence. + - **Therefore the following leave no row in `AuthzAuditStore`:** `PUT /admin/flags/{key}` + (`:592`, and `Data/FeatureFlagStore.cs:54-66` writes nothing either — the endpoint does + not even emit a log line), `PUT /admin/org-template/{subOrgId}` (`:739`), + `POST /admin/org-template/{subOrgId}/rollback/{version}` (`:764`), + `DELETE /admin/cases/{id}` (`:554`, log line only at `:557`), + `DELETE /admin/uploads/{documentId}` (`:268`, see BIO-003), + `POST /brief/approve` / `/reject` / `/send` (`:631-658`, `LogBrief` writes a log line with + no actor), and `POST /beoordeling/{id}/besluit` (`:473`). + - The comment at `Program.cs:777-778` states the intent — "the allow path is left un-logged + (the endpoints log their own effect, e.g. publish)" — and the intent is only half met: + publish (`:755`) and admin case delete (`:557`) log; the other six do not log at all. +- **Baseline citation:** §7 Backend — `AuthzAuditStore` listed among the 7 static stores; + §3c `backend/Program.cs` 97.4% line / **84.8% branch** — the allow branches run constantly + and simply have nothing in them. +- **Remediation, minimal:** move the `AuditAuthz` call from each gate's deny branch to the + gate itself, passing the real boolean and wrapping `action()`: + `var ok = Authz.CanX(p); AuditAuthz(ctx, "x", resource, ok, p); return ok ? action() : Forbidden(...)`. + Five identical edits in `Program.cs:779-830`, no signature change, no new concept. Add + `AuditAuthz` to the three brief transitions and the besluit. +- **Effort:** S for the five gates; M including the brief/besluit sites and their tests. + +### BIO-008 — the BSN is written into the authz audit trail's `Resource` column + +- **Control:** 8.15; 5.12 (classification of special-category data) +- **Class:** **defect now** +- **Severity: high** — this is the single clearest "wrong even for a POC" item in the file: + a national identifier is persisted to a store whose own type documentation, class + documentation and endpoint documentation all state it holds none, and it is then served over + an API and rendered in a UI that repeat the same claim. +- **Evidence (read), the whole chain:** + 1. `Program.cs:674` — + `AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);` + The BSN is concatenated into the `resource` argument. + 2. `Program.cs:840-844` — that argument is logged + (`"authz action={Action} resource={Resource} …"`) **and** passed to + `AuthzAuditStore.Record(action, resource, …)`. + 3. `Data/AuthzAuditStore.cs:30-38` — `Record` inserts it as `AuthzAuditEntry.Resource` into + SQLite. The entity's doc comment (`:5-8`) says "**never** a name, BSN, or the value that + was (or wasn't) revealed"; the class comment (`:22-23`) says "Holds NO PII by construction + (see the entity); the schema test asserts it". + 4. `Program.cs:566-569` — `GET /admin/audit` returns `a.Resource` verbatim in `AuthzAuditDto`. + 5. `libs/beheer/src/ui/audit.page.ts:10-12` — the page's own header comment reads + "data-minimised, no PII", and its table renders the resource column. + 6. `Program.cs:666-667` — the endpoint comment: "every attempt — allow or deny — is audited + with **NO PII** (AuditAuthz)". +- **The false assurance, named:** `backend/tests/BigRegister.Tests/AuthzAuditTests.cs:51-53` + asserts over **column names** — + `Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …))` — + not over values. The BSN travels in a column called `Resource`, which the regex cannot see. + Four documents claim the control; the test that is cited as enforcing it does not enforce it. +- **Baseline citation:** §7 Backend — the 7 static stores, `AuthzAuditStore` among them; + §3c `backend/Data` **75.5% branch** (**BL-005**). +- **Remediation, minimal:** the resource ref for a per-owner brief does not need the BSN — use + the brief's own id, or `"brief/" + MaskTail(bsn, 3)` (the masker already exists at + `Program.cs:861-863` and is already used for the BIG-nummer at `:878`). Then extend + `AuthzAuditTests` to assert on **values**: seed a reveal attempt and assert no stored + `Resource` matches `\d{9}`. +- **Effort:** S + +### BIO-009 — the BSN is the `Actor` on every document audit row + +- **Control:** 8.15; 5.12 +- **Class:** **defect now** +- **Severity: medium** — same class as BIO-008 but a narrower blast radius: this table is not + exposed by any endpoint (`grep`: `DocumentStore.AuditLog` has no caller in `Program.cs`), so + it is a storage-side leak only. +- **Evidence (read):** + - `Data/DocumentStore.cs:52-63` — `Add(..., string owner)` ends with + `Audit("upload", doc.DocumentId, categoryId, owner)`, and `owner` is the caller's BSN + (`Program.cs:224` passes `ctx.Zorgverlener()`, whose `Bsn` becomes `StoredDocument.Owner`). + - `:159` — `DeleteOwned` likewise: `Audit("delete-user", documentId, categoryId, owner)`. + - `:181-189` — `Audit` persists it as `AuditEntry.Actor`. + - The class doc comment (`:30-36`) states "The audit log holds metadata only (never file + content **or other PII**)." + - `StoredDocument.Owner` itself is the BSN by design (it is the ownership key) — that is + correct and is **not** the finding; the finding is the _audit_ row, which needs only a + pseudonymous actor. +- **Baseline citation:** §7 Backend — `DocumentStore` among the 7 static stores; §3c + `backend/Data` 99.0% line / 75.5% branch. +- **Remediation, minimal:** pass `MaskTail(owner, 3)` (or a per-session pseudonym) as the + `actor` argument at `:61` and `:159`; the ownership column is untouched. +- **Effort:** S + +### BIO-010 — the BSN reaches logs and a persisted aanvraag field through the ZGW error path + +- **Control:** 8.15; 5.12 +- **Class:** **defect now**, conditional on `Zgw:Enabled=true` (off by default, + `appsettings.json`) — which is why the severity is medium and not high today, and why it + becomes high the moment OpenZaak is switched on. +- **Severity: medium** +- **Evidence (read):** + - `Zgw/OpenZaakZaakSource.cs:52` — the citizen-scoped list builds + `url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}"`. + The BSN is in the request URI. + - `Zgw/ZgwHttpClient.cs:76-81` — on a non-transient failure the client throws + `new HttpRequestException($"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}")`, + i.e. the exception message carries **both** the BSN-bearing URI **and** up to 500 characters + of the OpenZaak response body. `Zgw/OpenZaakZaakSource.cs:246` shows the write side posts + `inpBsn` in the body, and `Data/DocumentStore.cs:44-47` records that OpenZaak's own + validation errors for that field are a real, observed failure mode — so the body snippet + is a live BSN-echo path, not a hypothetical one. + - `Program.cs:851-856` (`RecordZgwDivergence`) — `app.Logger.LogError(ex, …)` logs that + message, **and** `ApplicationStore.SetZgwError(id, ex.Message)` persists it to the aanvraag + row (`Data/ApplicationStore.cs:286-294`). `Data/AanvraagMapper.cs:25,50,63,78,96` carry it + onward through the mapper. + - Mitigating, verified: `ZgwError` is **not** on any DTO in `Contracts/` and does not appear + anywhere in the frontend — the value is stored and logged, not served. + - Also verified and **not** a finding: `Zgw/ZgwDiagnosticHandler.cs:17-24` logs only method, + URI and byte counts, and only when `request.Content is not null` — so the BSN-bearing GET + URI is never reached by it, and no body is logged. It is opt-in behind `ZGW_DEBUG_HTTP=1` + (`Program.cs:73-78`). That hatch is clean. +- **Baseline citation:** §7 Backend — "ZGW anti-corruption layer | Fully built"; §3c + `backend/Zgw` 98.1% line / **85.5% branch — the strongest branch figure on the backend**, + so this is a design gap, not a test gap. +- **Remediation, minimal:** in `ZgwHttpClient.SendWithRetryAsync`, build the message from + `req.RequestUri.GetLeftPart(UriPartial.Path)` (drop the query) and omit the body snippet + from the _message_, logging it separately at Debug if the diagnostic value is wanted. +- **Effort:** S + +--- + +## 3. Control area: data classification & handling (5.12, 5.13) + +### BIO-011 — cross-owner list endpoints ship unmasked BSNs while the detail endpoint masks + +- **Control:** 5.12 (classification), 5.13 (labelling/handling); PRD-0002 §5c ("Default DTO + carries a **masked** BSN … or omits it entirely") +- **Class:** **defect now** +- **Severity: medium** — the correct behaviour is implemented one file away, so this is an + inconsistency rather than a missing capability, and the list is the _wider_ exposure (every + open case, not one). +- **Evidence (read):** + - `Contracts/Mappers.cs:72-74` — `ToAdminSummaryDto(now) => a.ToSummaryDto(now) with { Owner = a.Owner }`. + `a.Owner` is the raw BSN. `Contracts/Dtos.cs:107` documents the field as "populated for the + admin cross-owner list (WP-36)". + - `Data/LocalZaakSource.cs:15-16` — `ListCases` maps every row through `ToAdminSummaryDto`. + - `Program.cs:425-427` — `GET /admin/cases` returns `zaken.ListCases(...)` unmodified. + `Program.cs:434-438` — `GET /werkvoorraad` returns the same list, filtered by status only. + - **Contrast, in the same file:** `Program.cs:453` — the beoordeling detail does + `var masked = c with { Owner = MaskTail(c.Owner!, 3) };` before returning. The detail + masks; the list that leads to it does not. + - Rendered unmasked in both UIs: + `apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts:28` + (`subtitle: $localize\`:@@werkvoorraad.row.bsn:BSN ${item.owner}\``) and +`apps/ssp/src/app/registratie/ui/admin-cases.page.ts:91`("Eigenaar (BSN)").`behandeling/domain/beoordeling.ts:26`correctly documents its own field as "masked by the +server";`werkvoorraad-item.ts:20` documents its as "always populated" with no masking note. +- **Baseline citation:** §7 Backend — "Mapping | `Contracts/Mappers.cs` (`.ToDto()`, + `.ToDetailDto()`), `Data/AanvraagMapper.cs`" (the exact seam); §3a `bhp/behandeling` + 91.6% line / 81.5% branch — well tested, so nothing here is accidental. +- **Remediation, minimal:** apply `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` + (`Mappers.cs:74`) — one expression, and both list endpoints inherit it. If an admin genuinely + needs the full value, that is a reveal action with the existing gated+audited shape, not a + default list field. +- **Effort:** S + +### BIO-012 — `?role=` and `?subject=` are **not** stripped from production builds on three hand-written `fetch` paths + +- **Control:** 5.13 (handling — a BSN written to web storage and onto the wire), 9.4 +- **Class:** **defect now** — the defect is that a documented control does not hold, not that + the residual exposure is large. Stated plainly: an attacker who can send a header does not + need this path (see BIO-001), so the incremental attack value is low. The value of the + finding is that the docs and CLAUDE.md assert a production property the code does not have, + and anyone reasoning about production risk from those docs will get it wrong. +- **Severity: medium** +- **Evidence (read):** + - The interceptor chain **is** correctly gated: `apps/ssp/src/app/app.config.ts:58-62` and + `apps/behandelportal/src/app/app.config.ts:57-63` both register + `[scenarioInterceptor, roleInterceptor, subjectInterceptor(, medewerkerInterceptor)]` + only when `isDevMode()`. That much of the claim is true. + - But three adapters bypass `HttpClient` entirely and set the headers themselves, with **no** + `isDevMode()` guard: + - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26` — + `headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }` + - `apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts:43-46` — + `headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }` + - `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:79` — + `headers: { 'X-Role': currentRole() }` + - The readers are ungated too: `libs/shared/src/infrastructure/role.ts:23-31` and + `libs/shared/src/infrastructure/subject.ts:23-29` both read the query param and **write it + into `sessionStorage`** on any navigation, in any build. For `?subject=` that value is a + **BSN** — persisted to web storage under `dev-subject`, then sent as `X-Subject` on + `/brief/preview`. `subject.interceptor.ts:14-29` argues at length that the BSN must not + leave `SessionStore` ("`SessionStore`'s G1 comment is explicit that the BSN … is never + persisted or otherwise handed outward, by design") and then routes it through + `sessionStorage` instead. + - The claim being contradicted: `docs/reference/roles-and-access.md:23` — "Both are wired + only under `isDevMode()` — **they do not exist in a production build**"; CLAUDE.md's + "Scenario toggle (**dev-only**, not wired in prod builds)" and "Dev role stand-in + (**dev-only**)". `?scenario=` genuinely is stripped (its only consumer is the gated + interceptor plus `upload.adapter.ts`'s simulator); `?role=` and `?subject=` are not. + - BSN-in-URL has its own consequences independent of the header: browser history, `Referer`, + and any reverse-proxy access log. +- **Baseline citation:** §3b `ssp/brief` **42% spec reach** (11 of 26 files) — all three + hand-written `fetch` adapters are among the 15 unreached, and none is a `ui/` file, so + **BL-004**'s Storybook carve-out does not cover them; §3a `ssp/brief` 68.8% branch. +- **Remediation, minimal:** guard the three adapters — + `...(isDevMode() ? { 'X-Role': currentRole() } : {})` — or, cleaner, have `currentRole()` + and `currentSubject()` return `undefined` outside `isDevMode()` so every caller inherits the + guard and the sessionStorage write disappears with it. Then correct + `docs/reference/roles-and-access.md:23`. +- **Effort:** S + +### BIO-017 — two PII guards have no executable test + +- **Control:** 8.29 (security testing in development); 5.13 +- **Class:** **defect now** +- **Severity: low** — both guards were verified correct by reading them; the finding is that + nothing would catch a regression. +- **Evidence (read):** + - `apps/ssp/src/app/auth/application/session.store.ts` — **G1 holds on every path, + verified:** `restore()` (`:12-21`) returns `{ bsn: '', naam }` and never reads a stored + BSN; the constructor `effect()` (`:43-48`) writes only `{ naam: s.naam }`; `login()` + (`:52-56`) sets the signal in memory only; `logout()` removes the key. There is no path + that writes the BSN to `localStorage`. Identical in + `apps/behandelportal/src/app/auth/application/session.store.ts`. + - But agent 02's **TE-001** is right that the guard is untestable as written: `restore()` + is module-private and reads `localStorage` in a field initializer. Per-file lcov (agent 02, + from §3a): **LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13**. + - `apps/ssp/src/app/shell/debug-state/mask.ts:13-25` — `redactProfile` is a pure, exported, + directly callable PII-redaction function with **no spec** (agent 02's "missing test, not + blocked test"). It redacts name, birthdate and address and masks the BIG-nummer; verified + correct by reading. +- **Baseline citation:** §3a `ssp/auth` · `bhp/auth` **42.9% line / 46.2% branch — jointly the + worst line coverage in the frontend** (§8 ranking); **BL-009** (no coverage threshold is + enforced anywhere, so nothing ratchets this). +- **Remediation, minimal:** take agent 02's TE-001 seam (`parseStoredSession` in + `auth/domain/session.ts`, which already has a spec file) and add the three cases — + absent, non-JSON, wrong shape — plus one that asserts a stored `{"bsn":"…","naam":"…"}` + yields `bsn: ''`. Add a five-line spec for `redactProfile`. +- **Effort:** S (this is TE-001 plus one assertion; it does not need its own ticket if TE-001 + is scheduled — but the BSN assertion must be in TE-001's acceptance criteria, which today + it is not). + +--- + +## 4. Control area: cryptography (8.24) + +### BIO-014 — nothing is encrypted at rest: document bytes, BSNs and the audit trail sit in a plaintext SQLite file + +- **Control:** 8.24 (use of cryptography) +- **Class:** **production gate** +- **Severity: high** — the file contains the highest-classification data in the system in one + place, unprotected. +- **Evidence (read) — what is at rest, and what class of data:** + + | Table | Contains | Class | + | ----------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------- | + | `Documents` (`DocumentStore.cs:9-20`) | `byte[] Content` — the uploaded diploma / identity / language-proficiency scan | AVG art. 9-adjacent; identity documents | + | `Documents.Owner` | BSN | AVG art. 87 national identifier | + | `Applications.Owner` (`ApplicationStore`) | BSN | same | + | `Applications.ZgwError` | may contain a BSN (BIO-010) | same | + | `AuthzAudit.Resource` | contains a BSN today (BIO-008) | same | + | `AuditEntries.Actor` | BSN (BIO-009) | same | + | `Briefs` / `Briefs.ArchivedHtml` | the rendered letter, incl. name, BIG-nummer, address | personal data | + - `Data/Db.cs:16-18` — `UseSqlite(ConnectionString)`, default + `"Data Source=bigregister.db"`. No key, no `SQLCipher`, no page encryption. + - `DocumentStore.cs:3-8` records the intent — "a real backend persists them to blob storage + keyed by DocumentId" — so the shape is a known stand-in. + - The file lands in the app's working directory and, under `docker-compose.yml`, on the host + through the `./backend:/src:z` bind mount. **Verified: it is gitignored** + (`backend/.gitignore:5`, `git check-ignore` confirms both copies), and `git ls-files` shows + zero tracked `.db` files — so there is no committed-database finding. + - No transparent-data-encryption, no filesystem-level requirement, and no key management + exists anywhere in the repo. + - Also verified and **not** a finding: no secrets are committed. `appsettings.json`'s `Zgw` + block ships every credential field empty, `backend/openzaak/setup_configuration/data.prod.yaml` + and `seeded.env` are both gitignored (`.gitignore:57`, `:60`), and + `ZgwOptions.Secret`'s doc comment correctly states it is "Held only by the BFF, never the + browser". `ZgwTokenProvider.cs` mints a short-lived HS256 JWT per call with no refresh + token to store — sound for what it is. + +- **Baseline citation:** §2 size inventory — `backend/Data` (excl. Migrations) 1 697 lines, + the largest backend folder after `Program.cs`; §7 Backend — the 7 static stores, "each opens + a short-lived context via `Db.Create()` under its own lock". +- **Remediation, minimal:** none for the POC. Checklist items: move document bytes out of the + relational store to encrypted object storage keyed by `DocumentId` (the code already says + this is the target); require encryption at rest for the database (managed-service TDE or + SQLCipher); define key custody and rotation. **Prerequisite:** BIO-008/009/010 first, so the + BSN is not in three places that do not need it before deciding what must be encrypted. +- **Effort:** L (infrastructure), but S to write the requirement down and to stop widening it. + +### BIO-015 — no transport security, no security response headers, Swagger and `AllowedHosts: *` unconditional + +- **Control:** 8.24 (cryptography in transit), 8.28 (secure coding — attack surface) +- **Class:** **production gate** +- **Severity: medium** +- **Evidence (read):** `Program.cs:86-131` is the whole pipeline. It contains no + `UseHttpsRedirection`, no `UseHsts`, no `UseAuthentication`/`UseAuthorization`, and no + response-header middleware — so no `Strict-Transport-Security`, no + `X-Content-Type-Options: nosniff`, no `Content-Security-Policy`, no `Referrer-Policy`. + `app.UseSwagger(); app.UseSwaggerUI();` (`:121-122`) run in every environment, with no + `app.Environment.IsDevelopment()` guard. `appsettings.json` sets `"AllowedHosts": "*"`. + CORS (`:37-39`) allows only `http://localhost:4200` — which is tight, and worth noting is + effectively unused since both the dev servers and `docker-compose.yml` proxy `/api` + same-origin (`API_PROXY_TARGET`); the behandelportal's `:4201` is not in the list and does + not need to be. + - **`nosniff` in context:** the upload allow-list is `application/pdf`, `image/jpeg`, + `image/png` only (`Domain/Documents/DocumentCategory.cs:20-22`, enforced at + `Program.cs:216-217`), and `GET /uploads/{id}/content` serves `inline` only for pdf and + `image/*` (`:235`). No script-capable type (SVG, HTML) can be uploaded, so the usual + stored-XSS-via-inline-attachment path is **closed by the allow-list**. The content type is + nevertheless client-declared rather than sniffed from magic bytes, which is why `nosniff` + belongs on the checklist rather than being a finding today. +- **Baseline citation:** **BL-003** (`Program.cs` 940 lines, file CC 78 — the one place every + pipeline decision lives); §3c `Program.cs` 97.4% line / 84.8% branch. +- **Remediation, minimal:** wrap Swagger in `if (app.Environment.IsDevelopment())` — one line, + and it is a genuine attack-surface reduction with no POC cost. The rest are checklist items, + most of which belong to the reverse proxy rather than the app. +- **Effort:** S for Swagger; the rest is deployment configuration. + +--- + +## 5. Control area: secure development (8.25, 8.28, 8.29) + +### BIO-016 — what the security gates cover, and what they do not + +- **Control:** 8.25 (secure development lifecycle), 8.28 (secure coding), 8.29 (security + testing in development and acceptance) +- **Class:** **production gate** +- **Severity: medium** +- **Evidence (read) — `.github/workflows/ci.yml`, in full:** + + **Present, and genuinely blocking:** + - `semgrep scan --config p/default --config p/csharp --metrics=off --error` (`:254-279`) — + SAST on both sides, `--error` makes it a gate, telemetry off, prior findings triaged + rather than suppressed wholesale. + - `npm audit --omit=dev` (`:128`) — the shipped bundle must audit clean. + - All actions pinned to full SHAs (`:32-33`, `:64`, `:70`, …) — supply-chain hygiene. + - `permissions: contents: read` at workflow level (`:9-11`), plus per-ref concurrency cancel. + - `npm run dep:check` (`:109`) — 11 `severity: error` architecture rules, **frontend only**. + - `dotnet format --verify-no-changes` + `dotnet test --filter "Category!=Integration"` + (`:199-204`) — 241 backend tests. + - `api-client-drift` (`:281-317`) — the wire contract cannot drift unnoticed. + + **Absent:** + - **No dependency vulnerability scan on the backend.** There is no + `dotnet list package --vulnerable --include-transitive` step; `npm audit` covers only the + frontend. The .NET dependency tree is unscanned. + - **No secret scanning** (gitleaks/trufflehog). Today nothing is committed (verified in + BIO-014), so this is prevention, not repair. + - **No authorization regression suite as a gate.** `AuthzTests.cs`, `AuthzAuditTests.cs`, + `WerkvoorraadTests.cs` and `StubIdentityProviderTests.cs` exist and run inside + `dotnet test`, but nothing asserts the _set_ of gated endpoints — so an endpoint added + without a gate (BIO-004's shape) fails no test. + - **No backend architecture enforcement at all — BL-006.** `Domain/` purity holds by + convention. The property ADR-0005 depends on ("ZGW shapes never leave `Zgw/`") and the + property this file depends on ("authorization lives in `Authz`") are both review-maintained. + - **No coverage ratchet — BL-009.** Nothing can regress-test a security fix by CI number. + - No DAST, no container image scan, no SBOM. Reasonable omissions for a POC; listed so the + production decision is explicit. + +- **Baseline citation:** **BL-006** (verbatim: "the backend has zero automated architecture + enforcement … `Domain/` purity currently holds by convention") and **BL-009** (no coverage + threshold is enforced anywhere). +- **Remediation, minimal:** two cheap additions with real value here — + (a) `dotnet list package --vulnerable --include-transitive` as a failing step in the + `backend` job; (b) one endpoint-inventory test that enumerates the app's route table and + asserts every route outside a small allow-list passes through one of the six authorization + wrappers. (b) is the test that would have caught BIO-003, BIO-004 and BIO-005. +- **Effort:** S for (a), M for (b). + +--- + +## 6. Control area: change control (8.32) + +### BIO-020 — the only deployment artifact in the repo builds development bundles + +- **Control:** 8.32 (change management); 8.25 +- **Class:** **production gate** +- **Severity: medium** — there is no release path, so there is no gate at which any of the + production-gate items in this file would be checked. +- **Evidence (read):** `docker-compose.yml` — the `api` service sets + `ASPNETCORE_ENVIRONMENT=Development` and runs `dotnet run`; both `web` and + `web-behandelportal` run `npx ng build … --configuration development --localize`, and the + file's own header comment states why: "development config keeps `isDevMode()=true` so the + dev tools render". The image is `mcr.microsoft.com/dotnet/sdk:10.0` / `node:24-slim`, and + the header opens "dev-server images (not multi-stage prod builds) — this is a demo". + **Consequence for every dev hatch in this file:** in the one containerised deployment the + repo ships, `isDevMode()` is `true`, so `roleInterceptor`, `subjectInterceptor`, + `medewerkerInterceptor`, `scenarioInterceptor` and the `⚙ state` debug panel are all live. + That is correct for a demo and must not be mistaken for a production deployment. +- **Baseline citation:** §2 size inventory (two apps + one backend, all deployed by this one + file); **BL-006** (no automated enforcement that would distinguish the two). +- **Remediation, minimal:** none for the POC. Checklist: a separate production compose/Helm + artifact with `--configuration production`, `ASPNETCORE_ENVIRONMENT=Production`, and a + release checklist that names this file's production gates. +- **Effort:** M (out of this backlog) + +**Positive finding, recorded rather than ticketed.** Change control over the _business rules_ +is genuinely strong and is the model the rest should follow: stamdata is config-as-code +(ADR-0004), validated at build by `StamdataValidationTests`, with **no runtime write +endpoint at all** — verified: `Program.cs:164` and `:173` are both GETs behind +`StamdataAdmin`, and `libs/beheer`'s editor downloads a JSON file for a reviewed PR +(`libs/beheer/src/infrastructure/stamdata.adapter.ts:16-20`, "There is no write method"). A +bad reference-data edit fails CI, never production. The two sanctioned runtime-editable +surfaces (`OrgTemplateStore`, `FeatureFlagStore`) both keep their catalog in code and fail +closed on an unknown key (`Data/FeatureFlagStore.cs:56`) — but see BIO-007: their **writes are +not audited**, which fails clause (4) of the four-part test agent 06's ADR-C-009 proposes. + +--- + +## 7. Control area: input validation (8.26) + +**The server is the authority, and it is.** Verified against ADR-0001's rule ("the FE renders +decisions, it does not recompute business rules"): `SubmissionRules.RejectPhoneChange` +(`Domain/Submissions/SubmissionRules.cs:36-42`) re-validates the phone number server-side +with the same normalisation the FE's `parseTelefoonnummer` applies, and says so; +`DocumentRules.RejectUpload` (`Domain/Documents/DocumentCategory.cs:82-90`) authoritatively +enforces the content-type allow-list and the size cap before any byte is stored; +`Program.cs:208-217` validates multipart shape and required fields before reading the file; +`Program.cs:367` validates document ownership on submit. `IntakePolicy.ScholingThreshold` is +shipped to the FE as a _value_ for instant feedback and re-validated server-side — the +config-value shape ADR-0001 prescribes. The 30 FE `parse*` boundaries (baseline §7) are +defence in depth on the response direction, not a substitute; CLAUDE.md explicitly puts +runtime DTO validation on every endpoint out of scope, and this pass does not reopen that. + +Two gaps, both narrow: + +### BIO-019 — `GET /stamdata/{table}?peildatum=` 500s on unparseable input + +- **Control:** 8.26 +- **Class:** **defect now** +- **Severity: low** — admin-gated, and the failure is a 500 rather than a leak. Filed because + an unhandled exception on a user-supplied string is exactly what 8.26 exists to prevent, and + in a Development environment the exception detail is returned to the caller. +- **Evidence (read):** `Program.cs:178` — + `var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`. + `DateOnly.Parse` throws `FormatException` on anything unparseable; there is no + `TryParse`, no 400 path, and `.Produces` on the endpoint declares only 200/403/404. +- **Baseline citation:** §3c `backend/Stamdata` 96.8% line but **71.7% branch** — named in + **BL-005** as the second-weakest branch axis; this is one of the unentered branches. +- **Remediation, minimal:** `DateOnly.TryParse(p, out var d) ? … : Results.Problem(statusCode: 400)`. +- **Effort:** S + +### Noted, not filed + +`GET /uploads/status?localIds=` splits an unbounded comma-separated list into a single +`WHERE IN` query (`Program.cs:242-248`, `DocumentStore.ByLocalIds`). No cap. Real but +negligible at POC scale; it belongs with BIO-018's "bound the client-supplied inputs" theme +rather than as a ticket of its own. + +--- + +# Module sections + +Every module in scope gets a section. "No findings" is a result, not an omission. + +## apps/ssp — auth + +**BIO-017** (the G1 guard has no executable test). **The guard itself holds on every path** — +`restore()`, the persistence `effect()`, `login()` and `logout()` were each read and none +writes the BSN to storage. No other findings: `DigidAdapter` is the faked login CLAUDE.md +places out of scope, and `Session.bsn` never leaves the in-memory signal (verified: the one +place that needed it, `subject.interceptor.ts`, deliberately does **not** reach into +`SessionStore` — see BIO-012 for what it does instead). + +## apps/ssp — registratie + +**No findings of its own.** It is the consumer side of **BIO-011**: +`ui/admin-cases.page.ts:91` renders the unmasked BSN column the backend ships; the fix is +server-side. `application/draft-sync.ts` and the five value objects +(`domain/value-objects/`) do format validation only and never act as authority — correct per +ADR-0001. `parseBsn` (`libs/shared/src/kernel/bsn.ts`) is checksum-only and says so. + +## apps/ssp — herregistratie + +**No findings.** The scholing threshold arrives from the server as a config value +(`domain/intake.machine.ts:52`) with the offline fallback ADR-0001 sanctions; nothing in this +context touches PII, authorization or the network directly. + +## apps/ssp — brief + +**BIO-012** (the three hand-written `fetch` adapters carry `?role=`/`?subject=` into +production builds) and **BIO-006** (the client sends `X-Step-Up: 'true'` as a literal). The +context is otherwise the best-behaved consumer of the decision-DTO pattern in the repo: +`application/brief.store.ts:31,103-108` derives every gate from `BriefState.loaded.decisions` +and states "this store never computes them itself" — verified true. + +## apps/ssp — showcase, shell, root + +**BIO-017** (`shell/debug-state/mask.ts::redactProfile` has no spec; verified correct by +reading). **The dev panel is genuinely gated and is not a finding** — +`libs/shared/src/layout/shell/shell.component.ts:73,79` renders it only under +`@if (isDev && debugPanel)` with `isDev = isDevMode()`, and +`shell/debug-state/debug-state.component.ts:176` masks the BSN even there. The `showcase` +context is a teaching page and reads every context by sanction; it introduces no data path. + +## apps/behandelportal — auth + +**BIO-002** — this is where it lands. `app.config.ts:57-63` is the gate; +`auth/infrastructure/medewerker.ts:14` is the fixed stand-in id. Also relevant and already +owned by agent 06: `auth/ui/login.page.ts:31` logs a backoffice user in through DigiD with a +BSN (ADR-C-004). + +## apps/behandelportal — behandeling + +**No findings of its own.** Consumer side of **BIO-011**: +`domain/werkvoorraad-item-view.ts:28` renders the unmasked BSN for every queue row while +`domain/beoordeling.ts:26` correctly documents its own field as server-masked. Positively: +`infrastructure/beoordeling.adapter.ts:87-97` rejects the payload at the parse boundary if +`decisions.canBesluiten` is absent — deny-by-default at the wire, which is the right reflex. + +## apps/behandelportal — shell, root + +**BIO-002** (the `app.config.ts` interceptor gate). No other findings — routing, providers and +nav config only. + +## libs/shared — domain + +**No findings.** `capability.ts`, `role.ts`, `feature-flag.ts` — 30 lines of type declarations +with no executable statement (agent 02's verified correction to BL-004). The capability names +are enforced at runtime by `parseMe`, which is exported and spec'd. + +## libs/shared — application + +**No findings.** Both access-control primitives were read and are correct: +`access.store.ts:34-37` is deny-by-default (`rd.tag === 'Success' && rd.value.includes(cap)`), +and `whenReady()` (`:50-53`) exists specifically so the guard cannot read `can()` mid-load and +deny an entitled user. `auth.guard.ts:22-47` (already moved here per agent 06's ADR-C-006) +reads only `SESSION_PORT` and `AccessStore` and documents itself accurately as "the UX +pre-gate. The backend re-enforces regardless (403)". **That claim was verified endpoint by +endpoint for the admin surfaces and holds:** `/beheer/stamdata` → `StamdataAdmin` +(`Program.cs:164,173`), `/beheer/zaken` → `CasesAdmin` (`:425,554`), `/beheer/audit` → +`CasesAdmin` (`:566`), `/beheer/functies` → `FlagsAdmin` (`:592`), `/brief/huisstijl` → +`OrgAdmin` (`:726,732,739,751,764,703`), `/aanvraag/:id` → `Beoordelen` (`:446,473`). Every +capability the guard checks has a server-side twin. The exceptions are in `Program.cs`, not +here: BIO-003 (a gate outside `Authz`), BIO-004 and BIO-005 (endpoints with no gate at all) — +and none of those has a `capabilityGuard` claiming to front it. + +## libs/shared — infrastructure + +**BIO-012** — `role.ts:23-31` and `subject.ts:23-29` are the ungated readers, and +`subject.ts` is where a BSN reaches `sessionStorage`. Otherwise clean: +`api-client.provider.ts:58` attaches the Idempotency-Key only when `method !== 'GET'`, and +`:66`'s `retry({count: 2})` is GET-only. + +## libs/shared — ui + +**No findings.** No network, no storage, no PII decision. The `masked-value` atom renders what +it is given; the masking itself lives in `kernel/pii.ts`. + +## libs/shared — layout + +**No findings.** `shell.component.ts:73,79` is the dev-panel gate and it is correct +(see ssp/shell above). + +## libs/shared — kernel + +**No findings, and this is the reference standard.** `bsn.ts` is a "parse, don't validate" +value object whose doc comment correctly classifies a BSN as "art. 9 GDPR/AVG +special-category data" and correctly scopes itself to format+elfproef ("identity is still +faked in this POC"). `pii.ts`'s `maskTail`/`maskBsn` are pure, and the backend keeps a +verified twin (`Program.cs:861-863`) so wire redaction and UI redaction agree. §3a: 96.4% +line / 90.0% branch, §3b **100% reach** — the best-covered module in the repo. + +## libs/shared — upload + +**No findings in this remit**, but two dependencies to record: the document bytes this module +moves are the objects **BIO-004** serves without a check and **BIO-014** stores unencrypted. +`upload.adapter.ts:216-217`'s server-side counterpart enforces the content-type allow-list +authoritatively, so the FE's accept filter is UX only — the correct division. Agent 02's +TE-005 (`uploadOutcome`) is an 8.26-relevant boundary; see the compliance-review section. + +## libs/shared — testing + +**No findings.** Kept out of production by the `no-testing-in-production` dependency-cruiser +rule (§6, 0 violations). + +## libs/shared — environments + +**No findings.** `apiBaseUrl` only; no credentials, no keys. + +## libs/beheer + +**No findings of its own.** It is the display surface for **BIO-008**: `ui/audit.page.ts:10-12` +describes the trail as "data-minimised, no PII" and renders the `resource` column that today +contains a BSN. `ui/feature-flags.page.ts:93` is the caller of the unaudited admin write in +**BIO-007**. `infrastructure/stamdata.adapter.ts:16-20` is genuinely read-only, which is the +positive ADR-0004 note in §6. + +## backend/Program.cs + +**BIO-003, BIO-004, BIO-005, BIO-006, BIO-007, BIO-013, BIO-015, BIO-019.** Eight of the +twenty findings land in one 940-line file, which is itself the observation: **BL-003** records +it as "the single largest complexity concentration in the repo", and the six authorization +wrappers (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`, plus the +orphaned `IsAdmin`) sit 500 lines below the endpoints they gate, with nothing that enumerates +which endpoint uses which. That is the structural condition under which BIO-003/004/005 exist, +and it is why BIO-016's proposed route-table test is worth more than three individual fixes. + +## backend/Domain + +**BIO-001, BIO-002, BIO-006** — all three live in `Domain/Authorization/`. The rest of the +folder is clean and its design is right: `Authz` is a single source of truth where it is used, +the same function both emits the decision flag and gates the mutation +(`Authz.Decisions` ↔ `Authz.CanActOn` ↔ `BriefStore.Review`), and the four-eyes rule +(`CanActOn(Approve, …) => principal.Role == Approver && ActingId(principal) != drafterId`) is +a real segregation-of-duty control, correctly ordered Forbidden-before-Conflict. +`CanBeoordelen(CallerIdentity)` is deliberately caller-kind-derived rather than role-derived +and is the one capability that a forged `X-Role` cannot reach — the right instinct, and the +reason BIO-002 fails closed in that direction. `Domain/` is EF-free and ASP-free (§7), though +nothing enforces it (**BL-006**). + +## backend/Data + +**BIO-008, BIO-009, BIO-014, BIO-018.** All four are storage-side data-handling items rather +than logic defects; the store shape itself (static, `Db.Create()`-per-call, documented in +`Data/Db.cs:6-12`) is out of scope here and is deliberately not challenged — agent 02 reaches +the same conclusion from the testability side. + +## backend/Zgw + +**BIO-010** only. Otherwise the strongest module in the backend for this pass: the ACL is +complete (ADR-0005, fully conformed per agent 06), the client secret is BFF-only and never +reaches the browser, tokens are short-lived and minted per call with no stored refresh +credential, the inbound notification webhook fails closed on an unconfigured secret +(`ZgwOptions.NotificatieAuthorization`: "Empty (the default) means every notification is +rejected — an unconfigured secret must never mean 'accept anything'"), and the diagnostic +handler logs no bodies and is opt-in. §3c: 98.1% line / **85.5% branch, the best on the +backend**. + +## backend/Contracts + +**BIO-011** — `Mappers.cs:74` is the single line that decides whether a cross-owner list ships +a raw BSN, so the fix is one expression here rather than at each endpoint. No other findings: +`Dtos.cs` contains no `Bsn` field by name (verified by grep) — the BSN travels only as +`Owner`, which is exactly the field BIO-011 addresses. + +## backend/Stamdata + +**BIO-019** only, and it is a low-severity parse gap. The module is otherwise the best +change-control story in the repo — see the positive finding in §6. + +--- + +# Compliance review required + +**This is the mandatory flag. Agent 08 must carry every row into `99-backlog.md`, attached to +the finding, not filed as a separate ticket.** These are findings from agents 02, 04 and 06 +that touch a control area in §0. A flag is not a rejection: each of these should proceed, with +a compliance acceptance criterion added to its definition of done. + +| Their ID | Their title (abbrev.) | Control touched | Why it is flagged, and what the added acceptance criterion must be | +| ------------------- | ---------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **TE-001** | `SessionStore.restore()` reads `localStorage` inline | 5.13, 8.29 | The extracted `parseStoredSession` **is** the G1 PII guard. Ship it with an explicit assertion that a stored `{"bsn":…}` yields `bsn: ''` — see **BIO-017**. Without that assertion the ticket moves the guard without testing it. Lands twice (both apps). | +| **TE-002** | Trust boundary hidden in a global-`fetch` method | 5.12, 8.26, 8.15 | The boundary guards a **PII reveal**. `reveal-bignummer.adapter.ts` is also the site of **BIO-012** (`X-Role` ungated) and **BIO-006** (`X-Step-Up: 'true'` literal). Fix all three in one touch of the file, or the next reviewer will re-open it. | +| **TE-003** | `UploadTransport` port declared, concrete class injected | 5.12, 8.26 | The transport carries identity-document bytes. Introducing `UPLOAD_TRANSPORT` must not make it possible to substitute a transport in a production build — token factory only, no route/query override. | +| **TE-004** | `createUploadController` injects + binds at call time | 8.26 | `planFileSelection` is the client-side accept/reject decision. It is **UX only**; the ticket must not let it read as authority — the server allow-list (`DocumentRules.RejectUpload`) stays the authority. State that in the moved function's docstring. | +| **TE-005** | `xhrUpload` interprets responses inside an XHR closure | 8.26 | `uploadOutcome` is a trust boundary on an untrusted response. Also: the extraction must not move `parseError`'s ProblemDetails text into a place where it can be logged with a filename. | +| **TE-006** | Blob-to-browser handoff inlined in 3 commands | 5.13 | The blobs are **rendered letters containing name, BIG-nummer and address**, and the stamdata download is an admin export. `BLOB_PRESENTER` must not gain any persistence or caching; recording fakes belong in specs only. | +| **TE-008** | Brief transition rules live inside store methods | **9.4** | The five guards being extracted include the authorization ones, and `Authz.CanActOn` (the **four-eyes / SoD** rule) sits one line away at `BriefStore.cs:163`. Acceptance: `BriefRules` must **call** `Authz`, never re-implement it, and `tests/Domain/BriefRuleTests.cs` must cover approver == drafter. | +| **CQ-002** | 2 read stores write without the fold; errors dropped | 8.15, 8.16 | One of the two is `AdminCasesStore.delete` — a **destructive admin action** whose failure is currently silent. The error surfacing this ticket adds is the FE half of **BIO-007**'s server-side audit gap. Ship them aware of each other. | +| **CQ-003 / CQ-005** | `runSubmit` (write fold + idempotency mint) used for reads | 9.4, 8.26 | Splitting `runResult`/`runSubmit` narrows who mints an `Idempotency-Key`, which is the key **BIO-018** shows is an unscoped cache key. Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set. | +| **CQ-004** | `FeatureFlagStore.set` skips the fold, drops the error | 8.15, 8.32 | A `flags:manage` admin write that fails silently **and** writes no audit row (**BIO-007**). This is the concrete case that fails clause (4) of agent 06's **ADR-C-009** four-part test. Fix the FE error and the BE audit row together. | +| **CQ-006** | Read/write banner split abandoned in 5 of 7 sections | **9.4** | A large-diff, zero-semantic-change reordering across **all 48 endpoint mappings**, including every authorization wrapper call site. Acceptance: `AuthzTests`, `AuthzAuditTests`, `WerkvoorraadTests`, `OrgTemplateEndpointTests`, `StamdataEndpointTests` and `AdminCasesTests` all green, **and** a reviewer confirms each moved endpoint kept its gate. Land it alone, as CQ-006 already says. | +| **CQ-007** | `GET /brief` creates a row | 8.26, 9.4 | A GET with a persisted side effect, auto-retried by the FE. Either fix is acceptable to compliance; the documentation-only alternative is **not** — a non-idempotent GET must be visible in the code, not only in a ticket. | +| **ADR-C-002** | `libs/shared/src/upload/` does network outside `infrastructure/` | 5.12 | Moving the adapter that carries identity documents. Mechanical, but the acceptance criterion (deleting the depcruise carve-out) must not be met by widening a different rule. | +| **ADR-C-004** | The `Principal` union never landed | **9.2, 9.4** | This is the natural home for **BIO-002**. Acceptance must include: the behandelportal's identity works in a **production** build, and `IIdentityProvider` can express "no identity". Landing `Principal` on the FE alone would close the ADR and leave BIO-002 open. | +| **ADR-C-006** | Extract the actor-agnostic route guards to `libs/shared` | **9.4** | Already present in the tree at `libs/shared/src/application/auth.guard.ts` — verify the state before ticketing. Any future change to `authGuard`/`capabilityGuard` is an access-control change and needs the guard spec re-run for both apps. | +| **ADR-C-009** | Generalise the runtime-editable-config exception | **8.32, 9.4** | Agent 06's proposed clause (4) is "writes are admin-capability-gated **and audited**". Today they are gated but **not audited** (**BIO-007**). Either the ADR amendment lands with BIO-007, or the amendment ratifies a control the code does not implement. | + +**Not flagged** (read and judged to touch no control in §0): TE-007, TE-009, CQ-001, +ADR-C-001, ADR-C-003, ADR-C-005, ADR-C-007, ADR-C-008, ADR-C-010, ADR-C-011. + +--- + +# Pre-production compliance checklist + +The **production gate** items, as a checklist. None of these is a defect in the POC; every one +must be true before this system holds real BSNs. Ordered by dependency, not by severity. + +**Identity and access (9.1, 9.2, 9.4)** + +- [ ] **Replace `StubIdentityProvider`** with a provider built from verified DigiD claims + (zorgverlener) and verified employee-SSO/eHerkenning claims (medewerker). `X-Role`, + `X-Subject`, `X-Medewerker`, `X-Rollen` and `X-Admin` are removed as inputs, not merely + ignored. — **BIO-001** +- [ ] **`IIdentityProvider` can express "no identity"**, and an unauthenticated request is + rejected rather than defaulted. No code path may resolve a caller from a constant. — + **BIO-002** +- [ ] **The behandelportal has a non-dev identity path.** Verify by building both apps with + `--configuration production` and confirming the backoffice cannot act as a citizen. — + **BIO-002** (see also ADR-C-004) +- [ ] **A startup assertion** fails the app in Production if the resolved `IIdentityProvider` + is the stub. — **BIO-001** +- [ ] **Row-level scoping** (PRD-0002 §5b) on every read that returns person data. Acceptance: + a second seeded citizen cannot see the first's dashboard, notes, BRP address or diplomas. + — **BIO-013** +- [ ] **The PII-reveal capability comes from the app overlay, not the coarse role**, and is not + held by the default role. — **BIO-006** +- [ ] **Real step-up.** `X-Step-Up` is replaced by a server-verified assurance/recency + attribute; no client may satisfy it with a constant. — **BIO-006** + +**Cryptography (8.24)** + +- [ ] **Encryption at rest** for the database, with documented key custody and rotation. — + **BIO-014** +- [ ] **Document bytes move to encrypted object storage** keyed by `DocumentId` (the code + already names this as the target). — **BIO-014** +- [ ] **TLS everywhere**: `UseHttpsRedirection` + HSTS at the edge, and no plaintext listener. + — **BIO-015** +- [ ] **Security response headers**: `X-Content-Type-Options: nosniff`, CSP, + `Referrer-Policy`, and a real `AllowedHosts`. — **BIO-015** +- [ ] **Swagger and the OpenAPI document are Development-only.** — **BIO-015** + +**Logging, monitoring and retention (8.15, 8.16)** + +- [ ] **Every authorization-relevant event is audited on the allow path too** — admin + mutations, brief approvals/rejections, besluiten, PII reveals. — **BIO-007** +- [ ] **No BSN in any audit row, log line or persisted error field**, enforced by a test that + asserts on **values**, not column names. — **BIO-008, BIO-009, BIO-010** +- [ ] **Audit retention, integrity and access** are defined: how long, append-only, who may + read `/beheer/audit` (today it reuses `cases:manage`, which `Program.cs:565` already + flags as a placeholder for a dedicated `audit:read`). +- [ ] **Log shipping and alerting** — the audit trail is a SQLite table with no export path + today. + +**Data protection (5.12, 5.13)** + +- [ ] **A DPIA** covering BSN, uploaded identity documents and the BIG register, with a + documented lawful basis and retention schedule. Nothing in the repo covers this. +- [ ] **Data minimisation on every list endpoint** — no unmasked BSN as a default field. — + **BIO-011** +- [ ] **Deletion / retention** for uploaded documents and the audit trail. `AdminDelete` + exists; no retention policy does. + +**Secure development (8.25, 8.28, 8.29)** + +- [ ] **Backend dependency vulnerability scanning** in CI. — **BIO-016** +- [ ] **Secret scanning** in CI. — **BIO-016** +- [ ] **An authorization regression gate**: a test that enumerates the route table and asserts + every route passes an authorization wrapper or is on an explicit allow-list. — **BIO-016** +- [ ] **Backend architecture enforcement** (NetArchTest/ArchUnitNET) so `Domain/` purity, the + "ZGW shapes stay in `Zgw/`" property (ADR-0005) and "authorization lives in `Authz`" are + CI-maintained rather than review-maintained. — **BL-006** +- [ ] **A coverage ratchet**, so a security fix can be verified as not regressed by CI. — + **BL-009** +- [ ] **Penetration test / DAST** before go-live, with BIO-004's object-level authorization and + BIO-005's document linking as named test cases. + +**Change control (8.32)** + +- [ ] **A production build and deployment artifact exists** (`--configuration production`, + `ASPNETCORE_ENVIRONMENT=Production`), separate from the demo compose file, and its + release checklist references this list. — **BIO-020** +- [ ] **Verify by build, not by reading**: in a production bundle, `?role=`, `?subject=`, + `?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the three + hand-written `fetch` paths. — **BIO-012** + +--- + +## Summary + +| ID | Title | Control | Class | Sev. | Module | Effort | +| ----------- | -------------------------------------------------------------------- | ---------- | --------------- | ------ | ----------------------- | ------ | +| **BIO-001** | Backend trusts client-asserted identity headers in every environment | 9.2, 9.4 | production gate | high | BE/Domain+Program | S | +| **BIO-002** | Production backoffice has no identity; default is the seeded citizen | 9.4 | production gate | high | bhp/auth+root, BE | S | +| **BIO-003** | `X-Admin` is a second, unaudited gate outside `Authz` | 9.4, 8.15 | **defect now** | medium | BE/Program | S | +| **BIO-004** | `GET /uploads/{id}/content` + `/uploads/status` have no authz check | 9.4, 5.12 | **defect now** | high | BE/Program+Data | S | +| **BIO-005** | `POST /registrations` links arbitrary documents, unscoped | 9.4, 8.26 | **defect now** | medium | BE/Program | S | +| **BIO-006** | Reveal capability on the default role; step-up is a client constant | 9.4 | production gate | medium | BE/Domain, ssp/brief | S | +| **BIO-007** | Only denied decisions are audited; admin writes leave no trail | 8.15, 8.16 | **defect now** | medium | BE/Program | S–M | +| **BIO-008** | BSN written into the authz audit trail's `Resource` column | 8.15, 5.12 | **defect now** | high | BE/Program+Data | S | +| **BIO-009** | BSN is the `Actor` on every document audit row | 8.15, 5.12 | **defect now** | medium | BE/Data | S | +| **BIO-010** | BSN reaches logs + a persisted field via the ZGW error path | 8.15, 5.12 | **defect now** | medium | BE/Zgw+Program | S | +| **BIO-011** | Cross-owner lists ship unmasked BSNs; the detail masks | 5.12, 5.13 | **defect now** | medium | BE/Contracts | S | +| **BIO-012** | `?role=` / `?subject=` reach production on 3 hand-written fetches | 5.13, 9.4 | **defect now** | medium | ssp/brief, shared/infra | S | +| **BIO-013** | Seeded-citizen endpoints ignore the caller (no row scoping) | 9.4, 5.12 | production gate | medium | BE/Program | M | +| **BIO-014** | No encryption at rest for bytes, BSNs and the audit trail | 8.24 | production gate | high | BE/Data | L | +| **BIO-015** | No TLS/HSTS/nosniff/CSP; Swagger + `AllowedHosts:*` unconditional | 8.24, 8.28 | production gate | medium | BE/Program | S | +| **BIO-016** | CI security gates: semgrep+audit present; 5 gaps | 8.25/28/29 | production gate | medium | repo (CI) | S–M | +| **BIO-017** | Two PII guards have no executable test | 8.29, 5.13 | **defect now** | low | ssp/auth, ssp/shell | S | +| **BIO-018** | `IdempotencyStore` unscoped by caller, no TTL, unbounded | 9.4, 8.26 | **defect now** | low | BE/Data | S | +| **BIO-019** | `?peildatum=` 500s on unparseable input | 8.26 | **defect now** | low | BE/Stamdata | S | +| **BIO-020** | The only deployment artifact builds development bundles | 8.32 | production gate | medium | repo (compose) | M | + +**Twelve defect-now findings** (BIO-003, 004, 005, 007, 008, 009, 010, 011, 012, 017, 018, 019 +— BIO-006's step-up sub-item is counted inside its production gate), **eight production gates**. +Fifteen of the twenty are effort **S**. + +**Modules with no findings of their own:** ssp/registratie · ssp/herregistratie · +bhp/behandeling · libs/shared/{domain, application, ui, layout, kernel, upload, testing, +environments} · libs/beheer. + +**Recorded as correct, so a later pass does not "fix" them:** the deny-by-default +`AccessStore.can()` + `whenReady()` pair; `capabilityGuard`'s "UX pre-gate, backend +re-enforces" claim, verified endpoint by endpoint for all six admin surfaces; +`Authz.CanBeoordelen`'s caller-kind derivation (the one capability a forged `X-Role` cannot +reach); the four-eyes rule in `Authz.CanActOn` with Forbidden-before-Conflict ordering; the +`isDevMode()` gate on the debug panel and the interceptor chain; the ZGW secret never reaching +the browser and the notification webhook failing closed on an unset secret; the upload +content-type allow-list enforced server-side; stamdata having no runtime write endpoint at +all; and `libs/shared/src/kernel/{bsn,pii}.ts`, which are the standard the rest should be +measured against. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index e69de29..8c954a0 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -0,0 +1,421 @@ +## Scope: all findings from 00-baseline, 02-testability, 04-cqrs-light, 06-adr-conformance, 07-bio2-compliance — deduplicated, scored, CD-sequenced + +## Status: complete + +## Last updated: 2026-08-27 + +## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md, 07-bio2-compliance.md + +## --- + +# 99 — Consolidated refactoring backlog + +**47 findings in, 33 open tickets + 5 ADR-fixes + 1 shipped set out.** Everything below +traces to at least one `TE-`/`CQ-`/`ADR-C-`/`BIO-` finding and cites a baseline metric. + +**HALT.** This file is the deliverable. No Implementation Agent starts until a human has +approved it. Nothing in this pass was implemented; no source file was modified. + +--- + +## Coverage of this backlog — read this before treating it as complete + +Three of the seven Phase 1 agents were **deliberately skipped** by the operator +(reasons recorded in `_status.md`). This backlog therefore contains **no findings of the +following kinds**, and their absence is not evidence that none exist: + +| Agent not run | Category of finding that is absent | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **01 — readability** | Function/file length, naming, nesting depth, comment quality, dead code, test readability. No ticket below is a "this is too long/unclear" ticket. | +| **03 — DDD/hexagonal** | Backend layering, vertical-slice structure, port extraction, module boundaries. The backend's structure is untouched except where CQRS-light reached it. | +| **05 — BDD** | Nothing material — the agent self-reduced to a structural note; `gen:behaviour-spec` already covers the intent. | + +Concrete consequences, so nobody assumes these were considered and dismissed: + +- **`createDraftSync` (143 lines, the longest function in the repo, §4a) is only partly + addressed.** RB-21 splits its read half out on CQRS grounds. Whether the remainder is + still too long was never assessed. +- **The other named length/complexity candidates have no owner:** + `api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11) — + the only two CC>10 functions outside the mandated idioms per **BL-001**; the 293-line + CC-20 test method in `OpenZaakZaakSourceTests.cs`; and the six files over 400 lines + (§9). RB-19 reorders `Program.cs` but does not shorten it. +- **Backend structure was assessed only through the CQRS-light lens.** **BL-003**'s + invitation (940 lines → `Features/`) is filed as out-of-mandate **OOM-A**, not a ticket. + **BL-010** (`libs/shared/upload/` outside the layer convention) is resolved only + incidentally, by RB-24, which came from the ADR agent rather than the structure agent. +- **Two baseline observations remain unowned by any agent:** **BL-005** (backend branch + coverage 18 points behind line coverage; `Contracts` 65.0%, `Stamdata` 71.7%, `Data` + 75.5% — `backend/tests/` has no `Contracts/` folder at all) and **BL-009** (no coverage + ratchet anywhere). Neither is a testability _blocker_, so agent 02 correctly declined + both; they are coverage work with no seam to add, and no ticket below covers them. + +--- + +## Already done — implemented and committed, do not re-file + +Branch `refactor/adr-c-006-shared-route-guards`, five commits. + +| Finding | Commit subject | Status | Residual | +| ------------- | ----------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ADR-C-005** | `docs(adr-0002): accept, and record the unbuilt Principal union as debt` | **implemented** | ADR-0002 is now `Accepted`, so **RB-13 (ADR-C-004) now stands on a correct ADR** — that was the whole point of the gate. | +| **ADR-C-006** | `refactor(auth): share the actor-agnostic route guards (ADR-C-006)` | **implemented** | Auth duplication **211 → 151 lines**. §5's `ssp/auth 100% / bhp/auth 86.8%` rows and the `auth.guard*` clone pairs in the baseline are now **stale** — re-measure before citing them. Standing compliance criterion from agent 07: any future change to `authGuard`/`capabilityGuard` is an access-control change and must re-run the guard spec for both apps. | +| **CQ-004** | `fix(flags): surface a failed admin toggle instead of swallowing it` | **implemented** | **Half of its compliance criterion is unmet.** Agent 07 required "fix the FE error **and** the BE audit row together". The FE error shipped; `PUT /admin/flags/{key}` still writes **no** audit row. That half is carried by **RB-07**, and it is why **ADR-C-009** must not be signed off before RB-07 lands. | +| **TE-009** | `fix(stamdata): evaluate the profession validity window per call, not at type-load` | **implemented** | Also closed the latent dead-`ActiveOn`-branch bug. Not compliance-flagged. | +| **BL-008** | `build: make coverageExclude actually exclude the generated API client` | **implemented** | The reported `libs/shared/infrastructure` figure should now read ≈94.7%, not 6.9%. §3a is stale on that row. | + +**Correction to the hand-off.** The brief listed "CQ-002/004 (`FeatureFlagStore.set`)" as +fixed. Only **CQ-004** was — `FeatureFlagStore.set` is the CQ-004 subject. **CQ-002** +(`ApplicationsStore.cancel`, `AdminCasesStore.delete`) is **verified still open**: both +still do `try { await this.adapter.x(id) } catch { this.state.set(before) }` with no +`runSubmit`, no `Result`, and no error channel. It is filed below as **RB-20**. + +--- + +# The backlog + +**How to read the CD batch column.** A batch is a _suggested ordering wave_, not a release +train. Every ticket in the table ships **alone**, on its own merge, without any other +ticket in its batch. Where a ticket genuinely cannot ship alone it was split into a chain +(RB-22/RB-23) — see "Tickets that were rejected and split". `Depends on` means _must be +deployed first_, not _must ship together_. + +**Compliance column.** `SIGN-OFF` = requires compliance sign-off before merge, per rule 4. +Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative +16-row "Compliance review required" list, carries it — regardless of priority. + +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ------ | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | + +--- + +## Notes on the table + +**Why P1 is not simply "everything".** Rule 2's P1 definition ("violates a correct ADR, +blocks testability, or is a BIO2 compliance risk") would catch nearly every finding, which +would make the score useless. It is applied as: **P1 = a control is broken, an accepted +ADR's decision is unexecuted, or a security-relevant guard has no test today.** A ticket +that is merely _flagged because it touches a control_ (TE-003/4/5/6/8, CQ-006, ADR-C-002) +is **P2 with mandatory sign-off** — the compliance risk is one the ticket could introduce, +not one that exists. That distinction is the whole reason rule 4 is orthogonal to rule 2. + +**RB-01 and RB-02 sort above every structural ticket** regardless of effort. Both are live +production-shaped defects, independently verified: a BSN concatenated into the persisted +authz audit `Resource` (`Program.cs:674`) and an unauthorized document-content endpoint +(`GET /uploads/{documentId}/content`). Four documents claim the audit trail holds no PII +and the test cited as enforcing it (`AuthzAuditTests.cs:51-53`) asserts on **column +names**, so the BSN travels in a column called `Resource` that the regex cannot see — the +value-asserting test is part of RB-02's definition of done, not a follow-up. + +**RB-11 ships the doc correction in the same diff as the code.** `?role=` and `?subject=` +are _not_ stripped from production builds on three hand-written `fetch` adapters, while +`docs/reference/roles-and-access.md:23` says "they do not exist in a production build". +Correcting the doc without the code, or the code without the doc, both leave the repo +lying about itself. `?subject=` additionally writes a **BSN into `sessionStorage`** in any +build, which is the specific thing `SessionStore`'s G1 comment promises never happens. + +**RB-12 before RB-19, deliberately.** Agent 07 flags CQ-006 as needing the authz suites as +its safety net; agent 04 flags it as the prerequisite for OOM-A. RB-12's route-table test +is the check that "each moved endpoint kept its gate" is verified by CI rather than by a +reviewer's eye across a 900-line diff. RB-19 carries the only **High** risk in the table +for exactly that reason and must land alone, never mixed with a behaviour change. + +**RB-07 gates ADR-C-009, not the other way round.** Agent 06's proposed four-part test for +runtime-editable config includes "writes are admin-capability-gated **and audited**". +Today they are gated and not audited. Signing the ADR amendment first would ratify a +control the code does not implement. + +**RB-13's dependency on RB-09 is real, not stylistic.** Landing `Principal` on the +frontend alone closes ADR-C-004 and leaves BIO-002 wide open: a production behandelportal +build still resolves to the seeded **zorgverlener** — failing closed on backoffice +capabilities (correctly) but **open on every citizen-scoped endpoint** and holding +`CanRevealBigNummer`, because `drafter` is the no-header default. RB-09 makes "no +identity" representable at the interface; RB-13 is the FE half. + +--- + +## Merges — what was deduplicated, and how confident each merge is + +| Merged ticket | Findings folded in | Confidence | Reasoning | +| --------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **RB-10** | TE-001 + BIO-017 | **Certain** | Agent 07 says outright: "this is TE-001 plus one assertion; it does not need its own ticket if TE-001 is scheduled". BIO-017's second half (`redactProfile` spec) is a five-line spec in the same PII-guard category, so it rides along. | +| **RB-11** | BIO-012 + TE-002 + BIO-006(a) + BIO-006(b) | **Certain** | Agent 07 instructs: "Fix all three in one touch of the file, or the next reviewer will re-open it." All four land in the same three `fetch` adapters plus `role.ts`/`subject.ts` plus one doc line. BIO-006(b) is the same doc edit as BIO-012's. | +| **RB-09** | BIO-001(a) + BIO-001(b) + BIO-002 | **Certain** | BIO-001's own remediation _is_ (a) fail-fast + (b) "give `Resolve` a way to say no identity (see BIO-002)". BIO-002's root cause is the same non-nullable `Resolve`. One change, one file pair. | +| **RB-17** | CQ-003 + CQ-005 | **Certain** | Agent 04: "Fix them in one ticket; they are listed separately only because the module scope requires it." One shared-file split, five call sites. | +| **RB-14/12** | BIO-016 split into (a) and (b) | **Certain** | Two unrelated CI changes of different size and different value; the rest of BIO-016's "Absent" list is genuinely a production gate and stays on the checklist. | +| **RB-08** | BIO-003, sequenced behind RB-07 | High | Routing through `CasesAdmin` gives BIO-003's missing audit row for free **once** RB-07 has moved auditing to the allow path. Shipping BIO-003 first would mean writing the audit call twice. It can ship standalone if RB-07 slips. | +| **RB-18** | BIO-018, sequenced behind RB-17 | High | Agent 07: "Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set." Not a merge, an ordering constraint. | +| **RB-25/26/27** | TE-003/004/005, sequenced behind RB-24 | **Judgement call** | Agent 04 argued BL-010 must be resolved before anything is layered onto the upload folder, and RB-24 (ADR-C-002) is the ticket that resolves it. But the three seams are each independently shippable **today**, against the current paths. If RB-24 is deferred or rejected, unblock all three — the dependency is hygiene, not correctness. | + +**Merges considered and rejected:** + +- **BIO-008 / BIO-009 / BIO-010 kept as three tickets (RB-02/04/05).** They share a theme + ("no BSN in any audit row, log line or persisted error field") and a shared acceptance + criterion (assert on **values**, e.g. no stored string matching `\d{9}`). They were not + merged because they sit in three modules with three different test suites, and BIO-010 + is conditional on `Zgw:Enabled` (off by default) which gives it a different risk profile. + Three one-line fixes that each ship alone beat one cross-module sweep. **If a reviewer + prefers one ticket, merging them is defensible** — this is the least settled call here. +- **CQ-002 not merged into BIO-007 (RB-07).** They are the two halves of the same + admin-mutation-observability gap, but one is FE error surfacing and the other is BE + auditing. Agent 07 asked only that they "ship aware of each other". Cross-referenced, + not merged. +- **`SessionStore` not merged across the TE-001 / residual-auth-duplication overlap.** + Both touch `session.store.ts`, but agent 06 is explicit that merging the two apps' + session stores now would cement a citizen DigiD/BSN login as the backoffice's login — + the exact outcome ADR-0002 §3 exists to prevent. RB-10 lands the same seam **twice**, on + purpose. The duplication question reopens only after RB-13, on re-measurement. +- **ADR-C-004 not merged into BIO-002.** Split into RB-09 (BE, S) → RB-13 (FE, M) instead, + because a single ticket spanning both would not be independently deployable. + +--- + +## Tickets that were rejected and split (rule 3) + +**CQ-007 → RB-22 then RB-23.** As filed, CQ-007 is the one finding agent 04 marked +"**no** — FE+BE together": the FE must handle a 404 that the BE does not yet return. +Shipping it as one ticket is a coordinated release. Split into the standard +expand/contract pair: + +1. **RB-22 (expand, FE).** `BriefStore.load()` tolerates a 404 by calling the existing + `reset()` command once. Deploys against today's backend as a **no-op** — the BE never + 404s, so the branch is dead on arrival and provably safe. +2. **RB-23 (contract, BE).** `GET /brief` returns 404 when no brief exists; + `BriefStore.GetOrCreate` splits into `Get` + the already-existing `ResetAndCreate`. + Deploys only once RB-22 is live. + +Agent 07 rejected CQ-007's documentation-only alternative outright: "a non-idempotent GET +must be visible in the code, not only in a ticket". That alternative is therefore **not** +on the table. + +**No other ticket failed the single-deploy test.** TE-001 lands in two apps but in one +merge; RB-24 touches 30 dependents but is one atomic move; RB-19 is a 900-line diff but +zero-semantic-change. + +--- + +# ADR-fix tickets — architect approval required before any dependent code ticket + +None of these five is a code change. All five change what the repo's architecture +documents _claim_. **Three of them require a matching CLAUDE.md correction in the same +diff** (CLAUDE.md's own precedence rule: "the docs win — update this file"). + +| ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status | +| ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- | +| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | pending | +| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | pending | +| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | pending | +| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | pending | +| **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** | + +**No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it +as instructed and rejected it with evidence: `grep -rn "Principal" apps libs` returns one +comment and no type, so the rule was never _tested_, only _unexecuted_. Amending it now +would ratify the omission rather than the evidence. The correct sequence is +ADR-C-005 (done) → **RB-13** → **re-measure BL-002**; agent 06's expectation is that the +residual duplication drops from 151 lines to under 40 on its own. If RB-13 is still +unstarted at the next backlog cycle, _that_ is when the ADR-fix conversation becomes +legitimate. + +--- + +# Production gates — a release checklist, not tickets + +These are **correct for a POC** and must be true before the system holds real BSNs. They +are deliberately kept out of the ticket table: they are acceptance criteria for a release +that does not exist yet (there is no production build artifact at all — **BIO-020**), not +work that can be merged and deployed this week. Where a _part_ of a production-gate +finding was shippable now, that part was pulled out as a ticket and is named below. + +**Identity and access (9.1, 9.2, 9.4)** + +- [ ] Replace `StubIdentityProvider` with verified DigiD / employee-SSO claims. `X-Role`, + `X-Subject`, `X-Medewerker`, `X-Rollen`, `X-Admin` removed as **inputs**, not ignored. — BIO-001 +- [ ] Verify by building both apps `--configuration production` that the backoffice cannot + act as a citizen. — BIO-002 _(the interface half is **RB-09**; the FE half is **RB-13**)_ +- [ ] Row-level scoping on every read returning person data; acceptance = a second seeded + citizen cannot see the first's dashboard, notes, BRP address or diplomas. — BIO-013 +- [ ] The PII-reveal capability comes from the app overlay, not the coarse role, and is + **not held by the default role**. — BIO-006 _(the `X-Step-Up` literal is in **RB-11**)_ +- [ ] Real step-up: a server-verified assurance/recency attribute no client can satisfy + with a constant. — BIO-006 + +**Cryptography (8.24)** + +- [ ] Encryption at rest with documented key custody and rotation. — BIO-014 + **Prerequisite: RB-02/04/05 first**, so the BSN is not in three places that do not + need it before deciding what must be encrypted. +- [ ] Document bytes move to encrypted object storage keyed by `DocumentId`. — BIO-014 +- [ ] TLS everywhere: `UseHttpsRedirection` + HSTS at the edge. — BIO-015 +- [ ] Security response headers (`nosniff`, CSP, `Referrer-Policy`) and a real + `AllowedHosts`. — BIO-015 _(the Swagger gate is **RB-15**)_ + +**Logging, monitoring and retention (8.15, 8.16)** + +- [ ] Audit retention, integrity and access defined — how long, append-only, and who may + read `/beheer/audit` (it reuses `cases:manage`, which `Program.cs:565` already flags + as a placeholder for a dedicated `audit:read`). +- [ ] Log shipping and alerting — the audit trail is a SQLite table with no export path. +- [ ] _(Covered by tickets: allow-path auditing = **RB-07**; no BSN in any audit row, log + line or persisted error field = **RB-02/04/05**.)_ + +**Data protection (5.12, 5.13)** + +- [ ] A DPIA covering BSN, uploaded identity documents and the register, with lawful basis + and retention schedule. Nothing in the repo covers this. +- [ ] Deletion / retention policy for uploaded documents and the audit trail. +- [ ] _(Covered: data minimisation on list endpoints = **RB-03**.)_ + +**Secure development (8.25, 8.28, 8.29)** + +- [ ] Secret scanning in CI (prevention — nothing is committed today, verified). — BIO-016 +- [ ] Backend architecture enforcement (NetArchTest/ArchUnitNET) so `Domain/` purity, ZGW + containment (ADR-0005) and "authorization lives in `Authz`" are CI- rather than + review-maintained. — BL-006 +- [ ] A coverage ratchet, so a security fix can be verified as not regressed by CI. — BL-009 +- [ ] Penetration test / DAST, with BIO-004's object-level authorization and BIO-005's + document linking as named cases. +- [ ] _(Covered: backend dependency scanning = **RB-14**; the authorization regression gate + = **RB-12**.)_ + +**Change control (8.32)** + +- [ ] A production build and deployment artifact exists, separate from the demo compose + file, and its release checklist references this list. — BIO-020 +- [ ] Verify **by build, not by reading**: in a production bundle `?role=`, `?subject=`, + `?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the + three hand-written `fetch` paths. — BIO-012 _(the code fix is **RB-11**; this box is + the build-time proof)_ + +--- + +# Verified clean — do not "fix" + +Each of these was read and judged correct by the agent named. Re-checking them is wasted +effort; "simplifying" them is a regression. + +**Security and access control** (agent 07, verified endpoint by endpoint) + +- `AccessStore.can()` deny-by-default + `whenReady()` — the pair exists so the guard cannot + read `can()` mid-load and deny an entitled user. +- `capabilityGuard`'s "UX pre-gate, the backend re-enforces" claim — verified true for all + six admin surfaces; every capability the guard checks has a server-side twin. +- `Authz.CanBeoordelen`'s caller-kind derivation — the one capability a forged `X-Role` + cannot reach, and the reason BIO-002 fails _closed_ in that direction. +- The four-eyes rule in `Authz.CanActOn`, Forbidden-before-Conflict ordering. +- The `isDevMode()` gate on the debug panel and on the interceptor chain (the _interceptor_ + chain is correctly gated — RB-11 is about the three adapters that bypass it). +- The ZGW client secret never reaching the browser; the notification webhook failing closed + on an unset secret; `ZgwDiagnosticHandler` logging no bodies and being opt-in. +- The upload content-type allow-list enforced **server-side** — which is also why + `nosniff` is a checklist item and not a finding. +- Stamdata having no runtime write endpoint at all. +- `libs/shared/src/kernel/{bsn,pii}.ts` — the standard the rest should be measured against. +- No secrets committed; no `.db` file tracked (both verified by `git check-ignore`/`ls-files`). + +**Architecture and structure** + +- **ADR-0005 is fully conformed — zero findings** (agent 06). The ZGW anti-corruption layer + is the repo's worked example; the ADR even predicted its own remaining gap and the gap + stayed where predicted. +- **`bhp/behandeling` is the CQRS-light reference implementation** (agent 04). Query + adapters, command adapter and command factory in separate files, write-free read stores. + Do not "clean it up". +- **The FE dependency structure is not a problem area** (baseline §6): 0 violations across + 11 `severity: error` rules, textbook instability gradient (`kernel` I=5%, contexts I≥83%). + Do not spend tickets here. +- `BigProfileStore` — the reference implementation of the read/write split (agent 04). +- The `ToDetailDto(now)` / `ToDto(now)` status projection — a real read-model derivation; + do not let a future ticket "simplify" it into a stored status column (agent 04). +- The 7 static backend stores and `[assembly: DisableTestParallelization]` — deliberate, + documented in `Data/Db.cs`, and explicitly _not_ challenged by agents 02, 04 or 07. + RB-30 works **because** the rules never needed the DbContext, not by redesigning stores. + +**Baseline rows closed as false gaps** (agent 02, verified — do not ticket them) + +- `libs/shared/domain` 0% reach / 3 files, and `libs/beheer/contracts` 0% reach / 1 file. + Both are pure type declarations with **zero executable statements**; 0% is correct and + unimprovable. BL-004 named both as "genuine gaps"; that part of BL-004 is superseded. +- 23 of the 25 CC>10 TS functions are reducers / `parse*` / `validate*` — mandated house + idioms (**BL-001**). A bare CC number is not grounds for a ticket against any of them. +- `createDraftSync` is **acquitted on testability** (explicit deps object, optional + injection, `enabled()` escape hatch, has a spec). RB-21 is a CQRS split, not a fix. +- `httpClientFetch`, `Contracts/Mappers.cs`, `submit-besluit.ts`, `breadcrumb-trail.ts`, + `route-focus.ts`, `AccessStore.can()` — all "missing test, not blocked test", or a seam + that costs more than it returns. Filing them would be volume, not quality. + +--- + +# Out of mandate — recorded so a later phase does not read this file as a step toward them + +- **OOM-A — `Program.cs` → `Features/` folders with handler types.** BL-003's most obvious + invitation, and out of mandate because §7 is explicit that the backend has "no handler + types, no mediator, no `Features/` folders" — there is no structure to extend, only one + to introduce. **RB-19 is a strict prerequisite** if it is ever taken: you cannot cut a + 940-line file into vertical slices while five of its seven sections interleave + directions. Agent 03, which would have owned this, did not run. +- **OOM-B — read/write repository split in `backend/Data`.** Would introduce the pattern + where §7 records it absent, and collides with the documented static/no-DI design. +- **OOM-C — no read model, no event sourcing, and none proposed.** +- **OOM-D — BL-011: the FE suite is flaky under parallel load, and BL-009 means nothing + ratchets.** "CI green" alone does not verify any ticket in this backlog. Verify against + `00-baseline.md`'s numbers — **and note that §3a, §3b and §5 are already partly stale** + after the five shipped commits (auth duplication 211→151; `libs/shared/infrastructure` + coverage no longer dragged down by the generated client). **Re-run the baseline before + using it as the before-picture for any ticket below.** + +--- + +## Provenance + +| Source finding | Where it went | +| ------------------------------------------------------- | -------------------------------------------------------------------- | +| TE-001…008 | RB-10, RB-11, RB-25, RB-26, RB-27, RB-28, RB-29, RB-30 | +| TE-009 | **shipped** | +| CQ-001, 002, 003+005, 006, 007 | RB-21, RB-20, RB-17, RB-19, RB-22+RB-23 | +| CQ-004 | **shipped** (BE audit half outstanding → RB-07) | +| ADR-C-001, 003, 007, 009 | ADR-fix table | +| ADR-C-002, 004, 008, 010, 011 | RB-24, RB-13, RB-32, RB-31, RB-33 | +| ADR-C-005, 006 | **shipped** | +| BIO-001, 002 | RB-09 + checklist | +| BIO-003, 004, 005, 007, 008, 009, 010, 011, 018, 019 | RB-08, RB-01, RB-06, RB-07, RB-02, RB-04, RB-05, RB-03, RB-18, RB-16 | +| BIO-006 | RB-11 (a+b) + checklist (c) | +| BIO-012, 017 | RB-11, RB-10 | +| BIO-015, 016 | RB-15 + checklist; RB-14 + RB-12 + checklist | +| BIO-013, 014, 020 | checklist only | +| BL-008 | **shipped** | +| BL-005, BL-009, BL-011 | **unowned** — see "Coverage of this backlog" and OOM-D | +| BL-001, BL-002, BL-004 (partly), BL-006, BL-007, BL-010 | absorbed into the tickets/checklist above | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index e952668..88b4e89 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -1,13 +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 | - | - | | +| Agent | Status | Last module processed | Last updated | Notes | +| --------------- | -------------------------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. | +| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. | +| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. | +| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. | +| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs` → `Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". | +| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. | +| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. | +| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). | +| consolidation | halted (awaiting approval) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. | From a2e935d1d8cca11f63c9c610611b418b3077984d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 10:48:04 +0200 Subject: [PATCH 08/61] fix(uploads): authorize the document-content and status endpoints (RB-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /uploads/{documentId}/content took only (string documentId) — no HttpContext, so no authorization was possible. It streams diploma and identity scans, protected by GUID unguessability alone, while DELETE on the same resource has always been owner-scoped. GET /uploads/status had the same shape and confirmed whether any client-chosen localId exists, plus its documentId. Both now take HttpContext. Content is readable by the owning ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the caller kind rather than branched on a boolean, because ctx.Zorgverlener() throws for a MedewerkerCaller and the behandelportal's beoordeling screen is a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new owner parameter on DocumentStore.ByLocalIds (one call site). 404, not 403, on both: a foreign document id must not be distinguishable from one that never existed, and a foreign localId reads back as "unknown". Residual, recorded in the implementation note: both callers reach the URL as a plain browser navigation ( / previewUrl), which carries no identity header and no interceptor, so StubIdentityProvider resolves it to the seeded citizen. That is BIO-002 and belongs to RB-09; the links keep working today only because one citizen owns every document in the POC. Co-Authored-By: Claude Opus 5 --- .../src/BigRegister.Api/Data/DocumentStore.cs | 4 +- backend/src/BigRegister.Api/Program.cs | 18 +++- .../BigRegister.Tests/UploadAccessTests.cs | 83 +++++++++++++++++++ .../refactor-backlog/implementation/rb-01.md | 57 +++++++++++++ 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/UploadAccessTests.cs create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 1f2faa0..4b60054 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -73,13 +73,13 @@ public static class DocumentStore /// Status for the poll-on-return pattern: a known localId is "complete" (it /// arrived), an unknown one is still in flight / never started. - public static IReadOnlyList ByLocalIds(IEnumerable localIds) + public static IReadOnlyList ByLocalIds(IEnumerable localIds, string owner) { var set = localIds.ToHashSet(); lock (_gate) { using var db = Db.Create(); - return db.Documents.Where(d => set.Contains(d.LocalId)).ToList(); + return db.Documents.Where(d => set.Contains(d.LocalId) && d.Owner == owner).ToList(); } } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 5dd830d..e390131 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -228,10 +228,18 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo // Serve stored bytes so a re-opened wizard can preview/download an upload. Inline // for pdf/image (browser renders it), attachment otherwise (download). -api.MapGet("/uploads/{documentId}/content", (string documentId) => +// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a +// behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than +// 403s, so the endpoint never confirms that a document id exists. +api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) => { var doc = DocumentStore.Get(documentId); - if (doc is null) return Results.NotFound(); + var allowed = ctx.Caller() switch + { + ZorgverlenerCaller z => doc?.Owner == z.Bsn, + var caller => Authz.CanBeoordelen(caller), + }; + if (doc is null || !allowed) return Results.NotFound(); var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); }) @@ -239,10 +247,12 @@ api.MapGet("/uploads/{documentId}/content", (string documentId) => .Produces(StatusCodes.Status404NotFound); // Poll-on-return: which of these client localIds have arrived at the BFF. -api.MapGet("/uploads/status", (string? localIds) => +api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) => { var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId); + // Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the + // same answer an id that never existed gets. + var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId); var results = ids.Select(id => found.TryGetValue(id, out var d) ? new UploadStatusItemDto(id, "complete", d.DocumentId) : new UploadStatusItemDto(id, "unknown", null)).ToList(); diff --git a/backend/tests/BigRegister.Tests/UploadAccessTests.cs b/backend/tests/BigRegister.Tests/UploadAccessTests.cs new file mode 100644 index 0000000..54e094f --- /dev/null +++ b/backend/tests/BigRegister.Tests/UploadAccessTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// RB-01/BIO-004: GET /uploads/{id}/content and /uploads/status used to take no +/// HttpContext at all — a diploma or identity scan was protected by GUID +/// unguessability alone, while DELETE on the same resource was owner-scoped. +public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private const string OtherCitizen = "999999990"; + + private async Task UploadAsOwner() + { + var form = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + form.Add(file, "file", "diploma.pdf"); + form.Add(new StringContent("diploma"), "categoryId"); + form.Add(new StringContent("local-rb01"), "localId"); + form.Add(new StringContent("registratie"), "wizardId"); + var res = await _client.PostAsync("/api/v1/uploads", form); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!.DocumentId; + } + + private Task Get(string path, params (string Name, string Value)[] headers) + { + var req = new HttpRequestMessage(HttpMethod.Get, path); + foreach (var (name, value) in headers) req.Headers.Add(name, value); + return _client.SendAsync(req); + } + + [Fact] + public async Task The_owner_can_read_the_bytes() + { + var id = await UploadAsOwner(); + Assert.Equal(HttpStatusCode.OK, (await Get($"/api/v1/uploads/{id}/content")).StatusCode); + } + + [Fact] + public async Task Another_citizen_gets_404_not_403() + { + var id = await UploadAsOwner(); + // 404, not 403: a foreign id must not be distinguishable from one that never existed. + Assert.Equal(HttpStatusCode.NotFound, + (await Get($"/api/v1/uploads/{id}/content", ("X-Subject", OtherCitizen))).StatusCode); + } + + [Fact] + public async Task A_behandelaar_can_read_a_linked_document() + { + var id = await UploadAsOwner(); + Assert.Equal(HttpStatusCode.OK, + (await Get($"/api/v1/uploads/{id}/content", ("X-Medewerker", "medewerker-1"))).StatusCode); + } + + [Fact] + public async Task A_medewerker_without_the_behandelaar_rol_does_not() + { + var id = await UploadAsOwner(); + Assert.Equal(HttpStatusCode.NotFound, + (await Get($"/api/v1/uploads/{id}/content", + ("X-Medewerker", "medewerker-1"), ("X-Rollen", "geen"))).StatusCode); + } + + [Fact] + public async Task Status_reports_another_citizens_localId_as_unknown() + { + await UploadAsOwner(); + var res = await Get("/api/v1/uploads/status?localIds=local-rb01", ("X-Subject", OtherCitizen)); + res.EnsureSuccessStatusCode(); + var status = (await res.Content.ReadFromJsonAsync())!; + var item = Assert.Single(status.Results); + Assert.Equal("unknown", item.Status); + Assert.Null(item.DocumentId); + } +} diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md new file mode 100644 index 0000000..27ee3a9 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md @@ -0,0 +1,57 @@ +# RB-01 — authorize `GET /uploads/{id}/content` and `/uploads/status` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-004 · `99-backlog.md` RB-01 + +## What was wrong + +`GET /uploads/{documentId}/content` took `(string documentId)` — no `HttpContext`, so no +authorization was possible at all. It streams diploma and identity scans; the only +protection was the unguessability of the document GUID. `DELETE` on the same resource has +been owner-scoped (`DocumentStore.DeleteOwned`) since it was written. + +`GET /uploads/status?localIds=` had the same shape, and leaks less but still confirms +whether a given client-chosen `localId` exists anywhere in the store, plus its documentId. + +## What changed + +| File | Change | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` | +| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` | +| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) | +| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases | + +The two actor kinds are matched, not branched on a boolean, because `ctx.Zorgverlener()` +**throws** for a `MedewerkerCaller` — a behandelaar reading an aanvraag's linked documents +(`beoordeling-documenten.component.ts`) is a legitimate caller here: + +```csharp +var allowed = ctx.Caller() switch +{ + ZorgverlenerCaller z => doc?.Owner == z.Bsn, + var caller => Authz.CanBeoordelen(caller), +}; +``` + +**404, not 403**, per the ticket: a foreign document id must not be distinguishable from +one that never existed. `doc is null || !allowed` collapses both to the same answer, and +`/uploads/status` reports a foreign `localId` as `"unknown"` — the same word an id that +never existed gets. + +## Known residual — this endpoint is reached without identity headers + +Both callers link to the URL directly (`` in `beoordeling-documenten.component.ts`, +`previewUrl` in `libs/shared/src/upload/upload.adapter.ts`), so the request is a plain +browser navigation that carries **no** `X-Medewerker` / `X-Subject` header and never passes +through an Angular interceptor. `StubIdentityProvider` therefore resolves it to the seeded +citizen, which owns every document in the POC, so both links keep working — by coincidence, +not by authorization. That coincidence **is** BIO-002, and it is fixed by **RB-09** (making +`IIdentityProvider` able to express "no identity"), not here. RB-09 will need this endpoint +to receive a real credential — a signed URL or a cookie — rather than the ambient default. + +## Verification + +`dotnet build` clean. `dotnet test`: **250 passed, 1 failed** — the failure is +`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, +which needs a live OpenZaak container and **fails identically on a stashed tree**, i.e. it +pre-dates this change. From 6ffd3643b1ec0fc99e121da970876ed50ce614ab Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 10:49:57 +0200 Subject: [PATCH 09/61] fix(audit): stop writing a BSN into the authz audit Resource (RB-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Program.cs built the BIG-nummer reveal's audit resource ref as "brief/" + ctx.Zorgverlener().Bsn. AuditAuthz persists that to the AuthzAudit.Resource column in SQLite and /admin/audit renders it, so a BSN reached durable storage and a UI on the one trail four documents describe as data-minimised and PII-free — on the endpoint whose own comment promises the audit carries no PII. The ref is now "brief". Nothing is lost: BriefStore keys one brief per owner, so the id named what the row's acting principal already implies. The existing guard, The_audit_schema_carries_no_pii, asserts on column names, so a BSN inside a column called Resource could never fail it. Added No_audit_row_carries_a_subjects_bsn, which drives a denied reveal as a non-default subject and scans every string field of every row for that BSN and for DemoOwner — asserting on the two BSNs actually in play rather than a \d{9} shape, since a hex correlation id can hold nine digits by chance. Verified it goes red when only the Program.cs line is reverted. AuditEntry.Actor on document audit rows holds a raw BSN too; that is a different store and stays with RB-04. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 5 +- .../BigRegister.Tests/AuthzAuditTests.cs | 18 ++++++ .../refactor-backlog/implementation/rb-02.md | 58 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index e390131..20e3ce4 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -681,7 +681,10 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => var canReveal = Authz.CanRevealBigNummer(principal); var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var allowed = canReveal && steppedUp; - AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal); + // RB-02/BIO-008: the resource ref is the brief, not the subject — a BSN concatenated + // here lands in a persisted, admin-visible column the "no PII" guarantee covers. One + // brief exists per owner, so the id added nothing the acting principal did not imply. + AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal); if (!allowed) return Results.Problem( detail: canReveal diff --git a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs index a670bd1..6a4848f 100644 --- a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs @@ -43,6 +43,24 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer"); } + /// RB-02/BIO-008: the schema test below asserts on **column names**, so a BSN inside a + /// column called `Resource` was invisible to it — and one was there, concatenated as + /// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents + /// promise this trail holds no PII; this is the test that makes the promise checkable. + [Fact] + public async Task No_audit_row_carries_a_subjects_bsn() + { + const string subject = "999999990"; + var reveal = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); + reveal.Headers.Add("X-Subject", subject); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(reveal)).StatusCode); + + var bsns = new[] { subject, DocumentStore.DemoOwner }; + foreach (var e in await AuditLog()) + foreach (var field in new[] { e.Action, e.Resource, e.Decision, e.Role, e.At, e.CorrelationId }) + Assert.DoesNotContain(bsns, bsn => field.Contains(bsn, StringComparison.Ordinal)); + } + [Fact] public void The_audit_schema_carries_no_pii() { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md new file mode 100644 index 0000000..2d2ff8a --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md @@ -0,0 +1,58 @@ +# RB-02 — stop concatenating the BSN into `AuthzAudit.Resource` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-008 · `99-backlog.md` RB-02 + +## What was wrong + +`Program.cs` (was `:674`) built the reveal attempt's audit resource ref as +`"brief/" + ctx.Zorgverlener().Bsn`. `AuditAuthz` persists that string to the +`AuthzAudit.Resource` column in SQLite, and `GET /admin/audit` renders it on the admin +audit page — so a BSN was written to durable storage and shown in a UI, on the one trail +four documents describe as data-minimised and PII-free. + +The endpoint is the *BIG-nummer reveal*, whose own comment says the audit carries +"NO PII. Never the value that was (or wasn't) revealed" — and it did not carry the +BIG-nummer. It carried the BSN instead, in the adjacent argument. + +## Why the existing test did not catch it + +`AuthzAuditTests.The_audit_schema_carries_no_pii` asserts on **column names**: + +```csharp +Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …)); +``` + +A BSN inside a column called `Resource` is invisible to a regex over the word `Resource`. +The test was structurally incapable of failing on this defect, which is why the +value-asserting test is part of this ticket's definition of done rather than a follow-up. + +## What changed + +| File | Change | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | +| `Program.cs` | resource ref is `"brief"`; a comment records why the id added nothing | +| `AuthzAuditTests.cs` | **new** `No_audit_row_carries_a_subjects_bsn` — asserts on stored **values**, every string field | + +No identifier was lost. `BriefStore` keys one brief per owner, so `brief/` named the +same thing the row's acting principal already implies; there is no second brief the ref +could have disambiguated. + +The new test drives a denied reveal as a **non-default** subject (`X-Subject: 999999990`), +then scans every string field of every audit row for that BSN and for +`DocumentStore.DemoOwner`. Asserting against the two BSNs actually in play, rather than a +`\d{9}` shape, keeps it deterministic — a hex correlation id can hold nine consecutive +digits by chance. + +**Confirmed it fails without the fix**: reverting only the `Program.cs` line turns +`No_audit_row_carries_a_subjects_bsn` red, and restoring it turns it green. + +## Not in scope + +`AuditEntry.Actor` on document audit rows also holds a raw BSN. That is a different store +(`DocumentStore.Audit`) and is **RB-04**, which is where the masking decision for it lives. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the +failure is `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live OpenZaak container +and fails identically on a stashed tree. From 487818e67a075b226724f622957e63f49cb930ce Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 10:52:29 +0200 Subject: [PATCH 10/61] fix(privacy): mask the owner BSN on the cross-owner case lists (RB-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mappers.ToAdminSummaryDto set Owner to the raw BSN. Both consumers are cross-owner lists read by someone who is not the subject — GET /admin/cases and GET /werkvoorraad — while GET /beoordeling/{id}, the detail view of the same data, already masked it. The detail screen showed ******782 and the list one click earlier showed the whole thing. Masked in the mapper rather than at each endpoint, so a third cross-owner list cannot be added that forgets to. MaskTail moves out of Program.cs into Domain/People/Pii.cs: it now has callers in Contracts, Program.cs and (once RB-04 lands) Data, and a second hand-rolled copy is how one of them drifts into leaking. Documented as idempotent, which is what lets /beoordeling/{id} keep its own call — IZaakSource has a second implementation whose Owner is mapped from the OpenZaak zaak identificatie, so that endpoint should not depend on which source answered. No frontend change: all three consumers display the value, and the parse boundaries only require a non-empty string. Co-Authored-By: Claude Opus 5 --- .../src/BigRegister.Api/Contracts/Mappers.cs | 6 ++- .../src/BigRegister.Api/Domain/People/Pii.cs | 18 ++++++++ backend/src/BigRegister.Api/Program.cs | 14 +++--- .../BigRegister.Tests/AdminCasesTests.cs | 6 ++- .../BigRegister.Tests/WerkvoorraadTests.cs | 3 +- .../refactor-backlog/implementation/rb-03.md | 45 +++++++++++++++++++ 6 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 backend/src/BigRegister.Api/Domain/People/Pii.cs create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index d4af6fb..ae32125 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -70,8 +70,12 @@ public static class Mappers a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); /// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null). + /// The owner is a BSN, and both consumers of this mapper are cross-owner lists read by + /// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked + /// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner + /// list cannot be added that forgets to. public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => - a.ToSummaryDto(now) with { Owner = a.Owner }; + a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) }; public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds, diff --git a/backend/src/BigRegister.Api/Domain/People/Pii.cs b/backend/src/BigRegister.Api/Domain/People/Pii.cs new file mode 100644 index 0000000..c98b620 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/People/Pii.cs @@ -0,0 +1,18 @@ +namespace BigRegister.Domain.People; + +/// +/// One redaction rule for identifiers that must not leave the server in full (BSN, +/// BIG-nummer). Lives in Domain/ because three layers need it — the DTO mappers +/// (Contracts/Mappers.cs), the audit writes (Data/DocumentStore.cs) and the +/// endpoints themselves — and a second hand-rolled copy is exactly how one of them drifts +/// into leaking. Mirrors the FE maskTail (libs/shared/src/ui/debug-state/mask.ts) +/// so wire redaction and the dev panel agree on what a masked value looks like. +/// +public static class Pii +{ + /// Keep the last characters, mask the rest. Idempotent: masking an + /// already-masked value is a no-op, so a defence-in-depth second call is harmless. + public static string MaskTail(string value, int keep) => + value.Length <= keep ? new string('*', value.Length) + : new string('*', value.Length - keep) + value[^keep..]; +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 20e3ce4..bc7e98f 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -12,6 +12,7 @@ using BigRegister.Domain.Documents; using BigRegister.Domain.Features; using BigRegister.Domain.Intake; using BigRegister.Domain.Letters; +using BigRegister.Domain.People; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; using BigRegister.Api.Zgw; @@ -460,7 +461,10 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) if (c is null || c.Status.Tag == "Concept") return Results.NotFound(); var docs = DocumentStore.ByIds(c.DocumentIds) .Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); - var masked = c with { Owner = MaskTail(c.Owner!, 3) }; + // Belt and braces: ToAdminSummaryDto already masks the local source (RB-03) and + // MaskTail is idempotent, but IZaakSource has a second implementation whose Owner + // is mapped from OpenZaak, so this stays as the guarantee for this response. + var masked = c with { Owner = Pii.MaskTail(c.Owner!, 3) }; // WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an // unrecognised tag degrades to "cannot decide" instead of a 500. var canBesluiten = Enum.TryParse(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag); @@ -869,12 +873,6 @@ void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exceptio AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid); } -// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail -// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree. -static string MaskTail(string value, int keep) => - value.Length <= keep ? new string('*', value.Length) - : new string('*', value.Length - keep) + value[^keep..]; - static string Now() => DateTimeOffset.UtcNow.ToString("o"); BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( @@ -888,7 +886,7 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( // behandel scherm can show whom/what it concerns without brief/ importing registratie. // The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal // endpoint returns the full value, gated + audited. - new CaseContextDto(SeedData.Registration.Naam, MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie)); + new CaseContextDto(SeedData.Registration.Naam, Pii.MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie)); // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run // through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. diff --git a/backend/tests/BigRegister.Tests/AdminCasesTests.cs b/backend/tests/BigRegister.Tests/AdminCasesTests.cs index 9bfc9dc..475778a 100644 --- a/backend/tests/BigRegister.Tests/AdminCasesTests.cs +++ b/backend/tests/BigRegister.Tests/AdminCasesTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -34,7 +35,10 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< list.EnsureSuccessStatusCode(); var cases = (await list.Content.ReadFromJsonAsync>())!; var mine = cases.Single(x => x.Id == a.Id); - Assert.False(string.IsNullOrEmpty(mine.Owner)); // admin list carries the owner + // RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is + // read by someone who is not the subject. + Assert.Equal("******782", mine.Owner); + Assert.DoesNotContain(DocumentStore.DemoOwner, mine.Owner); } finally { diff --git a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs index dc76a16..c665f08 100644 --- a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs +++ b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs @@ -38,7 +38,8 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur var queue = (await res.Content.ReadFromJsonAsync>())!; var mine = queue.Single(x => x.Id == a.Id); Assert.Equal("InBehandeling", mine.Status.Tag); - Assert.False(string.IsNullOrEmpty(mine.Owner)); // cross-owner, like /admin/cases + // RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto. + Assert.Equal("******782", mine.Owner); } finally { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md new file mode 100644 index 0000000..3cb43a1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md @@ -0,0 +1,45 @@ +# RB-03 — mask the owner BSN on the cross-owner case lists + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-03 + +## What was wrong + +`Mappers.ToAdminSummaryDto` set `Owner = a.Owner` — the raw BSN. Two endpoints consume it, +both cross-owner lists read by someone who is **not** the subject: + +- `GET /admin/cases` (`cases:manage`) +- `GET /werkvoorraad` (`aanvraag:beoordelen`) + +`GET /beoordeling/{id}` — the *detail* view of the same data — already masked. So the +detail screen showed `******782` while the list one click earlier showed the whole BSN. + +## What changed + +| File | Change | +| --------------------------- | ----------------------------------------------------------------------------------- | +| `Domain/People/Pii.cs` | **new** — `Pii.MaskTail`, moved out of `Program.cs` | +| `Contracts/Mappers.cs` | `Owner = Pii.MaskTail(a.Owner, 3)` | +| `Program.cs` | local `MaskTail` deleted; two call sites point at `Pii.MaskTail` | +| `AdminCasesTests.cs` | asserts the masked value and that `DemoOwner` does not appear | +| `WerkvoorraadTests.cs` | same assertion, replacing the `IsNullOrEmpty` one | + +**Masked in the mapper, not at the endpoints.** The point of the ticket is that both +lists *inherit* it, so a third cross-owner list cannot be added that forgets to mask. + +**`MaskTail` moved to `Domain/People/Pii.cs`** because it now has three callers across +three folders (`Contracts`, `Program.cs`, and `Data` once **RB-04** lands), and a second +hand-rolled copy is how one of them drifts into leaking. It is documented as idempotent, +which is what lets `/beoordeling/{id}` keep its own call: `IZaakSource` has a second +implementation (`OpenZaakZaakSource` → `ZgwZaakMapper`, which maps `Owner` from the zaak +`identificatie`), so that endpoint's guarantee should not depend on which source answered. + +## Blast radius on the frontend — none + +Both consumers use the value for display only (`admin-cases.page.ts:101`, +`beoordeling-view.ts:40`, `werkvoorraad-item-view.ts:28`); the `parse*` boundaries require +a non-empty string, which a masked BSN still is. Nothing keys, filters or looks up by owner. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the +pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. From fbd27ed641e3ecda38869e78aa09cfa84e6a3d5c Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 10:54:07 +0200 Subject: [PATCH 11/61] fix(privacy): mask the BSN recorded as the document audit Actor (RB-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentStore wrote one audit row per upload and per user delete carrying the acting citizen's raw BSN as AuditEntry.Actor, persisted to SQLite — on a store whose own doc comment says it holds metadata only, never file content "or other PII". Same shape as RB-02, in a second store. Masked at the two citizen call sites rather than inside Audit, because the third actor is the literal "admin" and MaskTail("admin", 3) is "**min"; masking centrally would mean guessing which actors are BSNs and which are role names. Audit's doc comment now states that actors arrive redacted. StoredDocument.Owner is untouched: it is the authorization key that DeleteOwned, ForeignIds and RB-01's content check all compare against, so the BSN stays where it is load-bearing and leaves the trail where it was only decoration. No endpoint exposes AuditLog, so no response shape changes. Co-Authored-By: Claude Opus 5 --- .../src/BigRegister.Api/Data/DocumentStore.cs | 12 +++++- .../BigRegister.Tests/UploadAccessTests.cs | 22 +++++++++-- .../refactor-backlog/implementation/rb-04.md | 37 +++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 4b60054..82054f1 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -1,3 +1,5 @@ +using BigRegister.Domain.People; + namespace BigRegister.Api.Data; /// @@ -58,7 +60,7 @@ public static class DocumentStore db.Documents.Add(doc); db.SaveChanges(); } - Audit("upload", doc.DocumentId, categoryId, owner); + Audit("upload", doc.DocumentId, categoryId, Pii.MaskTail(owner, 3)); return doc; } @@ -156,7 +158,7 @@ public static class DocumentStore db.Documents.Remove(d); db.SaveChanges(); } - Audit("delete-user", documentId, categoryId, owner); + Audit("delete-user", documentId, categoryId, Pii.MaskTail(owner, 3)); return DeleteResult.Ok; } @@ -178,6 +180,12 @@ public static class DocumentStore return true; } + /// Append one metadata-only audit row. must arrive + /// **already redacted** (RB-04/BIO-005) — the two citizen call sites pass + /// of the owner BSN, `delete-admin` passes the literal + /// `"admin"`. The unmasked BSN lives only in , which is + /// the authorization key and stays untouched. Masking here instead would have to guess + /// which actors are BSNs and which are role names. public static void Audit(string action, string documentId, string categoryId, string actor) { lock (_gate) diff --git a/backend/tests/BigRegister.Tests/UploadAccessTests.cs b/backend/tests/BigRegister.Tests/UploadAccessTests.cs index 54e094f..6537b01 100644 --- a/backend/tests/BigRegister.Tests/UploadAccessTests.cs +++ b/backend/tests/BigRegister.Tests/UploadAccessTests.cs @@ -2,13 +2,16 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; -/// RB-01/BIO-004: GET /uploads/{id}/content and /uploads/status used to take no -/// HttpContext at all — a diploma or identity scan was protected by GUID -/// unguessability alone, while DELETE on the same resource was owner-scoped. +/// Who may see what about an upload. RB-01/BIO-004: GET /uploads/{id}/content and +/// /uploads/status used to take no HttpContext at all — a diploma or identity scan was +/// protected by GUID unguessability alone, while DELETE on the same resource was +/// owner-scoped. RB-04/BIO-005: the document audit trail recorded the raw owner BSN as +/// its Actor, on a store whose own doc comment says it holds no PII. public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); @@ -69,6 +72,19 @@ public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixtur ("X-Medewerker", "medewerker-1"), ("X-Rollen", "geen"))).StatusCode); } + [Fact] + public async Task The_document_audit_trail_records_a_masked_actor() + { + var id = await UploadAsOwner(); + (await _client.DeleteAsync($"/api/v1/uploads/{id}")).EnsureSuccessStatusCode(); + + var rows = DocumentStore.AuditLog.Where(e => e.DocumentId == id).ToList(); + Assert.Equal(new[] { "upload", "delete-user" }, rows.Select(e => e.Action)); + Assert.All(rows, e => Assert.Equal("******782", e.Actor)); + // The unmasked BSN stays where it is load-bearing — the ownership key, not the trail. + Assert.All(rows, e => Assert.DoesNotContain(DocumentStore.DemoOwner, e.Actor)); + } + [Fact] public async Task Status_reports_another_citizens_localId_as_unknown() { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md new file mode 100644 index 0000000..817072e --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md @@ -0,0 +1,37 @@ +# RB-04 — mask the BSN recorded as `AuditEntry.Actor` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-005 · `99-backlog.md` RB-04 + +## What was wrong + +`DocumentStore` writes one audit row per upload and per user delete, with the acting +citizen's raw BSN as `AuditEntry.Actor`, persisted to SQLite. The class's own doc comment +says "The audit log holds metadata only (never file content **or other PII**)" — a BSN in +every row is precisely other PII. Same failure shape as RB-02, in a second store. + +## What changed + +| File | Change | +| ------------------------------- | ----------------------------------------------------------------- | +| `Data/DocumentStore.cs` `Add` | `Audit("upload", …, Pii.MaskTail(owner, 3))` | +| `Data/DocumentStore.cs` `DeleteOwned` | `Audit("delete-user", …, Pii.MaskTail(owner, 3))` | +| `Data/DocumentStore.cs` `Audit` | doc comment: actors arrive **already redacted** | +| `UploadAccessTests.cs` | **new** `The_document_audit_trail_records_a_masked_actor` | + +**Masked at the two call sites, not inside `Audit`** — unlike RB-03, where masking in the +mapper was the point. `Audit`'s third actor is the literal `"admin"` (from `AdminDelete`), +and `MaskTail("admin", 3)` is `"**min"`: masking centrally would mean guessing which +actors are BSNs and which are role names. The contract is stated on `Audit` instead. + +**`StoredDocument.Owner` is untouched**, per the ticket. It is the authorization key — +`DeleteOwned`, `ForeignIds` and now the RB-01 content check all compare against it — so it +has to stay whole. The BSN remains where it is load-bearing and leaves the trail where it +was only decoration. + +Nothing reads `DocumentStore.AuditLog` today (no endpoint exposes it), so this is a +data-at-rest fix with no response-shape change. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **252 passed, 1 failed** — the +pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. From 5187bfa19a5561a7e45c3ba3935343f7f6200c51 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 10:57:09 +0200 Subject: [PATCH 12/61] fix(zgw): keep the BSN out of the recorded ZGW failure message (RB-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZgwHttpClient interpolated the full request uri and up to 500 characters of the response body into its failure message. That message is persisted as Aanvraag.ZgwError in SQLite and written to the log, and both halves can carry a BSN: ZGW filters travel as query parameters (the citizen-scoped zaken list filters on rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), and OpenZaak echoes the offending request in its error bodies, so a rejected POST /rollen comes back holding the owner BSN it was sent. All three interpolation sites now use Redact(url) — the path without its query — and the body snippet is replaced by the reason phrase. Status plus path still routes a failure to the right endpoint; the lost detail already has a deliberate home in ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler), which is opt-in, dev-only and not persisted. The new test fails the one call in the fixture whose url carries a query string and asserts the persisted ZgwError has neither the body snippet nor a "?", while keeping the path and the 503. Verified red without the fix. Co-Authored-By: Claude Opus 5 --- .../src/BigRegister.Api/Zgw/ZgwHttpClient.cs | 23 ++++++--- .../BigRegister.Tests/ZgwDivergenceTests.cs | 26 ++++++++++ .../refactor-backlog/implementation/rb-05.md | 50 +++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md diff --git a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs index 6890fa6..6882c5d 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs @@ -24,7 +24,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) { using var res = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Get, url), caller); return (await res.Content.ReadFromJsonAsync()) - ?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); + ?? throw new InvalidOperationException($"ZGW GET {Redact(url)} returned null body."); } public async Task PostAsync(string url, object body, CallerIdentity? caller = null) @@ -32,7 +32,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) using var res = await SendWithRetryAsync( () => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller); return (await res.Content.ReadFromJsonAsync()) - ?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); + ?? throw new InvalidOperationException($"ZGW POST {Redact(url)} returned null body."); } /// @@ -42,7 +42,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) /// partial commit on the two non-idempotent ZGW POSTs (/statussen, /rollen) and /// retrying risks a duplicate write — the create-zaak/document POSTs are additionally /// protected by OpenZaak's own uniqueness constraint on (bronorganisatie, identificatie). - /// A non-transient (or exhausted) failure throws with the status + a body snippet, which + /// A non-transient (or exhausted) failure throws with the status + the redacted path, which /// Program.cs's submit endpoint catches and records as a flagged divergence rather /// than letting it diverge silently (see openzaak-integration.md's "Write resilience" section). /// @@ -73,15 +73,26 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) continue; } - var body = await res.Content.ReadAsStringAsync(); - var snippet = body.Length > 500 ? body[..500] : body; - var message = $"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"; + // RB-05/BIO-009: path only — no query string, no response-body snippet. The + // BSN-filtered zaken list puts a BSN in the query, and OpenZaak echoes the request in + // its error bodies, so both used to reach a message Program.cs persists as a flagged + // divergence and writes to the application log. Status + path routes the failure; + // ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler) is the deliberate opt-in for the rest. + var message = $"ZGW {req.Method} {Redact(req.RequestUri)} failed: {(int)res.StatusCode} {res.ReasonPhrase}"; var status = res.StatusCode; res.Dispose(); throw new HttpRequestException(message, null, status); } } + /// The path without its query string — ZGW filters travel as query parameters and + /// one of them is a BSN (rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), so no + /// ZGW url may be interpolated into a message that is logged or persisted (RB-05). + private static string Redact(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.GetLeftPart(UriPartial.Path) : url.Split('?')[0]; + + private static string Redact(Uri? url) => url is null ? "(no uri)" : url.GetLeftPart(UriPartial.Path); + private static bool IsTransient(HttpStatusCode status) => status is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; diff --git a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs index 881e922..be845bb 100644 --- a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs +++ b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs @@ -111,6 +111,32 @@ public class ZgwDivergenceTests Assert.Null(stored.ZgwError); } + /// RB-05/BIO-009: `ZgwError` is persisted to SQLite and written to the application log, so + /// the message it carries may not include the response body (OpenZaak echoes the request in + /// its errors) or the request's query string (ZGW filters travel there, and one of them is + /// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`). + [Fact] + public async Task A_recorded_divergence_carries_no_response_body_and_no_query_string() + { + // The zaak POST succeeds; the statustypen GET — the one call here that carries a query + // string — fails, so the recorded message is built from a url that has one. + var stub = new ZgwStubHandler(SuccessBody, + (url, _) => url.StartsWith($"{ZtBase}/statustypen") ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK); + using var factory = Factory(stub); + using var client = factory.CreateClient(); + + var id = await CreateConcept(client); + (await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" })) + .EnsureSuccessStatusCode(); + + var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError; + Assert.NotNull(error); + Assert.DoesNotContain("stub failure", error); // no response-body snippet + Assert.DoesNotContain("?", error); // no query string + Assert.Contains($"{ZtBase}/statustypen", error); // the path still routes the failure + Assert.Contains("503", error); + } + private static HttpRequestMessage AdminRequest(HttpMethod method, string path) { var req = new HttpRequestMessage(method, path); diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md new file mode 100644 index 0000000..d633420 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md @@ -0,0 +1,50 @@ +# RB-05 — drop the BSN-bearing query and body snippet from the ZGW failure message + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-009 · `99-backlog.md` RB-05 + +## What was wrong + +`ZgwHttpClient.SendWithRetryAsync` built its failure message as + +```csharp +$"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}" +``` + +with `snippet` being up to 500 characters of the **response body**. That message is not +transient: `Program.cs`'s submit endpoint catches it and stores it as `Aanvraag.ZgwError` +in SQLite, and logs it. + +Two BSN paths into it: + +- **the query string.** ZGW filters travel as query parameters, and the citizen-scoped zaken + list filters on `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=`. +- **the body snippet.** OpenZaak's error responses echo the offending request, so a rejected + `POST /rollen` (whose body carries `BetrokkeneIdentificatie(aanvraag.Owner)`) comes back + with the BSN in it. + +The two `"returned null body"` throws in `GetAsync`/`PostAsync` interpolated the same url. + +## What changed + +| File | Change | +| ------------------------ | --------------------------------------------------------------------------------- | +| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` | +| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` | + +Status + path is enough to route a failure to the right endpoint. The diagnostic detail +that was lost already has a deliberate home: `ZGW_DEBUG_HTTP=1` wires +`ZgwDiagnosticHandler`, which logs the full url and request bytes — opt-in, dev-only, and +not persisted. + +## The test + +Fails the `statustypen` GET (the only call in that fixture whose url carries a query +string) after the zaak POST succeeds, then asserts on the persisted `ZgwError`: +no `"stub failure"` (the body snippet), no `"?"` (the query string), but still the path and +the `503`. **Confirmed it fails without the fix** — restoring the old interpolation turns it +red on both counts. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **253 passed, 1 failed** — the +pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. From 0298ecc50657e5a1cf51d352c3fa3484a07410b7 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 11:04:03 +0200 Subject: [PATCH 13/61] fix(uploads): delete the dead POST /registrations (RB-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /registrations passed its Documents list straight to Submit, which calls DocumentStore.Link on every digital documentId in it — and linking a document blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That path had no ForeignIds ownership check, so any authenticated citizen could post another citizen's document id and permanently block them from deleting their own diploma scan. POST /applications/{id}/submit, the endpoint actually in use, has had that guard since it was written. Deleted rather than guarded: the endpoint is dead. No frontend caller, and the whole registratie flow goes through /applications/{id}/submit. RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie — reachable only from here, and contradicted by the live path, which treats a handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its own message said as much while being returned as a rejection. That last part is a judgement call beyond the ticket's wording; reverting the two SubmissionRules hunks restores it in isolation. Coverage moved rather than vanished: the problem+json shape assertion is now on /change-requests (the other endpoint on the same Submit helper), and the linked-delete 409 test goes through the real submit path. swagger.json, the generated client and the behaviour spec regenerated. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Contracts/Dtos.cs | 1 - .../Domain/Submissions/SubmissionRules.cs | 6 -- backend/src/BigRegister.Api/Program.cs | 5 -- backend/swagger.json | 56 --------------- .../Domain/SubmissionRuleTests.cs | 8 --- .../tests/BigRegister.Tests/EndpointTests.cs | 35 ++++------ .../refactor-backlog/implementation/rb-01.md | 12 ++-- .../refactor-backlog/implementation/rb-02.md | 10 +-- .../refactor-backlog/implementation/rb-03.md | 18 ++--- .../refactor-backlog/implementation/rb-04.md | 10 +-- .../refactor-backlog/implementation/rb-05.md | 8 +-- .../refactor-backlog/implementation/rb-06.md | 69 +++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 19 +++-- libs/shared/src/infrastructure/api-client.ts | 51 -------------- 14 files changed, 123 insertions(+), 185 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 1a8c787..211b5c0 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -74,7 +74,6 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D // Submit requests carry only the fields the server re-validates (UX-only fields // stay on the client). ponytail: a real submit would carry the full application. -public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList? Documents = null); public sealed record ChangeRequestRequest(string Telefoon); diff --git a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs index 1db366a..c9ee980 100644 --- a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs +++ b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs @@ -9,12 +9,6 @@ namespace BigRegister.Domain.Submissions; /// public static class SubmissionRules { - // RULE: a manually entered diploma cannot be auto-verified. - public static string? RejectRegistratie(string diplomaHerkomst) => - diplomaHerkomst == "handmatig" - ? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling." - : null; - // RULE: an application reporting zero worked hours is rejected. public static string? RejectZeroUren(int uren) => uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index bc7e98f..fbadce2 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -185,11 +185,6 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct // --- POST: submits. The server is the authority; it re-validates and decides. --- -api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => - Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents)) -.Produces() -.ProducesProblem(StatusCodes.Status422UnprocessableEntity); - api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon))) .Produces() diff --git a/backend/swagger.json b/backend/swagger.json index 6ca507f..af86f93 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -210,45 +210,6 @@ } } }, - "/api/v1/registrations": { - "post": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegistratieRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReferentieResponse" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, "/api/v1/change-requests": { "post": { "tags": [ @@ -2468,23 +2429,6 @@ }, "additionalProperties": false }, - "RegistratieRequest": { - "type": "object", - "properties": { - "diplomaHerkomst": { - "type": "string", - "nullable": true - }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DocumentRefDto" - }, - "nullable": true - } - }, - "additionalProperties": false - }, "RegistrationDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs index 2265864..ff3028e 100644 --- a/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs +++ b/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs @@ -4,14 +4,6 @@ namespace BigRegister.Tests.Domain; public class SubmissionRuleTests { - [Fact] - public void Manual_diploma_is_rejected() => - Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); - - [Fact] - public void Duo_diploma_is_accepted() => - Assert.Null(SubmissionRules.RejectRegistratie("duo")); - [Fact] public void Zero_hours_is_rejected() => Assert.NotNull(SubmissionRules.RejectZeroUren(0)); diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index d7335a1..c364a96 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -69,26 +69,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(); - Assert.NotNull(body); - Assert.StartsWith("BIG-2026-", body.Referentie); - } - - [Fact] - public async Task Registration_with_manual_diploma_is_rejected_with_problem_details() - { - var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); - var contentType = res.Content.Headers.ContentType; - Assert.NotNull(contentType); - Assert.Contains("application/problem+json", contentType.ToString()); - } - [Fact] public async Task Change_request_with_valid_phone_succeeds() { @@ -101,11 +81,16 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture())!; + var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit", + new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } }); submit.EnsureSuccessStatusCode(); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); } diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md index 27ee3a9..402caf3 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md @@ -14,12 +14,12 @@ whether a given client-chosen `localId` exists anywhere in the store, plus its d ## What changed -| File | Change | -| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | -| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` | -| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` | -| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) | -| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases | +| File | Change | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` | +| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` | +| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) | +| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases | The two actor kinds are matched, not branched on a boolean, because `ctx.Zorgverlener()` **throws** for a `MedewerkerCaller` — a behandelaar reading an aanvraag's linked documents diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md index 2d2ff8a..6a5d8fb 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md @@ -10,7 +10,7 @@ Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md audit page — so a BSN was written to durable storage and shown in a UI, on the one trail four documents describe as data-minimised and PII-free. -The endpoint is the *BIG-nummer reveal*, whose own comment says the audit carries +The endpoint is the _BIG-nummer reveal_, whose own comment says the audit carries "NO PII. Never the value that was (or wasn't) revealed" — and it did not carry the BIG-nummer. It carried the BSN instead, in the adjacent argument. @@ -28,10 +28,10 @@ value-asserting test is part of this ticket's definition of done rather than a f ## What changed -| File | Change | -| ----------------------- | ---------------------------------------------------------------------------------------------------- | -| `Program.cs` | resource ref is `"brief"`; a comment records why the id added nothing | -| `AuthzAuditTests.cs` | **new** `No_audit_row_carries_a_subjects_bsn` — asserts on stored **values**, every string field | +| File | Change | +| -------------------- | ------------------------------------------------------------------------------------------------ | +| `Program.cs` | resource ref is `"brief"`; a comment records why the id added nothing | +| `AuthzAuditTests.cs` | **new** `No_audit_row_carries_a_subjects_bsn` — asserts on stored **values**, every string field | No identifier was lost. `BriefStore` keys one brief per owner, so `brief/` named the same thing the row's acting principal already implies; there is no second brief the ref diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md index 3cb43a1..7b669e0 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md @@ -10,21 +10,21 @@ both cross-owner lists read by someone who is **not** the subject: - `GET /admin/cases` (`cases:manage`) - `GET /werkvoorraad` (`aanvraag:beoordelen`) -`GET /beoordeling/{id}` — the *detail* view of the same data — already masked. So the +`GET /beoordeling/{id}` — the _detail_ view of the same data — already masked. So the detail screen showed `******782` while the list one click earlier showed the whole BSN. ## What changed -| File | Change | -| --------------------------- | ----------------------------------------------------------------------------------- | -| `Domain/People/Pii.cs` | **new** — `Pii.MaskTail`, moved out of `Program.cs` | -| `Contracts/Mappers.cs` | `Owner = Pii.MaskTail(a.Owner, 3)` | -| `Program.cs` | local `MaskTail` deleted; two call sites point at `Pii.MaskTail` | -| `AdminCasesTests.cs` | asserts the masked value and that `DemoOwner` does not appear | -| `WerkvoorraadTests.cs` | same assertion, replacing the `IsNullOrEmpty` one | +| File | Change | +| ---------------------- | ---------------------------------------------------------------- | +| `Domain/People/Pii.cs` | **new** — `Pii.MaskTail`, moved out of `Program.cs` | +| `Contracts/Mappers.cs` | `Owner = Pii.MaskTail(a.Owner, 3)` | +| `Program.cs` | local `MaskTail` deleted; two call sites point at `Pii.MaskTail` | +| `AdminCasesTests.cs` | asserts the masked value and that `DemoOwner` does not appear | +| `WerkvoorraadTests.cs` | same assertion, replacing the `IsNullOrEmpty` one | **Masked in the mapper, not at the endpoints.** The point of the ticket is that both -lists *inherit* it, so a third cross-owner list cannot be added that forgets to mask. +lists _inherit_ it, so a third cross-owner list cannot be added that forgets to mask. **`MaskTail` moved to `Domain/People/Pii.cs`** because it now has three callers across three folders (`Contracts`, `Program.cs`, and `Data` once **RB-04** lands), and a second diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md index 817072e..890b5b2 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md @@ -11,12 +11,12 @@ every row is precisely other PII. Same failure shape as RB-02, in a second store ## What changed -| File | Change | -| ------------------------------- | ----------------------------------------------------------------- | -| `Data/DocumentStore.cs` `Add` | `Audit("upload", …, Pii.MaskTail(owner, 3))` | +| File | Change | +| ------------------------------------- | --------------------------------------------------------- | +| `Data/DocumentStore.cs` `Add` | `Audit("upload", …, Pii.MaskTail(owner, 3))` | | `Data/DocumentStore.cs` `DeleteOwned` | `Audit("delete-user", …, Pii.MaskTail(owner, 3))` | -| `Data/DocumentStore.cs` `Audit` | doc comment: actors arrive **already redacted** | -| `UploadAccessTests.cs` | **new** `The_document_audit_trail_records_a_masked_actor` | +| `Data/DocumentStore.cs` `Audit` | doc comment: actors arrive **already redacted** | +| `UploadAccessTests.cs` | **new** `The_document_audit_trail_records_a_masked_actor` | **Masked at the two call sites, not inside `Audit`** — unlike RB-03, where masking in the mapper was the point. `Audit`'s third actor is the literal `"admin"` (from `AdminDelete`), diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md index d633420..47e9203 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md @@ -26,10 +26,10 @@ The two `"returned null body"` throws in `GetAsync`/`PostAsync` interpolated the ## What changed -| File | Change | -| ------------------------ | --------------------------------------------------------------------------------- | -| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` | -| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` | +| File | Change | +| ----------------------- | ---------------------------------------------------------------------------- | +| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` | +| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` | Status + path is enough to route a failure to the right endpoint. The diagnostic detail that was lost already has a deliberate home: `ZGW_DEBUG_HTTP=1` wires diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md new file mode 100644 index 0000000..0e74589 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md @@ -0,0 +1,69 @@ +# RB-06 — delete the dead `POST /registrations` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-010 · `99-backlog.md` RB-06 + +## What was wrong + +`POST /registrations` took a `Documents` list and passed it straight to `Submit`, which +calls `DocumentStore.Link(...)` on every digital `documentId` in it. Linking a document +**blocks its owner from deleting it** (`DeleteOwned` → 409 `Linked`). + +There was no `ForeignIds` ownership check on that path. The real submit endpoint, +`POST /applications/{id}/submit`, has had one since it was written: + +```csharp +if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds) + return Results.Problem(detail: $"Onbekend of niet-eigen document(en): …", statusCode: 400); +``` + +So any authenticated citizen could post another citizen's document id and permanently +block them from deleting their own diploma scan. + +## Deleted rather than guarded + +The ticket allowed either. Deleted, because the endpoint is dead: no frontend caller (the +generated client's `registrations` method was unreferenced), and the whole registratie flow +goes through `POST /applications/{id}/submit`. + +| File | Change | +| -------------------------------------------- | ------------------------------------------------- | +| `Program.cs` | endpoint deleted | +| `Contracts/Dtos.cs` | `RegistratieRequest` deleted (no other reference) | +| `Domain/Submissions/SubmissionRules.cs` | `RejectRegistratie` deleted — see below | +| `backend/swagger.json`, `api-client.ts` | regenerated (`npm run gen:api`) | +| `EndpointTests.cs`, `SubmissionRuleTests.cs` | retargeted, see below | + +### Why `RejectRegistratie` went with it + +It was reachable only from this endpoint, and the live path deliberately **contradicts** +it. `RejectRegistratie("handmatig")` returned a 422 rejection; the modern submit does + +```csharp +"registratie" => (null, req.DiplomaHerkomst == "duo"), +``` + +— a manual diploma is not rejected, it simply does not auto-approve and goes to a +behandelaar. Its own message even said so ("doorgestuurd voor handmatige beoordeling") +while being returned as a rejection. Leaving it behind would have left an obsolete rule +with a passing spec, which is exactly how it gets reintroduced. + +**This is the one judgement call in this ticket** — the backlog row says "delete the dead +endpoint", not "delete the rule". Reverting just the `SubmissionRules`/`SubmissionRuleTests` +hunks restores it without touching anything else. + +### Test coverage that moved rather than vanished + +- `Registration_with_manual_diploma_is_rejected_with_problem_details` was the only test + asserting the `Submit` helper's `application/problem+json` rejection shape. That assertion + moved into `Change_request_with_bad_phone_is_rejected_with_problem_details` — + `/change-requests` is the other endpoint on the same helper. +- `User_delete_blocked_with_409_once_linked_to_submission` covered `DocumentStore.Link` + blocking a delete. Retargeted to `POST /applications/{id}/submit`, i.e. the path that is + actually in use. `POST /registrations` was the only other caller of `Link`. +- `Registration_with_duo_diploma_succeeds` was deleted outright — `Change_request_with_valid_phone_succeeds` + is the same assertion on the same helper. + +## Verification + +`npm run ci`. `dotnet test`: **249 passed, 1 failed** — the pre-existing +`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index b0507b8..39566bf 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 402 frontend behaviours across -8 contexts; 221 backend behaviours across 37 test +8 contexts; 225 backend behaviours across 38 test classes. ## Frontend (by context) @@ -825,6 +825,7 @@ classes. - A denied admin action is recorded - A reveal attempt is recorded +- No audit row carries a subjects bsn - The audit schema carries no pii ### AuthzTests @@ -921,10 +922,8 @@ classes. - Brp returns address - Duo lookup carries server decided questions and professions - IntakePolicy returns scholing threshold -- Registration with duo diploma succeeds -- Registration with manual diploma is rejected with problem details - Change request with valid phone succeeds -- Change request with bad phone is rejected +- Change request with bad phone is rejected with problem details - Health endpoint is ok - Correlation id supplied by the caller is echoed back - Correlation id is generated when the caller omits it @@ -1087,12 +1086,19 @@ classes. ### SubmissionRuleTests -- Manual diploma is rejected -- Duo diploma is accepted - Zero hours is rejected - Worked hours are accepted - Phone change is validated +### UploadAccessTests + +- The owner can read the bytes +- Another citizen gets 404 not 403 +- A behandelaar can read a linked document +- A medewerker without the behandelaar rol does not +- The document audit trail records a masked actor +- Status reports another citizens localId as unknown + ### WerkvoorraadTests - Behandelaar sees submitted cases in the queue @@ -1109,6 +1115,7 @@ classes. - Submit with a failing zgw flags the divergence instead of diverging silently - Submit with a healthy zgw leaves no divergence flag +- A recorded divergence carries no response body and no query string ### ZgwTokenProviderTests diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index ee5e57f..05eb07b 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -359,52 +359,6 @@ export class ApiClient { return Promise.resolve(null as any); } - /** - * @return OK - */ - registrations(body: RegistratieRequest): Promise { - let url_ = this.baseUrl + "/api/v1/registrations"; - url_ = url_.replace(/[?&]$/, ""); - - const content_ = JSON.stringify(body); - - let options_: RequestInit = { - body: content_, - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json" - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processRegistrations(_response); - }); - } - - protected processRegistrations(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 200) { - return response.text().then((_responseText) => { - let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse; - return result200; - }); - } else if (status === 422) { - return response.text().then((_responseText) => { - let result422: any = null; - result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Unprocessable Content", status, _responseText, _headers, result422); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - /** * @return OK */ @@ -2237,11 +2191,6 @@ export interface ReferentieResponse { referentie?: string | undefined; } -export interface RegistratieRequest { - diplomaHerkomst?: string | undefined; - documents?: DocumentRefDto[] | undefined; -} - export interface RegistrationDto { bigNummer?: string | undefined; naam?: string | undefined; From d682f91b541372566da26170a58e743f1dbb79bf Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 11:06:10 +0200 Subject: [PATCH 14/61] docs(backlog): lift the HALT, record CD batch 1 as complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator approved 99-backlog.md, so Phase 3 started. RB-01..RB-06 are marked done in the table, _status.md gains a Phase 3 section tracking all six batches, and the halt notice is replaced by what batch 1 leaves for later batches to pick up: - RB-01's residual is RB-09's problem — the document-content endpoint is reached by a plain browser navigation with no identity header, so it resolves to the seeded citizen and works only because one citizen owns every document in the POC. That is BIO-002. - Pii.MaskTail now lives in Domain/People/Pii.cs; RB-11 should use it rather than hand-rolling a second masker. - RB-06 additionally deleted SubmissionRules.RejectRegistratie, which its row did not ask for. Also records the standing OpenZaakIntegrationTests failure, which needs a live container and is unrelated to any of these tickets. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 90 +++++++++++-------- .../refactor-backlog/_status.md | 38 +++++--- 2 files changed, 80 insertions(+), 48 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 8c954a0..9cf63fd 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -13,8 +13,24 @@ **47 findings in, 33 open tickets + 5 ADR-fixes + 1 shipped set out.** Everything below traces to at least one `TE-`/`CQ-`/`ADR-C-`/`BIO-` finding and cites a baseline metric. -**HALT.** This file is the deliverable. No Implementation Agent starts until a human has -approved it. Nothing in this pass was implemented; no source file was modified. +**HALT lifted 2026-08-27** — the operator approved the backlog and Phase 3 started. +**CD batch 1 (RB-01..RB-06) is implemented**, one commit per ticket on branch +`refactor/adr-c-006-shared-route-guards`, each with a note in `implementation/rb-0N.md`. +Batches 2–6 are untouched. The `Status` column below is the source of truth. + +Two batch-1 findings had knock-on effects a later ticket must not re-derive: + +- **RB-01's residual is RB-09's problem.** Both callers of the document-content endpoint + reach it as a plain browser navigation (`` / `previewUrl`), carrying no identity + header and passing through no interceptor, so `StubIdentityProvider` answers with the + seeded citizen. The links keep working only because one citizen owns every document in + the POC. That is BIO-002; RB-09 needs this endpoint to receive a real credential. +- **RB-06 also deleted `SubmissionRules.RejectRegistratie`**, which the row did not ask for. + It was reachable only from the deleted endpoint and contradicted by the live submit path. + Recorded as the ticket's one judgement call in `implementation/rb-06.md`. + +`Pii.MaskTail` now lives in `Domain/People/Pii.cs` (moved out of `Program.cs` by RB-03) — +**RB-11 and any later redaction work should use it rather than hand-rolling a second copy.** --- @@ -84,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ------ | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | open | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | open | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | open | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 88b4e89..68302d0 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -1,13 +1,29 @@ # Agent run status -| Agent | Status | Last module processed | Last updated | Notes | -| --------------- | -------------------------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. | -| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. | -| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. | -| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. | -| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs` → `Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". | -| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. | -| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. | -| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). | -| consolidation | halted (awaiting approval) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. | +| Agent | Status | Last module processed | Last updated | Notes | +| --------------- | ------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. | +| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. | +| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. | +| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. | +| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs` → `Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". | +| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. | +| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. | +| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). | +| consolidation | complete (approved) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. **Approved 2026-08-27; HALT lifted.** | + +## Phase 3 — implementation + +| CD batch | Tickets | Status | Notes | +| -------- | ---------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | not started | RB-08 depends on RB-07. RB-07 gates signing ADR-C-009. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | +| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | + +**Standing caveat for every batch:** `dotnet test` reports one failure, +`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, +which needs a live OpenZaak container. It fails identically on a stashed tree — it is not +caused by any of these tickets. `npm run ci` does not run it. From e89525eef65b523d226e4eed3038b1a3d4252bd2 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 13:12:22 +0200 Subject: [PATCH 15/61] feat(audit): record the allow path, not just the denial (RB-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five authorization gates audited only their deny branch, so /beheer/audit could answer "who was turned away" but never "who changed this" — for a register whose integrity is the product, the wrong half. Nothing recorded the flag toggle, either org-template write, the admin case or upload delete, the three brief transitions, or the besluit; the comment claiming endpoints log their own effect held for two of the eight. Each gate now computes the decision once, audits it, and then acts. The row is written by the gate rather than the endpoint, so a new admin endpoint cannot be added that forgets to audit itself. Same reasoning for the brief: every transition already funnelled through LogBrief for its log line, so the audit row goes there too — submit/approve/reject/send in one place, with the transition's own outcome as the decision, so a 403 or 409 is as visible as a success. FlagsAdmin gained a per-call resource, the one deviation from BIO-007's minimal remediation: the toggle endpoint writes no log line of its own, so a constant "feature-flags" row would say a flag changed without saying which. It now records feature-flags/=. OrgAdmin and CasesAdmin keep coarse refs because those endpoints do log the specific object. The besluit gets a second row: the gate records that a behandelaar was allowed to act, aanvraag:besluit records what they decided. Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads now write rows. That is what auditing the allow path means; it is also what would make retention on AuthzAuditStore necessary later. Closes CQ-004's outstanding half and unblocks signing ADR-C-009. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 72 ++++++++++++------- .../BigRegister.Tests/AuthzAuditTests.cs | 38 ++++++++++ .../BigRegister.Tests/BeoordelingTests.cs | 7 ++ .../BigRegister.Tests/BriefEndpointTests.cs | 5 ++ .../refactor-backlog/implementation/rb-07.md | 63 ++++++++++++++++ 5 files changed, 161 insertions(+), 24 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index fbadce2..614162e 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -506,6 +506,10 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H statusCode: StatusCodes.Status409Conflict); app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit); + // RB-07/BIO-007: the gate above records that a behandelaar was allowed to act; this + // records what they decided. Without it /beheer/audit cannot answer "who rejected this + // aanvraag", which is the question the trail exists for. + AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx)); // WP-60: the local decision above already committed — a ZGW failure here is caught and // flagged rather than allowed to diverge silently, same handling as submit's create-zaak @@ -598,8 +602,9 @@ api.MapGet("/flags", () => Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList())) .Produces>(); -api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, () => - FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) +api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => + FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () => + FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status403Forbidden); @@ -629,7 +634,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now()); - LogBrief("submit", r); + LogBrief(ctx, "submit", r); return BriefResult(ctx, r, "Alleen de opsteller mag indienen."); }) .WithName("briefSubmit") // distinct name so the generated client method isn't `submit2` @@ -640,7 +645,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) => { var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now()); - LogBrief("approve", r); + LogBrief(ctx, "approve", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) .Produces() @@ -650,7 +655,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => { var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now()); - LogBrief("reject", r); + LogBrief(ctx, "reject", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) .Produces() @@ -663,7 +668,7 @@ api.MapPost("/brief/send", (HttpContext ctx) => // port); the backend only guards the approved→sent transition (not role-gated // today — see Authz.CanActOn(Send, …), a mechanical dispatch step). var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now()); - LogBrief("send", r); + LogBrief(ctx, "send", r); return BriefResult(ctx, r, "Versturen kan niet in deze status."); }) .Produces() @@ -785,14 +790,19 @@ app.Run(); static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; // One gate for every org-template endpoint — the enforce twin of the -// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial -// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their -// own effect, e.g. publish). +// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). +// +// RB-07/BIO-007: every gate below audits the real decision, allow *and* deny. Auditing +// only denials left /beheer/audit able to answer "who was turned away" but not "who +// changed this", which for a register whose integrity is the product is the wrong half +// (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate, +// not by the endpoint, so a new admin endpoint cannot be added that forgets it. IResult OrgAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanManageOrgTemplates(principal)) return action(); - AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal); + var ok = Authz.CanManageOrgTemplates(principal); + AuditAuthz(ctx, "orgtemplate:edit", "org-templates", ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", statusCode: StatusCodes.Status403Forbidden); } @@ -802,8 +812,9 @@ IResult OrgAdmin(HttpContext ctx, Func action) IResult StamdataAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanEditStamdata(principal)) return action(); - AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal); + var ok = Authz.CanEditStamdata(principal); + AuditAuthz(ctx, "stamdata:edit", "stamdata", ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.", statusCode: StatusCodes.Status403Forbidden); } @@ -813,8 +824,9 @@ IResult StamdataAdmin(HttpContext ctx, Func action) IResult CasesAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanManageCases(principal)) return action(); - AuditAuthz(ctx, "cases:manage", "cases", false, principal); + var ok = Authz.CanManageCases(principal); + AuditAuthz(ctx, "cases:manage", "cases", ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.", statusCode: StatusCodes.Status403Forbidden); } @@ -825,18 +837,23 @@ IResult CasesAdmin(HttpContext ctx, Func action) // zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row. IResult Beoordelen(HttpContext ctx, string resource, Func action) { - if (Authz.CanBeoordelen(ctx.Caller())) return action(); - AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx)); + var ok = Authz.CanBeoordelen(ctx.Caller()); + AuditAuthz(ctx, "aanvraag:beoordelen", resource, ok, Authz.ResolvePrincipal(ctx)); + if (ok) return action(); return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.", statusCode: StatusCodes.Status403Forbidden); } -// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). -IResult FlagsAdmin(HttpContext ctx, Func action) +// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a +// per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of +// its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which, +// and this is the surface CQ-004/ADR-C-009 hinge on. +IResult FlagsAdmin(HttpContext ctx, string resource, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanManageFeatureFlags(principal)) return action(); - AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal); + var ok = Authz.CanManageFeatureFlags(principal); + AuditAuthz(ctx, "flags:manage", resource, ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.", statusCode: StatusCodes.Status403Forbidden); } @@ -892,9 +909,16 @@ IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? e _ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict), }; -void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) => - app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}", - action, r.outcome, r.entity?.Status.Tag ?? "-"); +// RB-07/BIO-007: every brief transition already funnelled through here for its log line, +// so the audit row goes here too — a fifth transition cannot be added that logs but leaves +// no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is +// the transition's own outcome, so a 403 or a 409 is as visible as a success. +void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) +{ + app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}", + action, r.outcome, r.entity?.Status.Tag ?? "-"); + AuditAuthz(ctx, "brief:" + action, "brief", r.outcome == BriefStore.Outcome.Ok, Authz.ResolvePrincipal(ctx)); +} // Audit + outcome for a submit, with NO personal data: only kind, outcome, // generated reference and the caller's correlation id (the observability seam — a diff --git a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs index 6a4848f..094f456 100644 --- a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.RegularExpressions; using BigRegister.Api.Contracts; using BigRegister.Api.Data; +using BigRegister.Domain.Features; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -43,6 +44,43 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer"); } + /// RB-07/BIO-007: the trail used to record only denials, so `/beheer/audit` could answer + /// "who was turned away" but not "who changed this" — for a register whose integrity is the + /// product, the wrong half. Every gate now audits the real decision. + [Fact] + public async Task An_allowed_admin_action_is_recorded() + { + (await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"))).EnsureSuccessStatusCode(); + Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"); + } + + /// The flag toggle writes no log line of its own, so the audit row is the only record that + /// it happened — a bare "feature-flags" resource would not say which flag. + [Fact] + public async Task A_feature_flag_toggle_records_which_flag_changed() + { + var toggle = Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}"); + toggle.Content = JsonContent.Create(new { enabled = false }); + (await _client.SendAsync(toggle)).EnsureSuccessStatusCode(); + + Assert.Contains(await AuditLog(), e => + e.Action == "flags:manage" && e.Decision == "allow" && + e.Resource == $"feature-flags/{FeatureFlags.InschrijvingOpen}=False"); + } + + /// Every brief transition funnels through LogBrief, so all four are covered by the audit + /// call living there. The allow side is asserted in + /// BriefEndpointTests.Submit_succeeds_when_required_sections_filled, which already has + /// the fill-the-sections scaffolding; this is the refused side — a rejected transition must + /// leave a row rather than being dropped. + [Fact] + public async Task A_refused_brief_transition_is_recorded() + { + // No brief exists for this subject and nothing is filled in → illegal transition. + Assert.Equal(HttpStatusCode.Conflict, (await _client.PostAsync("/api/v1/brief/submit", null)).StatusCode); + Assert.Contains(await AuditLog(), e => e.Action == "brief:submit" && e.Decision == "deny"); + } + /// RB-02/BIO-008: the schema test below asserts on **column names**, so a BSN inside a /// column called `Resource` was invisible to it — and one was there, concatenated as /// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs index 19c0291..f79ffbb 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -150,6 +151,12 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture var view = (await detail.Content.ReadFromJsonAsync())!; Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag); Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed + + // RB-07/BIO-007: the gate records that a behandelaar was allowed to act; this records + // what they decided, which is the question /beheer/audit exists to answer. + Assert.Contains(AuthzAuditStore.List(), e => + e.Action == "aanvraag:besluit" && e.Decision == "allow" && + e.Resource == $"aanvraag/{a.Id}/Goedkeuren"); } finally { diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs index d38973c..22ec6ef 100644 --- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs @@ -160,6 +160,11 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu var submitted = await res.Content.ReadFromJsonAsync(); Assert.NotNull(submitted); Assert.Equal("submitted", submitted.Brief.Status.Tag); + + // RB-07/BIO-007: the allow side of the transition leaves a row, not just a log line. + // Resource is the bare "brief" — never the owner's BSN (RB-02). + Assert.Contains(AuthzAuditStore.List(), + e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief"); } [Fact] diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md new file mode 100644 index 0000000..50e9e34 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md @@ -0,0 +1,63 @@ +# RB-07 — audit the allow path, not just the denial + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-007 (+ the outstanding half of CQ-004) · `99-backlog.md` RB-07 + +## What was wrong + +All five authorization gates called `AuditAuthz(..., allowed: false, ...)` only on the deny +branch; the allow branch called `action()` and returned. So `/beheer/audit` — the queryable +trail the product ships as its audit surface — could answer "who was turned away" but never +"who changed this". + +Nothing recorded: `PUT /admin/flags/{key}`, `PUT /admin/org-template/{subOrgId}`, +`POST /admin/org-template/{subOrgId}/rollback/{version}`, `DELETE /admin/cases/{id}`, +`DELETE /admin/uploads/{documentId}`, `POST /brief/approve|reject|send`, and +`POST /beoordeling/{id}/besluit`. The comment above `OrgAdmin` claimed the endpoints logged +their own effect instead; publish and admin case delete do, the other six did not log at all. + +## What changed + +| File | Change | +| ------------------------- | ----------------------------------------------------------------------------- | +| `Program.cs` × 5 gates | `var ok = Authz.CanX(p); AuditAuthz(ctx, …, ok, p); if (ok) return action();` | +| `Program.cs` `FlagsAdmin` | takes a per-call `resource` (see below) | +| `Program.cs` `LogBrief` | takes `HttpContext`, writes the audit row alongside the log line | +| `Program.cs` besluit | one `aanvraag:besluit` row recording **what** was decided | +| `AuthzAuditTests.cs` | allow-path row; the flag key + value; a refused brief transition | +| `BriefEndpointTests.cs` | the allow side of `brief:submit` | +| `BeoordelingTests.cs` | the `aanvraag:besluit` row | + +**The row is written by the gate, not the endpoint.** That is the point: a new admin +endpoint cannot be added that forgets to audit itself. Same reasoning for the brief — every +transition already funnelled through `LogBrief` for its log line, so the audit call went +there too, which covers `submit`/`approve`/`reject`/`send` in one place and any fifth +transition automatically. The decision recorded is the transition's own outcome, so a 403 or +a 409 is as visible as a success. + +**`FlagsAdmin` gained a `resource` parameter** — the one deviation from BIO-007's minimal +remediation, and the reason is in the finding itself: the toggle endpoint writes no log line +of its own, so a constant `"feature-flags"` row would record that a flag changed without +recording _which_. It now writes `feature-flags/=`. One call site. +`OrgAdmin`/`CasesAdmin` keep their coarse refs because those endpoints do log the specific +object; **that asymmetry is deliberate, not an oversight.** + +**The besluit gets a second row.** The `Beoordelen` gate records that a behandelaar was +_allowed to act_; `aanvraag:besluit` records _what they decided_ +(`aanvraag//Goedkeuren`). Only the first would leave "who rejected this aanvraag" +unanswerable, which is the question the trail exists for. + +## Consequences worth knowing + +- **Row volume goes up.** `StamdataAdmin` gates read endpoints, so every admin page load now + writes rows. That is what "audit the allow path" means and BIO-007 asks for it explicitly; + if `AuthzAuditStore` ever needs retention or sampling, this is the change that made it + necessary. +- **This unblocks ADR-C-009.** Clause (4) of agent 06's four-part test is "writes are + admin-capability-gated **and** audited". Both surfaces now are, so the amendment can be + signed without ratifying a control the code does not implement. +- **CQ-004's outstanding half is closed.** `PUT /admin/flags/{key}` writes an audit row. + +## Verification + +`dotnet test`: **252 passed, 1 failed** — the pre-existing +`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. From 494cee9d0841d4de85fcda0dee87d462a8149fe6 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 13:55:41 +0200 Subject: [PATCH 16/61] fix(uploads): route the admin delete through CasesAdmin (RB-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /admin/uploads/{documentId} was gated by a standalone `X-Admin: true` header check (`IsAdmin`), outside the `Authz` module entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/ `FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no AuthzAuditStore row, so a destructive cross-owner document delete never appeared on /beheer/audit. A repo-wide grep confirmed the only sender of X-Admin was the backend test itself — no frontend or e2e path depends on it — so the gate was safe to delete outright. Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same wrapper the other admin-cases endpoints use. RB-07 already moved AuditAuthz onto every *Admin wrapper's allow path, so this gets the missing audit row for free with no second AuditAuthz call. Deleted the now-unused IsAdmin function and updated the two comments that referenced the old X-Admin seam. Updated EndpointTests.cs's Admin_delete_requires_admin_role to send X-Role: admin instead of X-Admin: true, and added AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the cases:manage/allow row count increases by exactly one (a plain Contains would already be satisfied by this test class's other cases:manage calls). Verified both tests fail red against the pre-fix gate. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 19 +++-- .../BigRegister.Tests/AuthzAuditTests.cs | 39 ++++++++++ .../tests/BigRegister.Tests/EndpointTests.cs | 4 +- .../refactor-backlog/implementation/rb-08.md | 75 +++++++++++++++++++ 4 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-08.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 614162e..270eacd 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -269,13 +269,14 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); -// Admin delete (seam): a real system requires an admin role; here an X-Admin header -// stands in. Bypasses ownership, unlinks, and flags the submission for review. -api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => - !IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden) - : DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()) +// Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated +// by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use +// (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and +// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07). +api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () => + DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())) .Produces(StatusCodes.Status204NoContent) -.Produces(StatusCodes.Status403Forbidden) +.ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- Applications (aanvragen): the system of record the dashboard reads. --- @@ -611,8 +612,8 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon // --- Brief (letter composition). One demo brief per owner; the server owns the // status machine + authorization (Authz, PRD-0002 phase P1). Principal is a -// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role= -// toggle) — no real identities in this POC. --- +// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real +// identities in this POC. --- api.MapGet("/brief", (HttpContext ctx) => { @@ -787,8 +788,6 @@ api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string sub app.Run(); -static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; - // One gate for every org-template endpoint — the enforce twin of the // `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). // diff --git a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs index 094f456..1252557 100644 --- a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.RegularExpressions; using BigRegister.Api.Contracts; @@ -27,6 +28,20 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< return (await res.Content.ReadFromJsonAsync>())!; } + private async Task UploadAsOwner() + { + var form = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + form.Add(file, "file", "diploma.pdf"); + form.Add(new StringContent("diploma"), "categoryId"); + form.Add(new StringContent("local-rb08"), "localId"); + form.Add(new StringContent("registratie"), "wizardId"); + var res = await _client.PostAsync("/api/v1/uploads", form); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync())!.DocumentId; + } + [Fact] public async Task A_denied_admin_action_is_recorded() { @@ -54,6 +69,30 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"); } + /// RB-08/BIO-003: the admin upload delete used to be gated by a standalone X-Admin + /// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through + /// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases + /// endpoint gets, for free, per RB-07. `CasesAdmin` audits under a fixed "cases" + /// resource shared with the other admin-cases endpoints, so this asserts a **count** + /// increase — reading the store directly (not via `GET /admin/audit`, itself a + /// `CasesAdmin` endpoint that would write its own row and confound the count) — + /// rather than mere presence, which this class's other cases:manage calls would + /// already satisfy even without the fix. + [Fact] + public async Task An_admin_upload_delete_is_recorded() + { + bool IsCasesManageAllow(AuthzAuditEntry e) => + e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"; + + var documentId = await UploadAsOwner(); + var before = AuthzAuditStore.List().Count(IsCasesManageAllow); + + (await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/uploads/{documentId}"))) + .EnsureSuccessStatusCode(); + + Assert.Equal(before + 1, AuthzAuditStore.List().Count(IsCasesManageAllow)); + } + /// The flag toggle writes no log line of its own, so the audit row is the only record that /// it happened — a bare "feature-flags" resource would not say which flag. [Fact] diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index c364a96..b8fb2d7 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -213,11 +213,13 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture ctx.Request.Headers["X-Admin"] == "true";` +and `DELETE /admin/uploads/{documentId}` (`:274`) was gated by `IsAdmin` alone — outside +`Authz`, outside every wrapper the four sibling admin surfaces use, and writing no +`AuthzAuditStore` row at all. `DocumentStore.AdminDelete` bypasses ownership and deletes the +row and its bytes; the only record left behind was a `DocumentStore.Audit("delete-admin", …)` +metadata row, which never surfaces on `/beheer/audit`. + +`grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e` (re-verified before deleting the +gate, as the ticket asked) confirmed the finding: the only sender was +`backend/tests/BigRegister.Tests/EndpointTests.cs:231`. No frontend or e2e path uses this +header — it was an orphaned gate, not a live seam. + +## What changed + +| File | Change | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` `DELETE /admin/uploads/{id}` | now `CasesAdmin(ctx, () => DocumentStore.AdminDelete(...) ? NoContent : NotFound)` — same wrapper the other four admin-cases endpoints use; response doc changed `Produces(403)` → `ProducesProblem(403)` to match `CasesAdmin`'s `Results.Problem` | +| `Program.cs` `IsAdmin` | **deleted** | +| `Program.cs` two stale comments | the endpoint's own comment rewritten to describe the new gate; the brief-section banner comment ("mirrors the X-Admin seam") no longer references a gate that doesn't exist | +| `tests/BigRegister.Tests/EndpointTests.cs` | `Admin_delete_requires_admin_role` sends `X-Role: admin` instead of `X-Admin: true` | +| `tests/BigRegister.Tests/AuthzAuditTests.cs` | **new** `An_admin_upload_delete_is_recorded` — asserts the `cases:manage`/`allow`/`Admin` row count increases by exactly one after the delete | + +No new `Authz` capability was added — `CasesAdmin`/`Authz.CanManageCases` is the wrapper the +ticket named as the expected outcome, and nothing about this endpoint needed a narrower +capability than "manage cases" already provides. + +**RB-07 already moved `AuditAuthz` onto the allow path for every `*Admin` wrapper**, so +routing through `CasesAdmin` gives BIO-003's missing audit row for free. No second +`AuditAuthz` call was added — confirmed by reading `CasesAdmin`'s body (`Program.cs`): it +calls `AuditAuthz(ctx, "cases:manage", "cases", ok, principal)` unconditionally before +branching on `ok`. + +## Judgement calls + +- **Test asserts a count delta, not mere presence.** `CasesAdmin` audits under a fixed + `"cases"` resource literal shared by every `cases:manage` call (`GET /admin/cases`, + `DELETE /admin/cases/{id}`, `GET /admin/audit` itself, and now this endpoint), so + `Assert.Contains(rows, cases:manage/allow/Admin)` would already be satisfied by this test + class's _other_ tests even without the fix. The new test counts matching rows before and + after the delete and asserts the count grew by exactly one. It reads `AuthzAuditStore.List()` + in-process rather than through `GET /admin/audit` — that endpoint is itself a `CasesAdmin` + read, so calling it to take the "before" measurement would have written its own + `cases:manage`/`allow` row and silently inflated the count by one every time it was called + (caught this by running the test once against the fix with an HTTP-based baseline: it + failed with an off-by-one before switching to the in-process read). +- **Two comments referencing the old gate were also updated**, not just the endpoint mapping + itself — one directly above the endpoint, one in the brief-section banner comment + ("dev-only stand-in via X-Role, mirrors the X-Admin seam") that would otherwise describe a + gate that no longer exists. + +## Known residual + +None new. RB-01's implementation note already records that `GET /uploads/{id}/content` is +reached with no identity header via plain browser navigation — that residual is RB-09's +territory, not this ticket's, and is untouched here. + +## Verification + +- Reverted `Program.cs`'s endpoint change only (`git stash push` on that one file, tests + left in place) and re-ran `dotnet test --filter "AuthzAuditTests|EndpointTests"`: **both** + `EndpointTests.Admin_delete_requires_admin_role` and + `AuthzAuditTests.An_admin_upload_delete_is_recorded` failed red (403 Forbidden — the old + gate rejects `X-Role: admin`, and the count-delta test throws on `EnsureSuccessStatusCode` + before it can assert). Restored the fix (`git stash pop`) and re-ran: both green. +- `dotnet build`: clean. +- `dotnet test` (full suite): **253 passed, 1 failed** — the pre-existing + `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + which needs a live OpenZaak container and fails identically on a clean tree; not touched by + this ticket. From 8b8b522052903146a3b7f2d7c69f9a8e7b55cae0 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:06:49 +0200 Subject: [PATCH 17/61] fix(auth): make no-identity representable; stub dev-only (RB-09) IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so the interface could not express "no identity" - StubIdentityProvider was forced to invent one for any request carrying no credential at all. Consequence: a production behandelportal build sends no X-Medewerker header (medewerkerInterceptor is dev-only), so it used to authenticate as the seeded citizen, role drafter - failing closed on backoffice capabilities but open on every citizen-scoped endpoint, including CanRevealBigNummer. Resolve now returns CallerIdentity?. StubIdentityProvider keeps a non-nullable return type (a valid narrower override) since it never itself has "no identity" to report - it is registered only under IsDevelopment() now. Production registers nothing and throws an InvalidOperationException immediately during startup instead: there is no real DigiD/employee-SSO provider in this POC yet, so a misconfigured Production deploy must fail before serving a single request, not resolve one per request. The identity-resolution middleware turns a null resolution into a 401 rather than passing it downstream. Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all and ProductionIdentityProviderTests, which builds its own WebApplicationFactory with UseEnvironment("Production") and asserts startup throws. Verified both new tests fail red against the pre-fix code. RB-01's residual (GET /uploads/{id}/content reached via plain browser navigation, no identity header) is confirmed unchanged in Development and its Production consequence is written up in implementation/rb-09.md for whoever lands the real identity provider - no signed-URL/cookie scheme was designed here, per scope. Co-Authored-By: Claude Opus 5 --- .../Domain/Authorization/IIdentityProvider.cs | 9 +- .../Authorization/StubIdentityProvider.cs | 5 + backend/src/BigRegister.Api/Program.cs | 29 ++++- .../StubIdentityProviderTests.cs | 30 +++++ .../refactor-backlog/implementation/rb-09.md | 123 ++++++++++++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md diff --git a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs index 5d4f047..a69a16d 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs @@ -4,9 +4,14 @@ namespace BigRegister.Domain.Authorization; /// Resolves the acting for a request (WP-53) — one of the two actor /// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker /// (real employee SSO/eHerkenning claims in production). is -/// the only implementation today. +/// the only implementation today, and is registered only in Development (Program.cs, +/// RB-09/BIO-002). /// public interface IIdentityProvider { - CallerIdentity Resolve(HttpContext ctx); + /// Null when the request carries no identity a real implementation can vouch for — + /// e.g. no credential at all. Returning null, rather than inventing a default, is what makes + /// "unauthenticated" representable; the identity-resolution middleware (Program.cs) + /// turns a null into a 401 instead of a silent identity substitution. + CallerIdentity? Resolve(HttpContext ctx); } diff --git a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs index d3ba6ff..0a721cd 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs @@ -13,6 +13,11 @@ namespace BigRegister.Domain.Authorization; /// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims /// (medewerker); every consumer of carries over unchanged once that /// swap happens. +/// +/// Registered only in Development (Program.cs, RB-09/BIO-002) — it always invents a +/// caller for a request with no credential, which is a deliberate developer convenience, not +/// something a production build may do. Its own return type stays non-nullable: unlike +/// , this stub never has "no identity" to report. /// public sealed class StubIdentityProvider : IIdentityProvider { diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 270eacd..e630dd6 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -51,7 +51,22 @@ Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.C // every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/ // X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real // DigiD/employee-SSO provider swaps in without touching a consumer. -builder.Services.AddSingleton(); +// +// RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no +// credential at all — a production behandelportal build sends no X-Medewerker header, so it +// used to authenticate every request as the seeded citizen (open on that citizen's own rights, +// including CanRevealBigNummer). Registering the stub only in Development, and failing to +// start in Production rather than falling through to a per-request 401, means a misconfigured +// deploy never serves a single request. The real DigiD/employee-SSO provider is out of scope +// for this POC (BIO-002's remediation says so explicitly) — until one exists, Production simply +// cannot start, which is the correct fail-closed behaviour for "no identity provider available". +if (builder.Environment.IsDevelopment()) + builder.Services.AddSingleton(); +else if (builder.Environment.IsProduction()) + throw new InvalidOperationException( + "No IIdentityProvider is registered for a Production environment. StubIdentityProvider " + + "is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " + + "this POC yet. Register one before deploying to Production."); // WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend // (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never @@ -111,11 +126,19 @@ app.Use(async (ctx, next) => // WP-53: resolve the acting citizen once per request, right after correlation — everything // downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of -// re-deriving "who" itself. +// re-deriving "who" itself. RB-09/BIO-002: a null resolution is "no identity", not "the seeded +// citizen" — this is the one place that turns it into a response (401) rather than letting it +// flow downstream as a silent identity substitution. var identityProvider = app.Services.GetRequiredService(); app.Use(async (ctx, next) => { - ctx.SetCaller(identityProvider.Resolve(ctx)); + var identity = identityProvider.Resolve(ctx); + if (identity is null) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + ctx.SetCaller(identity); await next(ctx); }); diff --git a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs index 529a154..28fef57 100644 --- a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs +++ b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs @@ -1,6 +1,8 @@ using BigRegister.Api.Data; using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -93,4 +95,32 @@ public class StubIdentityProviderTests var caller = Resolve(role: "admin", medewerker: "m.jansen"); Assert.Equal(PrincipalRole.Admin, caller.Role); } + + /// RB-09/BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this + /// stub's own contract stays non-nullable — it is a developer convenience that always invents + /// a caller, never a source of "no identity" itself. A request with genuinely no headers at + /// all still resolves to the seeded citizen, unchanged. + [Fact] + public void Never_returns_null_even_with_no_headers_at_all() + { + Assert.NotNull(new StubIdentityProvider().Resolve(new DefaultHttpContext())); + } +} + +/// RB-09/BIO-002: in Production, StubIdentityProvider is not registered at all (it is +/// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet — +/// so a Production build must fail at startup rather than silently resolving every request to +/// the seeded citizen (the failure mode BIO-002 documents). +public class ProductionIdentityProviderTests +{ + [Fact] + public void Production_environment_with_no_real_identity_provider_fails_at_startup() + { + using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.UseEnvironment("Production")); + + // The throw happens while the app builds services, before any request can be served — + // triggered here by the test host materialising that host to hand out a client. + Assert.ThrowsAny(() => factory.CreateClient()); + } } diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md new file mode 100644 index 0000000..8e7fd85 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md @@ -0,0 +1,123 @@ +# RB-09 — make "no identity" representable; stub Development-only; fail fast in Production + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-002 (folds in BIO-001(a)/(b)) · `99-backlog.md` RB-09 + +## What was wrong + +`IIdentityProvider.Resolve` returned a non-nullable `CallerIdentity` +(`IIdentityProvider.cs:12`, pre-change), so the interface could not express "no identity" — any +implementation, stub or real, was forced to invent one for an unauthenticated request. +`StubIdentityProvider` was registered unconditionally, for every environment. + +Consequence, traced end to end: `apps/behandelportal/src/app/app.config.ts:57-63` puts +`medewerkerInterceptor` inside the `isDevMode()` provider array, so a production +behandelportal build sends **no** `X-Medewerker`/`X-Rollen` header. With neither header, +`StubIdentityProvider.Resolve` fell through to +`new ZorgverlenerCaller(DocumentStore.DemoOwner, ..., PrincipalRole.Drafter)` — the single +seeded citizen, role `drafter`. That: + +- **Fails closed, correctly, on backoffice capabilities** — `Authz.CanBeoordelen` is + `caller is MedewerkerCaller`, so a zorgverlener caller is always `false` regardless of role. + This part of the design was right and is untouched. +- **Fails open on every citizen-scoped endpoint** — `GET/PUT/DELETE /applications*`, + `POST /applications/{id}/submit`, `DELETE /uploads/{id}`, `GET|PUT /brief`, + `POST /brief/submit|send|reset` all resolve `ctx.Zorgverlener().Bsn` to the seeded citizen's + BSN. An employee with no employee identity was granted a citizen's own read/write rights. +- **Holds `CanRevealBigNummer`** — that capability is `Role == PrincipalRole.Drafter`, and + `drafter` is exactly the no-header default. + +`CallerIdentityHttpContextExtensions.Caller()` (`CallerIdentity.cs:44-50`) already throws +loudly when the identity middleware didn't run — the codebase reached for fail-loud one layer +up and then defaulted one layer down, which is the shape of the bug. + +## What changed + +| File | Change | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Domain/Authorization/IIdentityProvider.cs` | `Resolve` returns `CallerIdentity?`; doc comment states what null means and where it's turned into a response | +| `Domain/Authorization/StubIdentityProvider.cs` | **implementation signature unchanged** (`CallerIdentity`, non-nullable) — a valid, narrower override of the nullable interface method (return-type covariance; the compiler accepts it with zero warnings); doc comment records it is Development-only and never itself returns null | +| `Program.cs` — registration | `StubIdentityProvider` registered only under `builder.Environment.IsDevelopment()`; an `else if (builder.Environment.IsProduction())` branch throws `InvalidOperationException` immediately, before `builder.Build()` — the earliest possible failure point | +| `Program.cs` — identity middleware | resolves the identity once; if `null`, sets `401` and returns without calling `next()`, instead of passing a null (or invented) caller downstream | +| `tests/BigRegister.Tests/StubIdentityProviderTests.cs` | **new** `Never_returns_null_even_with_no_headers_at_all`; **new** `ProductionIdentityProviderTests.Production_environment_with_no_real_identity_provider_fails_at_startup` | + +No consumer beyond the middleware itself calls `IIdentityProvider.Resolve` (`grep -rn +"IIdentityProvider\|identityProvider\."` over `backend/src` confirms exactly one call site) — +`Authz.ResolvePrincipal`, `ZgwTokenProvider.Mint`, and every endpoint read `ctx.Caller()` / +`ctx.Zorgverlener()`, which already throw on a missing identity and are untouched. The 401 +now happens _before_ those are ever reached for a request the middleware rejects. + +## Judgement calls + +- **`StubIdentityProvider`'s own method signature stays `CallerIdentity`, not + `CallerIdentity?`.** The interface needed the nullable shape to make "no identity" + representable in general; the stub itself never has that case (it is a developer + convenience that always invents a caller by design) and returning a narrower, + non-nullable type from an override of a nullable-returning interface method is valid C# + nullable-reference-type covariance — verified with a clean `dotnet build` (0 warnings). + This kept every existing `StubIdentityProviderTests` call site (`private static +CallerIdentity Resolve(...)`) compiling with zero changes, rather than sprinkling + null-forgiving operators through a file whose entire point is "the stub always resolves." +- **The Production-only check is `IsProduction()`, not `!IsDevelopment()`.** The ticket and + BIO-002 both say "Production must fail at startup" specifically. A third environment (e.g. + a hypothetical `Staging`) falls through neither branch, registers no `IIdentityProvider` at + all, and would still fail — one line later, when `app.Services.GetRequiredService< +IIdentityProvider>()` throws .NET's own "no service for type" exception — just with a less + specific message than the one this ticket adds for Production. That fallback is a safety + net, not the intended fail-fast message; if a real non-Production, non-Development + environment is added later, giving it the same explicit message is a one-line follow-up, + not a design gap today. +- **The throw sits before `builder.Build()`**, not after (where `GetRequiredService` already + runs today). Both satisfy "throw during service registration / app build so a misconfigured + deploy never serves a request" — throwing earlier was free and gives a message naming the + actual cause (no real identity provider) rather than a generic DI resolution failure. +- **The 401 short-circuits before `ctx.SetCaller`, not after.** `next(ctx)` is never called, + so no downstream middleware or endpoint runs for a request with no identity — a citizen or + behandelaar endpoint reached this way now gets a clean 401 instead of ever executing. +- **`TestWebApplicationFactory` needed no change.** `WebApplicationFactory` defaults its + test host to the `Development` environment when nothing overrides it (confirmed + empirically: `dotnet test` — every non-Production test, all 253 of them pre-existing plus + 2 new, passed unchanged), so the entire existing test suite continues to exercise the + Development path exactly as before. The Production test builds its own + `WebApplicationFactory().WithWebHostBuilder(b => b.UseEnvironment("Production"))` + rather than touching the shared fixture. + +## Known residual — explicitly out of scope, confirmed and written up per the ticket + +**RB-01's residual is this ticket's territory but is explicitly out of scope for this +ticket**, per the task: `GET /uploads/{documentId}/content` is reached by a plain browser +navigation (`` in `beoordeling-documenten.component.ts`, `previewUrl` in +`libs/shared/src/upload/upload.adapter.ts`) that sends no identity header and never passes +through an Angular interceptor. + +- **In Development, this is unchanged** — verified by reading the endpoint + (`Program.cs:253-266`) and confirming `StubIdentityProvider` is still registered and still + resolves the same non-null seeded-citizen default it always did when no headers are + present. `dotnet test`'s full pass (255/255, excluding the pre-existing OpenZaak failure) + including `UploadAccessTests` — which exercises exactly this endpoint — confirms it + byte-for-byte. +- **The Production consequence, for the next ticket:** today Production cannot start at + all (this ticket's fail-fast), so the question is moot until a real `IIdentityProvider` + exists. Once one does, this endpoint's plain-navigation callers carry no credential a real + provider could resolve — the identity middleware would treat that as "no identity" and + return 401 before the endpoint ever runs, breaking both preview links outright. Making the + stub Development-only does not itself break anything (nothing in Production exists yet to + break), but it does mean **whoever builds the real provider must also solve this endpoint's + credential-carrying problem in the same change**, or ship it broken. This is not a + signed-URL or cookie scheme, and no such scheme was designed here, per the ticket's explicit + instruction — it is recorded so the next ticket (RB-13, or whichever lands the real + provider) picks it up deliberately rather than discovering it in a production incident. + +## Verification + +- **Reverted the registration change only** (kept `AddSingleton()` unconditional, left the new tests in place) and ran + `ProductionIdentityProviderTests`: it failed red — `Assert.ThrowsAny() Failure: No exception +was thrown` (the stub gets registered in every environment, so the host builds fine and + `factory.CreateClient()` never throws). Restored the fix and re-ran: green. +- `dotnet build`: clean, **0 warnings** (confirms the nullable-covariance judgement call + above compiles cleanly). +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test` (full suite): **255 passed, 1 failed** — the pre-existing + `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT` + (needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter, + `dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**. From de349e702ed2440ba37401e97bd01c812ec624ab Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:08:06 +0200 Subject: [PATCH 18/61] test(auth): extract and spec the stored-session parse boundary (RB-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionStore.restore() — identical in both apps — read localStorage itself and did the parse plus shape validation in the same module-private function, invoked from a field initializer, so the storage read happened the instant the singleton was constructed and no spec could feed it a raw string. The logic it guards is a trust boundary, not incidental validation: the comment above it names G1 (never persist the BSN) and G2 (validate the shape before trusting it), and CLAUDE.md mandates a spec for boundary parse* adapters. ssp/auth and bhp/auth were jointly the worst-covered frontend modules. parseStoredSession(raw) moves into each app's auth/domain/session.ts, which is pure TS and already had a spec, so no new scaffolding was needed; restore() collapses to one line. Four cases: absent, non-JSON, wrong shape, and — BIO-017's addition — a stored {"bsn":…,"naam":…} restoring with bsn '', which makes the G1 guarantee executable rather than merely commented. Verified red without the fix. Landed twice, once per app, deliberately. TE-001 and BL-002 both say an extract-to-shared here would contradict ADR-0002, which models the two actors as different Principal variants and expects the two auth contexts to diverge; RB-13 is what differentiates them. Also specs redactProfile (BIO-017's second half) — a pure exported PII-redaction function that had none. behaviour-spec.mdx is regenerated, which also picks up the test names RB-07 added; that commit should have carried them and did not. Co-Authored-By: Claude Opus 5 --- .../src/app/auth/application/session.store.ts | 17 ++-- .../src/app/auth/domain/session.spec.ts | 21 ++++- .../src/app/auth/domain/session.ts | 18 ++++ .../src/app/auth/application/session.store.ts | 17 ++-- apps/ssp/src/app/auth/domain/session.spec.ts | 21 ++++- apps/ssp/src/app/auth/domain/session.ts | 18 ++++ .../src/app/shell/debug-state/mask.spec.ts | 58 +++++++++++++ .../refactor-backlog/implementation/rb-10.md | 87 +++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 28 +++++- 9 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 apps/ssp/src/app/shell/debug-state/mask.spec.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md diff --git a/apps/behandelportal/src/app/auth/application/session.store.ts b/apps/behandelportal/src/app/auth/application/session.store.ts index 47374d3..88ed651 100644 --- a/apps/behandelportal/src/app/auth/application/session.store.ts +++ b/apps/behandelportal/src/app/auth/application/session.store.ts @@ -1,23 +1,16 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; -import { Session } from '../domain/session'; +import { Session, parseStoredSession } from '../domain/session'; import { DigidAdapter } from '../infrastructure/digid.adapter'; const STORAGE_KEY = 'session-v1'; /** Restore a persisted session (best-effort; corrupt entry → logged out). - G2: validate the shape before trusting it. G1: the BSN is never persisted - (see the effect below), so a restored session carries an empty one — it is - unused after login; only `naam` is shown in the chrome. */ + The parse + shape validation (G1/G2) lives in `parseStoredSession` + (`../domain/session`) — pure, spec'd, and testable without stubbing + `localStorage`; this just supplies the raw value. */ function restore(): Session | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } + return parseStoredSession(localStorage.getItem(STORAGE_KEY)); } /** diff --git a/apps/behandelportal/src/app/auth/domain/session.spec.ts b/apps/behandelportal/src/app/auth/domain/session.spec.ts index c46343d..af90034 100644 --- a/apps/behandelportal/src/app/auth/domain/session.spec.ts +++ b/apps/behandelportal/src/app/auth/domain/session.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isAuthenticated, Session } from './session'; +import { isAuthenticated, parseStoredSession, Session } from './session'; const session: Session = { bsn: '19012345601', naam: 'Test' }; @@ -12,3 +12,22 @@ describe('isAuthenticated', () => { expect(isAuthenticated(null)).toBe(false); }); }); + +describe('parseStoredSession', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredSession(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredSession('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); + }); + + it('G1: a stored bsn is never restored, even if present in the raw value', () => { + const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); + expect(restored).toEqual({ bsn: '', naam: 'Test' }); + }); +}); diff --git a/apps/behandelportal/src/app/auth/domain/session.ts b/apps/behandelportal/src/app/auth/domain/session.ts index 486dc85..abbbbf6 100644 --- a/apps/behandelportal/src/app/auth/domain/session.ts +++ b/apps/behandelportal/src/app/auth/domain/session.ts @@ -7,3 +7,21 @@ export interface Session { export function isAuthenticated(s: Session | null): s is Session { return s !== null; } + +/** + * Parse a persisted session out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the + * shape before trusting it. G1: even if a stored entry carries a `bsn`, the + * restored session's `bsn` is always `''` — the BSN is never persisted (see + * the `SessionStore` effect that writes it), so a legacy or tampered entry + * cannot resurrect one. + */ +export function parseStoredSession(raw: string | null): Session | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; + } catch { + return null; + } +} diff --git a/apps/ssp/src/app/auth/application/session.store.ts b/apps/ssp/src/app/auth/application/session.store.ts index 47374d3..88ed651 100644 --- a/apps/ssp/src/app/auth/application/session.store.ts +++ b/apps/ssp/src/app/auth/application/session.store.ts @@ -1,23 +1,16 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; -import { Session } from '../domain/session'; +import { Session, parseStoredSession } from '../domain/session'; import { DigidAdapter } from '../infrastructure/digid.adapter'; const STORAGE_KEY = 'session-v1'; /** Restore a persisted session (best-effort; corrupt entry → logged out). - G2: validate the shape before trusting it. G1: the BSN is never persisted - (see the effect below), so a restored session carries an empty one — it is - unused after login; only `naam` is shown in the chrome. */ + The parse + shape validation (G1/G2) lives in `parseStoredSession` + (`../domain/session`) — pure, spec'd, and testable without stubbing + `localStorage`; this just supplies the raw value. */ function restore(): Session | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } + return parseStoredSession(localStorage.getItem(STORAGE_KEY)); } /** diff --git a/apps/ssp/src/app/auth/domain/session.spec.ts b/apps/ssp/src/app/auth/domain/session.spec.ts index c46343d..af90034 100644 --- a/apps/ssp/src/app/auth/domain/session.spec.ts +++ b/apps/ssp/src/app/auth/domain/session.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isAuthenticated, Session } from './session'; +import { isAuthenticated, parseStoredSession, Session } from './session'; const session: Session = { bsn: '19012345601', naam: 'Test' }; @@ -12,3 +12,22 @@ describe('isAuthenticated', () => { expect(isAuthenticated(null)).toBe(false); }); }); + +describe('parseStoredSession', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredSession(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredSession('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); + }); + + it('G1: a stored bsn is never restored, even if present in the raw value', () => { + const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); + expect(restored).toEqual({ bsn: '', naam: 'Test' }); + }); +}); diff --git a/apps/ssp/src/app/auth/domain/session.ts b/apps/ssp/src/app/auth/domain/session.ts index 486dc85..abbbbf6 100644 --- a/apps/ssp/src/app/auth/domain/session.ts +++ b/apps/ssp/src/app/auth/domain/session.ts @@ -7,3 +7,21 @@ export interface Session { export function isAuthenticated(s: Session | null): s is Session { return s !== null; } + +/** + * Parse a persisted session out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the + * shape before trusting it. G1: even if a stored entry carries a `bsn`, the + * restored session's `bsn` is always `''` — the BSN is never persisted (see + * the `SessionStore` effect that writes it), so a legacy or tampered entry + * cannot resurrect one. + */ +export function parseStoredSession(raw: string | null): Session | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; + } catch { + return null; + } +} diff --git a/apps/ssp/src/app/shell/debug-state/mask.spec.ts b/apps/ssp/src/app/shell/debug-state/mask.spec.ts new file mode 100644 index 0000000..92915a4 --- /dev/null +++ b/apps/ssp/src/app/shell/debug-state/mask.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { BigProfile } from '@registratie/domain/big-profile'; +import { REDACTED } from '@shared/kernel/pii'; +import { redactProfile } from './mask'; + +const profile: BigProfile = { + registration: { + bigNummer: '12345678901', + naam: 'J. Jansen', + beroep: 'arts', + registratiedatum: '2015-03-01', + geboortedatum: '1980-06-12', + status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' }, + }, + person: { + naam: 'J. Jansen', + geboortedatum: '1980-06-12', + adres: { straat: 'Hoofdstraat 1', postcode: '1234AB', woonplaats: 'Utrecht' }, + }, +}; + +describe('redactProfile', () => { + const redacted = redactProfile(profile) as { + registration: { + bigNummer: string; + naam: string; + beroep: string; + registratiedatum: string; + geboortedatum: string; + status: unknown; + }; + person: { naam: string; geboortedatum: string; adres: string }; + }; + + it('masks the BIG-nummer to its last 3 digits', () => { + expect(redacted.registration.bigNummer).toBe('********901'); + }); + + it('redacts the name on both the registration and the person', () => { + expect(redacted.registration.naam).toBe(REDACTED); + expect(redacted.person.naam).toBe(REDACTED); + }); + + it('redacts every date of birth', () => { + expect(redacted.registration.geboortedatum).toBe(REDACTED); + expect(redacted.person.geboortedatum).toBe(REDACTED); + }); + + it('redacts the address', () => { + expect(redacted.person.adres).toBe(REDACTED); + }); + + it('keeps structural/decision-relevant fields untouched', () => { + expect(redacted.registration.beroep).toBe('arts'); + expect(redacted.registration.registratiedatum).toBe('2015-03-01'); + expect(redacted.registration.status).toEqual(profile.registration.status); + }); +}); diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md new file mode 100644 index 0000000..00ce89c --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md @@ -0,0 +1,87 @@ +# RB-10 — extract `parseStoredSession` (both apps) and spec `redactProfile` + +Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-001 (ssp/auth and bhp/auth) · `07-bio2-compliance.md` BIO-017 · `99-backlog.md` RB-10 + +## What was wrong + +`SessionStore.restore()` — identical in `apps/ssp/src/app/auth/application/session.store.ts` +and `apps/behandelportal/src/app/auth/application/session.store.ts` — called +`localStorage.getItem(STORAGE_KEY)` itself and did the parse + shape validation in the same +module-private function. It was invoked from a field initializer +(`private _session = signal(restore())`), so the storage read happened the +instant the singleton was constructed; a spec could not feed it a raw string without +stubbing the `localStorage` global before the injector built the store. + +The logic behind that guard is a trust boundary, not incidental validation — the comment +above it names two guarantees: **G1** (never persist the BSN) and **G2** (validate the shape +before trusting it). CLAUDE.md §5 mandates a spec for boundary `parse*` adapters, and none +existed. Baseline evidence: `02-testability.md` §3a cites `ssp/auth` and `bhp/auth` at +42.9% line / 46.2% branch — jointly the worst line coverage in the frontend table — with +this file's own lcov at LH 2/LF 20 (10.0% line), BRH 3/BRF 13 (23.1% branch). + +BIO-017 read the same code and confirmed G1 holds on every path by inspection (`restore()` +returns `{ bsn: '', naam }`, the persistence `effect()` writes only `naam`, `login()`/ +`logout()` never touch storage with a BSN) — but "correct, unverified by a test" is exactly +the gap TE-001 already targeted, so BIO-017 folds into it and adds one required assertion: +a stored `{"bsn":"…","naam":"…"}` must yield a session whose `bsn` is `''`. + +Separately, `apps/ssp/src/app/shell/debug-state/mask.ts` — confirmed at the path the finding +cites — has `redactProfile`, a pure, exported, directly callable PII-redaction function with +no spec. It redacts name, birthdate and address and masks the BIG-nummer; BIO-017 verified it +correct by reading, same "no regression net" gap. + +## What changed + +| File | Change | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/auth/domain/session.ts` | added `export function parseStoredSession(raw: string \| null): Session \| null` — the exact parse+validate body `restore()` used to hold | +| `apps/ssp/src/app/auth/application/session.store.ts` | `restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))` | +| `apps/ssp/src/app/auth/domain/session.spec.ts` | 4 new cases: absent, non-JSON, wrong shape, and the G1 assertion | +| `apps/behandelportal/src/app/auth/domain/session.ts` | identical extraction, second app | +| `apps/behandelportal/src/app/auth/application/session.store.ts` | identical collapse, second app | +| `apps/behandelportal/src/app/auth/domain/session.spec.ts` | identical 4 cases, second app | +| `apps/ssp/src/app/shell/debug-state/mask.spec.ts` | new file — spec for `redactProfile`: masks the BIG-nummer, redacts name/geboortedatum/adres on both `registration` and `person`, leaves `beroep`/`registratiedatum`/`status` untouched | + +The extracted function's body is a byte-for-byte move — same `try`/`catch`, same +`JSON.parse` cast, same `typeof parsed?.naam === 'string'` guard, same `{ bsn: '', naam }` +construction. Only its location and the doc comment (rewritten to explain the _why_ of G1/G2 +for a function now read on its own, rather than inline next to the `effect()` it used to sit +beside) changed. + +## The seam lands twice, on purpose + +`auth` is deliberately unshared per ADR-0002 / CLAUDE.md §1: Zorgverlener and Medewerker are +different `Principal` variants with different login flows, and the two `session.ts` files are +expected to diverge. TE-001 says this outright, and BL-002 flags any extract-to-`libs/shared` +here as contradicting an accepted ADR. `parseStoredSession` was therefore written twice, once +per app's own `domain/session.ts` — not factored into a shared helper, and not resisted only +in this note; the two functions are word-for-word identical today and that is expected to +change the moment `RB-13` (`Session → Principal`) lands. + +## Judgement calls + +- **`redactProfile`'s spec asserts on the concrete shape**, not just "not equal to the input" — + it pins `bigNummer` to `'********901'`, checks `REDACTED` on each PII field individually, and + separately asserts the non-PII fields (`beroep`, `registratiedatum`, `status`) survive + unchanged. A looser "no PII substring appears" assertion would have been weaker at catching + the regression this ticket exists to prevent (e.g. a future field added to `redactProfile`'s + output that is left unmasked by accident). +- **The G1 spec case uses `toEqual`, not `toBe`**, since the parser constructs a new object; + this matches the existing `isAuthenticated` spec's style in the same file. +- No production code beyond the `restore()` one-liner in each `session.store.ts` changed — + `login()`, `logout()`, and the persistence `effect()` were already correct and are + unaffected. + +## Verification + +Confirmed both new specs are red without the fix: + +- Temporarily changed `parseStoredSession` to keep a stored `bsn` (`bsn: parsed.bsn ?? ''` + instead of `bsn: ''`) — the new G1 test failed with + `expected { bsn: '19012345601', naam: 'Test' } to deeply equal { bsn: '', naam: 'Test' }`, + all 243 other tests stayed green. Reverted; `git diff` on the file is empty afterward. +- Temporarily changed `redactProfile` to pass `naam` through unmasked — the new "redacts the + name" test failed with `expected 'J. Jansen' to be '‹redacted›'`. Reverted; `git diff` on the + file is empty afterward. + +`npm run ci`: **green** (see PR/commit for the run this doc ships with). diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 39566bf..6a55c75 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,8 +20,8 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 402 frontend behaviours across -8 contexts; 225 backend behaviours across 38 test +**is** the suite, reshaped for a business reader. 415 frontend behaviours across +9 contexts; 228 backend behaviours across 38 test classes. ## Frontend (by context) @@ -35,6 +35,17 @@ classes. - narrows a present session to Session - reports no session as not authenticated +#### parseStoredSession + +- returns null when nothing is stored +- returns null for a non-JSON string +- returns null when the stored shape is wrong (no naam) +- G1: a stored bsn is never restored, even if present in the raw value +- returns null when nothing is stored +- returns null for a non-JSON string +- returns null when the stored shape is wrong (no naam) +- G1: a stored bsn is never restored, even if present in the raw value + ### behandeling #### besluit reduce @@ -781,6 +792,16 @@ classes. - clears the key once the wrapped fn settles - falls back to a generated uuid-shaped key when none is pending +### shell + +#### redactProfile + +- masks the BIG-nummer to its last 3 digits +- redacts the name on both the registration and the person +- redacts every date of birth +- redacts the address +- keeps structural/decision-relevant fields untouched + ### showcase #### highlightTs @@ -825,6 +846,9 @@ classes. - A denied admin action is recorded - A reveal attempt is recorded +- An allowed admin action is recorded +- A feature flag toggle records which flag changed +- A refused brief transition is recorded - No audit row carries a subjects bsn - The audit schema carries no pii From 3545023af84516baf25dfd54e42aa429a3c5b891 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:08:27 +0200 Subject: [PATCH 19/61] docs: regenerate the behaviour spec for RB-07's new tests The RB-07 commit added three AuthzAuditTests cases and did not regenerate libs/shared/docs/behaviour-spec.mdx, so the "behaviour spec drift" CI step was left red on that branch. My mistake: I committed RB-07 and answered a question before running the gate. Co-Authored-By: Claude Opus 5 --- libs/shared/docs/behaviour-spec.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 39566bf..f951757 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 402 frontend behaviours across -8 contexts; 225 backend behaviours across 38 test +8 contexts; 228 backend behaviours across 38 test classes. ## Frontend (by context) @@ -825,6 +825,9 @@ classes. - A denied admin action is recorded - A reveal attempt is recorded +- An allowed admin action is recorded +- A feature flag toggle records which flag changed +- A refused brief transition is recorded - No audit row carries a subjects bsn - The audit schema carries no pii From 9bff19a3e6a1377a1765b691ffe5358d8b687271 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:11:18 +0200 Subject: [PATCH 20/61] build: keep agent worktrees out of prettier and git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running implementation agents with worktree isolation puts full checkouts of this repo under .claude/worktrees/. `prettier --check .` walks into them, so `npm run ci` went red on 70 files that belong to another checkout — including the vendored CIBG design system, which the top-level ignore already excludes. Ignored in both .prettierignore and .gitignore; the latter so a worktree can never be committed into the repo it is a checkout of. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++++ .prettierignore | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 10477d4..022d93b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,7 @@ backend/openzaak/seeded.env # WP-55: render-prod-secrets.sh's output — the real client secret, never committed backend/openzaak/setup_configuration/data.prod.yaml + +# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of +# this repo nested inside it; never commit one. +.claude/worktrees/ diff --git a/.prettierignore b/.prettierignore index 2e7e30e..07cb7ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,11 @@ storybook-static*/ coverage/ .angular/ +# Agent git worktrees — full checkouts of this repo nested inside it, so an +# unignored `prettier --check .` walks into every one of them (and reports the +# vendored CIBG files that the top-level ignore already excludes). +.claude/worktrees/ + # Lockfile package-lock.json From 4ac13f6cb524e3f356e0d5db86f2ec9d85dd65c8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:11:51 +0200 Subject: [PATCH 21/61] docs: regenerate behaviour spec (RB-07 drift, RB-08, RB-09) `npm run gen:behaviour-spec`'s drift check (part of `npm run ci`) caught two things: RB-07 had already left this generated doc stale (three of its new AuthzAuditTests cases were never picked up), and RB-08/RB-09 added more test names since. Regenerated so the doc matches the suite it claims to mirror. Co-Authored-By: Claude Opus 5 --- libs/shared/docs/behaviour-spec.mdx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 39566bf..67ece99 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 402 frontend behaviours across -8 contexts; 225 backend behaviours across 38 test +8 contexts; 231 backend behaviours across 39 test classes. ## Frontend (by context) @@ -825,6 +825,10 @@ classes. - A denied admin action is recorded - A reveal attempt is recorded +- An allowed admin action is recorded +- An admin upload delete is recorded +- A feature flag toggle records which flag changed +- A refused brief transition is recorded - No audit row carries a subjects bsn - The audit schema carries no pii @@ -1048,6 +1052,10 @@ classes. - Proefbrief is admin only - Proefbrief renders the draft template with a watermark +### ProductionIdentityProviderTests + +- Production environment with no real identity provider fails at startup + ### ProfessionsTests - A mapping is absent before its geldigVan @@ -1083,6 +1091,7 @@ classes. - Empty x medewerker falls through to the zorgverlener default - X rollen parses known tokens and drops unknown ones - X role still applies to a medewerker +- Never returns null even with no headers at all ### SubmissionRuleTests From b5432d2c637a26c0c230e958fe916b2b0315e99a Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:15:29 +0200 Subject: [PATCH 22/61] docs(backlog): RB-07 and RB-10 done, batch 2 in progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RB-07 unblocks signing ADR-C-009 (its clause 4, "writes are admin-capability-gated and audited", now holds) and closes CQ-004's outstanding half. RB-10 landed parseStoredSession twice, once per app, deliberately — recorded so a later reader does not file it as duplication. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 4 ++-- .../refactor-backlog/_status.md | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 9cf63fd..e008483 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -108,10 +108,10 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | | **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | | **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open | | **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | open | | **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | | **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 68302d0..49bbc9c 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,14 +14,14 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ---------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | not started | RB-08 depends on RB-07. RB-07 gates signing ADR-C-009. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | -| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | +| CD batch | Tickets | Status | Notes | +| -------- | ---------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **in progress** | **RB-07 and RB-10 done.** RB-07 unblocks signing **ADR-C-009** (clause 4 now holds) and closes CQ-004's outstanding half. RB-08/RB-09 (chained — both edit `Program.cs`) and RB-11 are running as worktree agents. RB-10 landed `parseStoredSession` **twice, once per app**, deliberately — TE-001 and BL-002 both say extract-to-shared here contradicts ADR-0002; **RB-13** is what differentiates the two auth contexts. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | +| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, From 772c47ea438b49c2fb8a2b9ae345e3f33e862fe6 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:20:47 +0200 Subject: [PATCH 23/61] fix(brief): keep the dev hatches out of production builds (RB-11) BIO-012: roleInterceptor/subjectInterceptor are correctly registered only under isDevMode(), but three hand-written fetch adapters (reveal-bignummer, letter-preview, org-template's proefbrief) bypass HttpClient and set X-Role/X-Subject themselves with no guard. The readers underneath, role.ts and subject.ts, were ungated too: they read ?role=/?subject= and wrote it into sessionStorage on any navigation, in any build -- for ?subject= that value is a BSN, which is exactly what SessionStore's G1 comment promises never happens. Gate both layers: currentRole()/currentSubject() return their safe default immediately outside isDevMode() (no query-param read, no sessionStorage write), and the three adapters additionally wrap their headers in isDevMode() so a production request carries neither header at all, matching what an HttpClient request already does once the interceptors aren't registered. TE-002: reveal-bignummer's response-shape validation was a "Trust boundary" a spec could only reach by stubbing globalThis.fetch. Exported it as parseRevealed(body), matching the other 30 parse* boundaries in the repo. Same treatment for letter-preview's errorMessage and org-template's proefbrief error mapping (extracted from an inline try/catch into a named, exported function first, since it wasn't already separate). BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally, so the backend's step-up precondition constrained nothing. reveal() now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable only after the UI's confirm() gesture -- is the one that supplies it, so the literal no longer lives in the transport adapter. BIO-006(b): documented in roles-and-access.md that drafter is also the backend's fallback identity (StubIdentityProvider's catch-all arm), not just the dev switcher's initial choice -- so the least-privilege consequence of it also being the only role that may reveal a BSN is visible. Doc correction, same diff: roles-and-access.md's "wired only under isDevMode()" claim was false for the three hand-written fetch paths; it now says where the gate lives (interceptor registration and the reader functions) so it doesn't go stale the same way again. CLAUDE.md's dev-only claims needed no correction -- they already noted these three calls bypass the interceptor. Every fix has a test confirmed red by temporarily reverting the source change and rerunning the suite before restoring it. Co-Authored-By: Claude Opus 5 --- .../src/app/brief/application/brief.store.ts | 7 +- .../letter-preview.adapter.spec.ts | 69 +++++++ .../infrastructure/letter-preview.adapter.ts | 15 +- .../org-template.adapter.spec.ts | 28 ++- .../infrastructure/org-template.adapter.ts | 27 ++- .../reveal-bignummer.adapter.spec.ts | 77 ++++++++ .../reveal-bignummer.adapter.ts | 49 +++-- .../refactor-backlog/implementation/rb-11.md | 179 ++++++++++++++++++ docs/reference/roles-and-access.md | 20 +- libs/shared/docs/behaviour-spec.mdx | 54 +++++- libs/shared/src/infrastructure/role.spec.ts | 65 +++++++ libs/shared/src/infrastructure/role.ts | 12 +- .../shared/src/infrastructure/subject.spec.ts | 57 ++++++ libs/shared/src/infrastructure/subject.ts | 10 + 14 files changed, 632 insertions(+), 37 deletions(-) create mode 100644 apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts create mode 100644 apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md create mode 100644 libs/shared/src/infrastructure/role.spec.ts create mode 100644 libs/shared/src/infrastructure/subject.spec.ts diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 46e9210..88c2a1c 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -233,9 +233,12 @@ export class BriefStore implements PendingSave { /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability + step-up and audits the attempt; on success we swap the masked value in the already-loaded caseContext (a field update, not a reload). The step-up gesture - itself is the UI's concern — this command just runs the audited server call. */ + itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm) + — this command is only reachable once that gesture has happened, so it is the one + that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer + hardcodes the header). */ async revealBigNummer() { - const r = await this.revealAdapter.reveal(); + const r = await this.revealAdapter.reveal(true); if (!r.ok) { this.actionState.set({ tag: 'Failed', error: r.error }); return; diff --git a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts new file mode 100644 index 0000000..7c52ca7 --- /dev/null +++ b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter'; + +// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing +// globalThis.fetch to reach this trust boundary (TE-002). +const fakeResponse = (body: unknown): Response => + ({ json: () => Promise.resolve(body) }) as unknown as Response; + +describe('errorMessage (TE-002 trust boundary)', () => { + it('surfaces the ProblemDetails detail when present', async () => { + expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe( + 'Geen toegang.', + ); + }); + + it('falls back to PREVIEW_FAILED when the body has no detail', async () => { + expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED); + }); + + it('falls back to PREVIEW_FAILED when the body is not JSON', async () => { + const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response; + expect(await errorMessage(res)).toBe(PREVIEW_FAILED); + }); +}); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('LetterPreviewAdapter.preview (BIO-012)', () => { + const okResponse = () => + ({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response; + + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + vi.unstubAllGlobals(); + history.pushState({}, '', '/'); + sessionStorage.clear(); + }); + + it('sends no X-Role/X-Subject headers outside isDevMode()', async () => { + setDevMode(false); + history.pushState({}, '', '/?subject=111222333'); + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + await new LetterPreviewAdapter().preview(); + + expect(fetchSpy.mock.calls[0][1].headers).toEqual({}); + }); + + it('sends X-Role (and X-Subject when known) under isDevMode()', async () => { + setDevMode(true); + history.pushState({}, '', '/?subject=111222333'); + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + await new LetterPreviewAdapter().preview(); + + const headers = fetchSpy.mock.calls[0][1].headers as Record; + expect(headers['X-Role']).toBeDefined(); + expect(headers['X-Subject']).toBe('111222333'); + }); +}); diff --git a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts index 7fc1f31..d777d7d 100644 --- a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { currentRole } from '@shared/infrastructure/role'; import { currentSubject } from '@shared/infrastructure/subject'; @@ -15,7 +15,10 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning * hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set * here explicitly (WP-74 — without `X-Subject` this always previewed - * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). + * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are + * dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under + * `isDevMode()`, mirroring how the interceptors themselves are only registered in dev + * (`app.config.ts`) — a production build sends neither header from this call (BIO-012). * * `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven * `Vary: Origin`, and its content changes at the SAME URL as the letter moves @@ -43,7 +46,9 @@ export class LetterPreviewAdapter { const subject = currentSubject(); res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, { cache: 'no-store', - headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }, + headers: isDevMode() + ? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) } + : {}, }); } catch { return err(PREVIEW_FAILED); @@ -53,7 +58,9 @@ export class LetterPreviewAdapter { } } -async function errorMessage(res: Response): Promise { +/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec + can call it directly instead of stubbing `globalThis.fetch`. */ +export async function errorMessage(res: Response): Promise { try { return problemDetail(await res.json(), PREVIEW_FAILED); } catch { diff --git a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts index ac7d221..b102a90 100644 --- a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts +++ b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client'; -import { parseOrgTemplateAdminView } from './org-template.adapter'; +import { + parseOrgTemplateAdminView, + proefbriefErrorMessage, + PROEFBRIEF_FAILED, +} from './org-template.adapter'; const draft: OrgTemplateDto = { subOrgId: 'cibg-registers', @@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => { expect(r.ok).toBe(false); }); }); + +// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids +// stubbing globalThis.fetch to reach this trust boundary (TE-002). +const fakeResponse = (body: unknown): Response => + ({ json: () => Promise.resolve(body) }) as unknown as Response; + +describe('proefbriefErrorMessage (TE-002 trust boundary)', () => { + it('surfaces the ProblemDetails detail when present', async () => { + expect( + await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })), + ).toBe('Niet gevonden.'); + }); + + it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => { + expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED); + }); + + it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => { + const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response; + expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED); + }); +}); diff --git a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts index 9477030..fef9891 100644 --- a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts @@ -1,4 +1,4 @@ -import { Injectable, inject } from '@angular/core'; +import { Injectable, inject, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { runSubmit } from '@shared/application/submit'; import { currentRole } from '@shared/infrastructure/role'; @@ -26,10 +26,13 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter'; * rollback go through the generated client (X-Role added by `roleInterceptor`); * `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and * `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`. + * `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under + * `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production + * build never sends it from this hand-written call either (BIO-012). */ const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`; -const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`; +export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`; @Injectable({ providedIn: 'root' }) export class OrgTemplateAdapter { @@ -76,22 +79,26 @@ export class OrgTemplateAdapter { try { res = await fetch( `${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`, - { headers: { 'X-Role': currentRole() } }, + { headers: isDevMode() ? { 'X-Role': currentRole() } : {} }, ); } catch { return err(PROEFBRIEF_FAILED); } - if (!res.ok) { - try { - return err(problemDetail(await res.json(), PROEFBRIEF_FAILED)); - } catch { - return err(PROEFBRIEF_FAILED); - } - } + if (!res.ok) return err(await proefbriefErrorMessage(res)); return ok(await res.blob()); } } +/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported + so a spec can call it directly instead of stubbing `globalThis.fetch`. */ +export async function proefbriefErrorMessage(res: Response): Promise { + try { + return problemDetail(await res.json(), PROEFBRIEF_FAILED); + } catch { + return PROEFBRIEF_FAILED; + } +} + // --- parse: wire → domain, validating at the boundary --- function parseSubOrg(dto: SubOrgSummaryDto): Result { diff --git a/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts new file mode 100644 index 0000000..c5ba514 --- /dev/null +++ b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter'; + +describe('parseRevealed (TE-002 trust boundary)', () => { + it('accepts a well-formed body', () => { + const r = parseRevealed({ bigNummer: '12345678' }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe('12345678'); + }); + + // The finding's own named case: a numeric bigNummer must be rejected, not + // coerced — this is a PII reveal, not a display formatter. + it('rejects a bigNummer sent as a number', () => { + const r = parseRevealed({ bigNummer: 42 }); + expect(r).toEqual({ ok: false, error: REVEAL_FAILED }); + }); + + it('rejects a missing bigNummer field', () => { + expect(parseRevealed({}).ok).toBe(false); + }); + + it('rejects null and non-object bodies', () => { + expect(parseRevealed(null).ok).toBe(false); + expect(parseRevealed(undefined).ok).toBe(false); + expect(parseRevealed('12345678').ok).toBe(false); + expect(parseRevealed(42).ok).toBe(false); + }); +}); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => { + const okResponse = () => + ({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response; + + beforeEach(() => setDevMode(true)); + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + vi.unstubAllGlobals(); + }); + + it('sends X-Step-Up only when the caller passes stepUp: true', async () => { + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + await new RevealBigNummerAdapter().reveal(false); + const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record; + expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined(); + + await new RevealBigNummerAdapter().reveal(true); + const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record; + expect(headersWithStepUp['X-Step-Up']).toBe('true'); + }); + + it('sends X-Role only under isDevMode()', async () => { + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + setDevMode(false); + await new RevealBigNummerAdapter().reveal(true); + const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record; + expect(prodHeaders['X-Role']).toBeUndefined(); + expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch + + setDevMode(true); + await new RevealBigNummerAdapter().reveal(true); + const devHeaders = fetchSpy.mock.calls[1][1].headers as Record; + expect(devHeaders['X-Role']).toBeDefined(); + }); +}); diff --git a/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts index 1b722e9..60d721b 100644 --- a/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts @@ -1,47 +1,62 @@ -import { Injectable } from '@angular/core'; +import { Injectable, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { currentRole } from '@shared/infrastructure/role'; import { problemDetail } from '@shared/infrastructure/api-error'; import { environment } from '@shared/environments/environment'; -const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`; +/** Exported so specs can assert against the same message id instead of retyping the + Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */ +export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`; /** * Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked; * this unmasks it, gated server-side by the reveal capability AND a step-up. The - * step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the - * user's confirm gesture, so a plain call (or a role without the capability) 403s. + * step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes + * `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only + * ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture, + * so the header now reflects that gesture instead of being a constant baked into this + * adapter (BIO-006a — a call that skips confirmation sends no step-up at all). * * Hand-written fetch (not the `ApiClient`) because the call needs a per-request header; * `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the - * same seam as `/brief/preview` and uploads — which also means `X-Role` is set here. + * same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in + * (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the + * `roleInterceptor` registration in `app.config.ts` — a production build never sends it + * from this hand-written call either (BIO-012). */ @Injectable({ providedIn: 'root' }) export class RevealBigNummerAdapter { - async reveal(): Promise> { + async reveal(stepUp: boolean): Promise> { let res: Response; try { res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, { method: 'POST', - headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }, + headers: { + ...(isDevMode() ? { 'X-Role': currentRole() } : {}), + ...(stepUp ? { 'X-Step-Up': 'true' } : {}), + }, }); } catch { return err(REVEAL_FAILED); } if (!res.ok) return err(await errorMessage(res)); - const body: unknown = await res.json().catch(() => null); - // Trust boundary: validate the shape before handing back a plain string. - if ( - typeof body === 'object' && - body !== null && - typeof (body as { bigNummer?: unknown }).bigNummer === 'string' - ) { - return ok((body as { bigNummer: string }).bigNummer); - } - return err(REVEAL_FAILED); + return parseRevealed(await res.json().catch(() => null)); } } +/** Trust boundary: validate the untrusted response shape before handing back a plain + string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */ +export function parseRevealed(body: unknown): Result { + if ( + typeof body === 'object' && + body !== null && + typeof (body as { bigNummer?: unknown }).bigNummer === 'string' + ) { + return ok((body as { bigNummer: string }).bigNummer); + } + return err(REVEAL_FAILED); +} + async function errorMessage(res: Response): Promise { try { return problemDetail(await res.json(), REVEAL_FAILED); diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md new file mode 100644 index 0000000..23c46c5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md @@ -0,0 +1,179 @@ +# RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-012, +TE-002, BIO-006(a)+(b) · `99-backlog.md` RB-11 + +## What was wrong + +**BIO-012 — the dev hatches were not actually dev-only.** The `roleInterceptor` / +`subjectInterceptor` chain is correctly registered only under `isDevMode()` +(`app.config.ts`), but three adapters bypass `HttpClient` entirely and set headers +themselves with no guard at all: + +- `reveal-bignummer.adapter.ts:26` — `'X-Role': currentRole(), 'X-Step-Up': 'true'` +- `letter-preview.adapter.ts:46` — `'X-Role': currentRole()`, plus `'X-Subject'` when present +- `org-template.adapter.ts:79` — `'X-Role': currentRole()` + +The readers underneath were ungated too: `role.ts:24` and `subject.ts:24` both read the +`?role=`/`?subject=` query param and **wrote it into `sessionStorage`** on any +navigation, in any build. For `?subject=` that value is a BSN — `subject.ts`'s own doc +comment argued at length that the BSN must never leave `SessionStore` and then routed it +through `sessionStorage` anyway. `docs/reference/roles-and-access.md:23` claimed "Both +are wired only under `isDevMode()` — they do not exist in a production build", which was +false for exactly these three call sites. + +**TE-002 — the reveal's trust boundary was not callable.** The response-shape validation +in `reveal-bignummer.adapter.ts` (the code's own comment called it a "Trust boundary") +lived inline inside `async reveal()`, after `await fetch(...)` on the global `fetch`. A +spec could not reach it without stubbing `globalThis.fetch`. The same shape recurred, +un-exported, in `letter-preview.adapter.ts`'s `errorMessage` and — contrary to the +finding's text, see "Judgement calls" below — as an inline `try/catch` (not yet a +function) in `org-template.adapter.ts`'s `proefbrief()`. + +**BIO-006(a) — the step-up stub was a constant.** `reveal-bignummer.adapter.ts` sent +`'X-Step-Up': 'true'` unconditionally, as a literal, so the backend's +`canReveal && X-Step-Up == "true"` precondition was satisfied by every call that reached +the endpoint and constrained nothing. + +**BIO-006(b) — the default role holds the PII-reveal capability, undocumented.** +`StubIdentityProvider`'s `_ =>` role-switch arm resolves any request with no (or an +unrecognised) `X-Role` header to `drafter` — the one role `Authz.CanRevealBigNummer` +grants. `roles-and-access.md` documented `drafter` as "the only role that may reveal a +BSN" without noting that it is also the fallback identity, so the least-privilege +consequence was invisible. + +## What changed + +| File | Change | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/infrastructure/role.ts` | `currentRole()` returns `'drafter'` immediately when `!isDevMode()` — no query-param read, no `sessionStorage` write | +| `libs/shared/src/infrastructure/subject.ts` | `currentSubject()` returns `undefined` immediately when `!isDevMode()` — same treatment for the BSN | +| `reveal-bignummer.adapter.ts` | `reveal(stepUp: boolean)`; `X-Role` sent only under `isDevMode()`, `X-Step-Up` sent only when `stepUp`; inline shape check moved to exported `parseRevealed(body)`; `REVEAL_FAILED` exported | +| `letter-preview.adapter.ts` | headers wrapped in `isDevMode() ? {...} : {}`; `errorMessage` exported | +| `org-template.adapter.ts` | `X-Role` sent only under `isDevMode()`; the inline `proefbrief()` error `try/catch` extracted to an exported `proefbriefErrorMessage`; `PROEFBRIEF_FAILED` exported | +| `apps/ssp/src/app/brief/application/brief.store.ts` | `revealBigNummer()` calls `this.revealAdapter.reveal(true)` — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter | +| `docs/reference/roles-and-access.md` | records which two places the `isDevMode()` gate now lives (interceptor registration **and** the reader functions) and why; adds the BIO-006(b) note that `drafter` is also the backend's fallback identity | +| 6 new/extended `*.spec.ts` | see "Verification" below | + +**The fix is two layers, not one, because the finding named both.** Gating only +`currentRole()`/`currentSubject()` would already stop the query param and the +`sessionStorage` write from working outside `isDevMode()` — the three adapters would +then send the safe default (`'X-Role': 'drafter'`, no `X-Subject`) even in production. +The adapters are _also_ wrapped in `isDevMode()` so a production request from any of the +three hand-written `fetch` calls carries no `X-Role`/`X-Subject` header at all, exactly +matching what a `HttpClient` request already does once `roleInterceptor` is not +registered — the two paths now agree on production behaviour instead of merely agreeing +on the resulting header value. + +**`X-Step-Up` is deliberately not folded into the same `isDevMode()` gate.** It is not a +`?role=`/`?subject=`-style dev override; it is the stub for a control BIO-006 says must +survive into production (in stubbed form) until a real step-up exists. Nesting it inside +`isDevMode()` would make the reveal endpoint permanently unreachable in a production +build. Instead it is gated on the `stepUp` parameter alone, which is `true` only when +`BriefStore.revealBigNummer()` — reachable only via `behandel-scherm.component.ts`'s +`onReveal()` confirm — calls it. + +## Judgement calls + +- **`org-template.adapter.ts`'s proefbrief error mapping was not "already a separate + function".** The finding's remediation text says "the proefbrief error mapping in + `org-template.adapter.ts` — both are already separate functions and only need + `export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading + the file: the other two adapters do have a standalone `errorMessage`/similar function, + but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the + `try { … } catch { … }` block, not a named function. This is a minor factual + imprecision in the finding, not a blocker — I extracted the same inline logic into a + named `proefbriefErrorMessage`, exported it, and added the same spec shape as its two + siblings. The result matches the finding's intent (a callable, spec'd trust boundary) + even though the starting shape needed one extra step the finding didn't mention. +- **The BIO-006(a) literal moved to `BriefStore.revealBigNummer()`, not to the UI.** + `behandel-scherm.component.ts`'s `onReveal()` already gates the _only_ path that can + reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own + docstring says the step-up gesture "is the UI's concern". Threading a boolean through + the component's `output()` and the page's template binding would touch three more + files for no behavioural change, since the call graph already guarantees confirmation + happened first. I moved the literal one layer up instead — out of the adapter (the + transport) and into the store (the command that is exclusively reachable via the + confirmed gesture) — which is the smallest change consistent with "not from the + adapter's literal" and with this repo's ui → application → infrastructure layering (ui + cannot call infrastructure directly to pass the flag down any other way). +- **Redundant-looking `isDevMode()` guards, kept anyway.** After gating + `currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around + the headers object is not strictly load-bearing for `X-Subject` (already `undefined` + outside dev) and only changes the _value sent_ for `X-Role` (a hardcoded `'drafter'` + vs. no header) rather than any security outcome (the backend treats both identically). + I kept the adapter-level gate anyway so the security posture is visible by inspection + at the fetch call site — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern + — rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it. +- **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via + `inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient` + purely to reach a method that doesn't use either. I judged that disproportionate to the + marginal coverage gained, since the identical `isDevMode()` pattern is already + exercised end-to-end (via `fetch` stubbing) on the other two adapters + (`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the + underlying reader-level fix is covered directly in `role.spec.ts`. Noted here as a + residual rather than silently skipped. +- **`setRole()` (the dev-switcher writer) was left ungated.** BIO-012's evidence names + the two _readers_ (`currentRole`/`currentSubject`); `setRole()` is only ever invoked + from `debug-state.component.ts`, which is itself rendered only under + `shell.component.ts`'s `@if (isDev && debugPanel)`. Gating it too would be harmless but + wasn't asked for and has no reachable production call site to protect — left alone to + keep the diff to what the finding actually named. + +## Consequences worth knowing + +- **Doc correction, same diff.** `roles-and-access.md`'s "Both are wired only under + `isDevMode()`" line is accurate as of this commit — the gate now lives in the + interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves. + Before this commit, the sentence was false for the three hand-written `fetch` paths; the + doc has been extended, not merely left as-is, to say _where_ the gate lives so a future + reader doesn't have to rediscover why the interceptor site alone wasn't sufficient. +- **CLAUDE.md needed no correction.** Its "Scenario toggle (dev-only, not wired in prod + builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three + hand-written `fetch` adapters specifically (the accompanying sentence already says they + "bypass the interceptor", which stays true — they still don't go through + `HttpClient`). Those claims were already compatible with a fix landing here; they made + no false statement that needed walking back. +- **`?subject=` is still undocumented by name in `roles-and-access.md`.** The BIO-012 + evidence and this ticket's brief both discuss it, but the doc file never named + `?subject=`/`X-Subject` before this change and still doesn't get a dedicated section — + only the new paragraph under "How to switch role" mentions it in passing. A full + `?subject=` write-up (its own e2e-only purpose, `X-Medewerker`/`X-Rollen` parallel) is + arguably worth a follow-up doc pass, but out of scope for a security-focused ticket + about production leakage. +- **Behaviour spec regenerated.** `libs/shared/docs/behaviour-spec.mdx` is generated from + the suite (`npm run gen:behaviour-spec`) and is included in this diff — the CI gate's + drift check would otherwise fail on the 6 new `describe` blocks this ticket adds. + +## Verification + +Every fix below was confirmed **red without it** by temporarily reverting the source +change (tests unchanged) and re-running the affected suite, then restoring the fix: + +- `libs/shared/src/infrastructure/role.spec.ts` — removing the `if (!isDevMode())` guard + from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured, + still written to `sessionStorage`). +- `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()` + turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written + to `sessionStorage`). +- `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting + `reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }` + turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the + `stepUp` argument; `X-Role` sent regardless of `isDevMode()`). + +New specs, all pure/exported-boundary tests per house convention (no `TestBed`, no +`globalThis.fetch` stub needed for the pure halves): + +| File | Covers | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `reveal-bignummer.adapter.spec.ts` | `parseRevealed` (incl. the finding's own `{ bigNummer: 42 }` rejection case); `reveal()`'s `X-Step-Up`/`X-Role` gating via a stubbed `fetch` | +| `letter-preview.adapter.spec.ts` | `errorMessage`; `preview()`'s header gating via a stubbed `fetch` | +| `org-template.adapter.spec.ts` (extended) | `proefbriefErrorMessage` | +| `role.spec.ts` (new) | `currentRole()` dev behaviour + the `isDevMode()`-gated production behaviour | +| `subject.spec.ts` (new) | `currentSubject()` dev behaviour + the `isDevMode()`-gated production behaviour | + +`npm run ci` (lint, typecheck, `dep:check`, `format:check`, `check:tokens`, `check:seam`, +full test suite with coverage, `ng build --localize` for both apps, `npm audit`, backend +`dotnet format` + `dotnet test`, showcase-snippets/behaviour-spec/api-client drift +checks): **green**, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and +`dotnet test` (241 passed). diff --git a/docs/reference/roles-and-access.md b/docs/reference/roles-and-access.md index 5348023..6613c2f 100644 --- a/docs/reference/roles-and-access.md +++ b/docs/reference/roles-and-access.md @@ -20,7 +20,15 @@ acting role to exercise the drafter/approver/admin flows. ## How to switch role (dev only) -Both are wired only under `isDevMode()` — they do not exist in a production build. +Both are wired only under `isDevMode()` — they do not exist in a production build. That +gate lives in two places: the `roleInterceptor` registration (`app.config.ts`) for every +`HttpClient` request, **and** inside `role.ts`'s `currentRole()` itself, because three +hand-written `fetch` calls (`reveal-bignummer.adapter.ts`, `letter-preview.adapter.ts`, +`org-template.adapter.ts`'s proefbrief) read the role directly and bypass the +interceptor entirely (RB-11/BIO-012). Before RB-11, `currentRole()` had no such gate, so +`?role=` kept working through those three calls in a production build even though this +page said otherwise; the same defect applied to `?subject=` and `subject.ts`, which is +how a BSN reached `sessionStorage` in any build. - **Dev switcher (easiest):** open the `⚙ state` panel (bottom-right in a dev build) and pick a role from the **role** dropdown. The page reloads with the new role. @@ -66,6 +74,16 @@ The admin pages appear in the header nav and in the dashboard **"Beheer"** secti matching capability is present — otherwise they are reachable only by URL (and the route guard redirects a user who lacks the capability back to `/dashboard`). +**`drafter` is also the backend's fallback identity (BIO-006).** It is not only the dev +switcher's initial selection — `StubIdentityProvider`'s role switch resolves **any** +request with no `X-Role` header at all (or an unrecognised one) to `drafter` too. Because +`drafter` is also the _only_ role that may reveal a BIG-nummer, the least-privilege +consequence is real: an unauthenticated or misconfigured caller inherits the PII-reveal +capability by default, rather than the weakest one. This is acceptable only because the +POC has no real identity or step-up yet (see the pre-production compliance checklist — +binding the reveal to an app-overlay attribute instead of the coarse role is a named, +not-yet-built item); it must not survive real identity and step-up. + ## The one principle Identity (AD/OIDC, faked here) supplies **coarse roles**; the app owns a **fine-grained capability** diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 34118ac..bbf6a26 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 406 frontend behaviours across +**is** the suite, reshaped for a business reader. 431 frontend behaviours across 8 contexts; 217 backend behaviours across 36 test classes. @@ -186,6 +186,16 @@ classes. - swaps the masked value for the revealed one on success - keeps the value masked and surfaces the error on failure +#### LetterPreviewAdapter.preview (BIO-012) + +- sends no X-Role/X-Subject headers outside isDevMode() +- sends X-Role (and X-Subject when known) under isDevMode() + +#### RevealBigNummerAdapter.reveal (BIO-006a + BIO-012) + +- sends X-Step-Up only when the caller passes stepUp: true +- sends X-Role only under isDevMode() + #### besluitGuidance - positief: counts inserted passages, no reden needed (positief has no redenen) @@ -237,6 +247,12 @@ classes. - marks added, removed, changed and unchanged by blockId - changedBlocks drops unchanged and keeps added/removed/changed +#### errorMessage (TE-002 trust boundary) + +- surfaces the ProblemDetails detail when present +- falls back to PREVIEW_FAILED when the body has no detail +- falls back to PREVIEW_FAILED when the body is not JSON + #### inferSelection - round-trips a positief selection @@ -275,6 +291,13 @@ classes. - rejects a missing count field - rejects a malformed history entry +#### parseRevealed (TE-002 trust boundary) + +- accepts a well-formed body +- rejects a bigNummer sent as a number +- rejects a missing bigNummer field +- rejects null and non-object bodies + #### passagesForBesluit - positief = shared intro + the positief passage, no negatief/reason passages @@ -283,6 +306,12 @@ classes. - preserves library order (= reading order) - never offers non-kern passages +#### proefbriefErrorMessage (TE-002 trust boundary) + +- surfaces the ProblemDetails detail when present +- falls back to PROEFBRIEF_FAILED when the body has no detail +- falls back to PROEFBRIEF_FAILED when the body is not JSON + #### redenenFor - derives reason checkboxes (code + label) from the negatief reason passages @@ -635,6 +664,29 @@ classes. - applies the pure update on dispatch - dispatch from inside an effect does not self-loop +#### currentRole (dev mechanism) + +- lists the three roles +- reads a valid ?role= from the URL and persists it for the tab +- falls back to drafter when nothing is set or the value is invalid + +#### currentRole (dev mechanism) › outside isDevMode() (production build) + +- ignores a ?role= in the URL and returns the default +- never touches sessionStorage +- ignores a role already sitting in sessionStorage from a prior dev session + +#### currentSubject (dev mechanism) + +- reads a ?subject= from the URL and persists it for the tab +- returns undefined when nothing has ever been set + +#### currentSubject (dev mechanism) › outside isDevMode() (production build) + +- ignores a ?subject= (a BSN) in the URL and returns undefined +- never writes the BSN into sessionStorage +- ignores a subject already sitting in sessionStorage from a prior dev session + #### delete flow (optimistic, revertible) - UploadDeleteRequested keeps the documentId for revert diff --git a/libs/shared/src/infrastructure/role.spec.ts b/libs/shared/src/infrastructure/role.spec.ts new file mode 100644 index 0000000..7b5cb9d --- /dev/null +++ b/libs/shared/src/infrastructure/role.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { currentRole, ROLES } from './role'; + +const setUrl = (search: string) => history.pushState({}, '', search || '/'); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('currentRole (dev mechanism)', () => { + beforeEach(() => { + sessionStorage.clear(); + setUrl('/'); + setDevMode(true); + }); + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + }); + + it('lists the three roles', () => { + expect(ROLES).toEqual(['drafter', 'approver', 'admin']); + }); + + it('reads a valid ?role= from the URL and persists it for the tab', () => { + setUrl('?role=admin'); + expect(currentRole()).toBe('admin'); + setUrl('/'); // navigation drops the query param — value stays sticky + expect(currentRole()).toBe('admin'); + }); + + it('falls back to drafter when nothing is set or the value is invalid', () => { + expect(currentRole()).toBe('drafter'); + setUrl('?role=nonsense'); + expect(currentRole()).toBe('drafter'); + }); + + // BIO-012: the three hand-written `fetch` adapters call this function directly, + // bypassing `roleInterceptor`'s own isDevMode()-gated registration — so the gate + // has to hold here, not just at the interceptor, or `?role=` keeps working in a + // production build through that side door. + describe('outside isDevMode() (production build)', () => { + beforeEach(() => setDevMode(false)); + + it('ignores a ?role= in the URL and returns the default', () => { + setUrl('?role=admin'); + expect(currentRole()).toBe('drafter'); + }); + + it('never touches sessionStorage', () => { + setUrl('?role=admin'); + currentRole(); + expect(sessionStorage.getItem('dev-role')).toBeNull(); + }); + + it('ignores a role already sitting in sessionStorage from a prior dev session', () => { + sessionStorage.setItem('dev-role', 'admin'); + expect(currentRole()).toBe('drafter'); + }); + }); +}); diff --git a/libs/shared/src/infrastructure/role.ts b/libs/shared/src/infrastructure/role.ts index 7931cae..6dd421c 100644 --- a/libs/shared/src/infrastructure/role.ts +++ b/libs/shared/src/infrastructure/role.ts @@ -1,3 +1,4 @@ +import { isDevMode } from '@angular/core'; import { Role } from '@shared/domain/role'; /** @@ -14,13 +15,22 @@ import { Role } from '@shared/domain/role'; * don't carry it), which would silently revert an admin to drafter mid-session and * 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab; * later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to - * reset. Dev-only — the interceptor itself is only wired under `isDevMode()`. + * reset. + * + * **Gated here, not only at the interceptor (BIO-012):** the `roleInterceptor` that + * consumes this for `HttpClient` traffic is only registered under `isDevMode()` + * (`app.config.ts`), but `brief`'s three hand-written `fetch` adapters call this + * function directly, bypassing that interceptor entirely. Reading `?role=` and + * writing it to `sessionStorage` is therefore gated in the function itself — outside + * `isDevMode()` the query param is never read, `sessionStorage` is never touched, and + * the fixed default (`drafter`, the least-privileged role) is returned every time. */ const STORAGE_KEY = 'dev-role'; export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin']; const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role); export function currentRole(): Role { + if (!isDevMode()) return 'drafter'; const fromUrl = new URLSearchParams(window.location.search).get('role'); if (isRole(fromUrl)) { sessionStorage.setItem(STORAGE_KEY, fromUrl); diff --git a/libs/shared/src/infrastructure/subject.spec.ts b/libs/shared/src/infrastructure/subject.spec.ts new file mode 100644 index 0000000..254ab21 --- /dev/null +++ b/libs/shared/src/infrastructure/subject.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { currentSubject } from './subject'; + +const setUrl = (search: string) => history.pushState({}, '', search || '/'); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('currentSubject (dev mechanism)', () => { + beforeEach(() => { + sessionStorage.clear(); + setUrl('/'); + setDevMode(true); + }); + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + }); + + it('reads a ?subject= from the URL and persists it for the tab', () => { + setUrl('?subject=111222333'); + expect(currentSubject()).toBe('111222333'); + setUrl('/'); // navigation drops the query param — value stays sticky + expect(currentSubject()).toBe('111222333'); + }); + + it('returns undefined when nothing has ever been set', () => { + expect(currentSubject()).toBeUndefined(); + }); + + // BIO-012: a BSN is art. 9 GDPR special-category data. Prior to this fix this + // function wrote it into sessionStorage on any navigation, in any build. + describe('outside isDevMode() (production build)', () => { + beforeEach(() => setDevMode(false)); + + it('ignores a ?subject= (a BSN) in the URL and returns undefined', () => { + setUrl('?subject=111222333'); + expect(currentSubject()).toBeUndefined(); + }); + + it('never writes the BSN into sessionStorage', () => { + setUrl('?subject=111222333'); + currentSubject(); + expect(sessionStorage.getItem('dev-subject')).toBeNull(); + }); + + it('ignores a subject already sitting in sessionStorage from a prior dev session', () => { + sessionStorage.setItem('dev-subject', '111222333'); + expect(currentSubject()).toBeUndefined(); + }); + }); +}); diff --git a/libs/shared/src/infrastructure/subject.ts b/libs/shared/src/infrastructure/subject.ts index 5556d87..ec64d7f 100644 --- a/libs/shared/src/infrastructure/subject.ts +++ b/libs/shared/src/infrastructure/subject.ts @@ -1,3 +1,5 @@ +import { isDevMode } from '@angular/core'; + /** * Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see * `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This @@ -13,6 +15,13 @@ * hand-written `fetch`, which bypasses every `HttpInterceptorFn` — the same reason * that adapter already sets `X-Role` explicitly via `currentRole()`). * + * **Gated here, not only at the interceptor (BIO-012):** `subjectInterceptor` is only + * registered under `isDevMode()`, but `letter-preview.adapter.ts` calls this function + * directly and bypasses that interceptor. The value read here is a **BSN** — a GDPR + * special-category identifier — so outside `isDevMode()` the query param is never + * read and `sessionStorage` is never written; `undefined` is returned unconditionally, + * exactly as if no `?subject=` had ever been seen. + * * `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike * `currentRole()` (a closed enum with a sensible default), there is no "default * subject" to fall back to here — omitting the header entirely lets the backend's @@ -21,6 +30,7 @@ const STORAGE_KEY = 'dev-subject'; export function currentSubject(): string | undefined { + if (!isDevMode()) return undefined; const fromUrl = new URLSearchParams(window.location.search).get('subject'); if (fromUrl) { sessionStorage.setItem(STORAGE_KEY, fromUrl); From 1fad7406068d43ca277f3cb1feda773af0920fc1 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:21:26 +0200 Subject: [PATCH 24/61] fix(api): regenerate client for RB-08's 403 response shape RB-08 changed DELETE /admin/uploads/{documentId}'s 403 mapping from .Produces to .ProducesProblem, matching CasesAdmin's actual Results.Problem() response - but the generated OpenAPI doc and typed client were never regenerated alongside it, so CI's api-client-drift check (npm run gen:api && git diff --exit-code) was left red. No frontend consumes this admin-only endpoint (confirmed by grep), so this is a pure regeneration with no consumer impact. Co-Authored-By: Claude Opus 5 --- backend/swagger.json | 9 ++++++++- libs/shared/src/infrastructure/api-client.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/swagger.json b/backend/swagger.json index af86f93..dbad12d 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -400,7 +400,14 @@ "description": "No Content" }, "403": { - "description": "Forbidden" + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { "description": "Not Found" diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 05eb07b..3d4da57 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -612,7 +612,9 @@ export class ApiClient { }); } else if (status === 403) { return response.text().then((_responseText) => { - return throwException("Forbidden", status, _responseText, _headers); + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); }); } else if (status === 404) { return response.text().then((_responseText) => { From 436e18421bc43214b605d404aab54528209efccc Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:21:34 +0200 Subject: [PATCH 25/61] fix(tooling): keep gen:api working under RB-09's Development-only stub RB-09 registers StubIdentityProvider only under IsDevelopment() and throws for Production. `dotnet swagger tofile` (npm run gen:api) loads the same Program.cs through .NET's design-time HostFactoryResolver, which executes the app's startup code (including the unconditional app.Services.GetRequiredService() the identity middleware already relied on) without ever setting ASPNETCORE_ENVIRONMENT - so it now defaults to Production and crashes (dotnet swagger tofile exited 134), breaking `npm run gen:api` entirely, including the "api-client drift" job in .github/workflows/ci.yml (a separate `- run:` step there, so this would fail real CI even though ci-local.sh's `cmd1 && cmd2` step shape happens to swallow a cmd1 failure under `set -e` and reports "passed" - a separate, pre-existing script fragility, not touched here). Real usage is unaffected: dotnet run already sets ASPNETCORE_ENVIRONMENT=Development via launchSettings.json, and docker-compose.yml/docker-compose.prod.yml already set Development/Production explicitly - gen:api's bare CLI invocation was the one place with no environment variable at all. Set it to Development inline, the same value launchSettings.json already uses for the real app. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41c60b2..3265575 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "format": "prettier --write .", "start": "ng serve ssp", "start:behandelportal": "ng serve behandelportal", - "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", + "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && ASPNETCORE_ENVIRONMENT=Development dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", "build": "ng build ssp && ng build behandelportal", "watch": "ng build ssp --watch --configuration development", "test": "ng test ssp && ng test behandelportal && ng test shared && ng test beheer", From d089151dbdb7324c6801247e564fd64eb33bed67 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:22:26 +0200 Subject: [PATCH 26/61] docs: record the gen:api regression found while verifying RB-09 Documents the dotnet swagger tofile crash discovered by actually running the affected command (not just trusting ci-local.sh's local "passed" line, which turned out to mask this exact failure via a set -e && short-circuit gotcha), its root cause, and the two follow-up fixes. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/implementation/rb-09.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md index 8e7fd85..d62af76 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md @@ -121,3 +121,48 @@ was thrown` (the stub gets registered in every environment, so the host builds f `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT` (needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter, `dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**. + +## A regression found by actually running `npm run ci`, and its fix + +`npm run gen:api` (`dotnet swagger tofile`) loads `BigRegister.Api.dll` through .NET's +design-time `HostFactoryResolver` — the same mechanism `dotnet ef` migrations use — which +executes this file's top-level statements, including the identity middleware's +pre-existing, unconditional `app.Services.GetRequiredService()`, without +ever setting `ASPNETCORE_ENVIRONMENT`. Unset defaults to `Production`. Before this ticket +that was harmless (`StubIdentityProvider` was registered unconditionally); after it, nothing +is registered for that default environment, so the tool crashed +(`dotnet swagger tofile` exited **134**, confirmed by running it directly, both before and +after the fix below). + +This is real breakage of a real workflow, not a false alarm from `ci-local.sh` — verified by +reading `.github/workflows/ci.yml`'s `api-client-drift` job: `npm run gen:api` and +`git diff --exit-code ...` are **two separate `- run:` steps** there, so the crash would fail +actual CI. `ci-local.sh` chains them as `npm run gen:api && git diff --exit-code ...` on one +line, and its first full run of `npm run ci` after this ticket's change **printed the crash +but still reported `✔ local CI passed`** — a bash `set -e` gotcha, not a false negative +specific to this fix: a failing command that is not the last element of an `&&`/`||` list is +exempt from triggering `errexit`, so `cmd1 && cmd2` silently "passes" whenever `cmd1` alone +fails. That is a pre-existing fragility in `ci-local.sh`'s three `step "X"; gen && git diff` +lines (snippets/behaviour-spec/api-client drift), unrelated to RB-09 and out of this +ticket's scope — flagged here rather than fixed, since fixing a local convenience script's +error handling is a different, standalone change. Running the failing command directly +(rather than trusting the local script) is what caught this. + +**Fix:** `package.json`'s `gen:api` script now sets `ASPNETCORE_ENVIRONMENT=Development` +on the `dotnet swagger tofile` invocation specifically — the same value +`backend/src/BigRegister.Api/Properties/launchSettings.json` already sets for `dotnet run`, +and the same value `docker-compose.yml` already sets for local Docker (confirmed by reading +both: `docker-compose.prod.yml` sets `Production` explicitly, `docker-compose.yml` sets +`Development` explicitly — the bare CLI tool invocation was the **one** place with no +environment variable set at all). Verified: `npm run gen:api` now exits 0 and produces the +regenerated `backend/swagger.json` / `libs/shared/src/infrastructure/api-client.ts` cleanly. + +This also surfaced a second, unrelated gap: RB-08 changed +`DELETE /admin/uploads/{documentId}`'s 403 mapping from `.Produces` to `.ProducesProblem` +but the generated OpenAPI doc/client were never regenerated for it (RB-08's own `npm run ci` +was run before this fix existed, so `gen:api` was already broken by the time RB-09 landed +and the drift went unnoticed). Regenerated and committed separately — see the two follow-up +commits **fix(api): regenerate client for RB-08's 403 response shape** and +**fix(tooling): keep gen:api working under RB-09's Development-only stub**. No frontend +consumes the admin-uploads-delete endpoint (confirmed by grep), so the client regeneration +has no consumer impact. From c6bc6dd4c358fcad554618b5bca2a55c06293a09 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:29:50 +0200 Subject: [PATCH 27/61] docs(backlog): RB-11 done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also records what RB-11 turned up: BIO-012 was factually wrong that the proefbrief error mapping was already a separate function (it was inlined in a try/catch), and the step-up literal is still a literal, moved one layer up to the only caller rather than eliminated — BIO-006(c) stays a production gate. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 2 +- .../refactor-backlog/_status.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index e008483..e779836 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -112,7 +112,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open | | **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open | | **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | | **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open | | **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 49bbc9c..f26225a 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,14 +14,14 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ---------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **in progress** | **RB-07 and RB-10 done.** RB-07 unblocks signing **ADR-C-009** (clause 4 now holds) and closes CQ-004's outstanding half. RB-08/RB-09 (chained — both edit `Program.cs`) and RB-11 are running as worktree agents. RB-10 landed `parseStoredSession` **twice, once per app**, deliberately — TE-001 and BL-002 both say extract-to-shared here contradicts ADR-0002; **RB-13** is what differentiates the two auth contexts. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | -| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | +| CD batch | Tickets | Status | Notes | +| -------- | ---------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **in progress** | **RB-07, RB-10 and RB-11 done.** RB-07 unblocks signing **ADR-C-009** (clause 4 now holds) and closes CQ-004's outstanding half. RB-08/RB-09 (chained — both edit `Program.cs`) still running. RB-11 corrected a factual error in BIO-012: the proefbrief error mapping in `org-template.adapter.ts` was **not** already a separate function needing only `export`; it was inlined in a `try/catch` and had to be extracted first. Its residual: the step-up is still a literal `true`, moved one layer up to `BriefStore.revealBigNummer()` (the only caller, reachable only after the confirm gesture) — BIO-006(c), binding the reveal to an app-overlay attribute, stays a production gate. RB-10 landed `parseStoredSession` **twice, once per app**, deliberately — TE-001 and BL-002 both say extract-to-shared here contradicts ADR-0002; **RB-13** is what differentiates the two auth contexts. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | +| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, From 6bdfa35abbde29897ccd5f9458d81a1142012fa6 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:31:50 +0200 Subject: [PATCH 28/61] build: stop ci-local.sh swallowing the first half of every paired step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `set -e` bash exempts every command of an AND-OR list except the last, so `npm run gen:api && git diff --exit-code ...` silently swallowed a CRASH in gen:api: the diff never ran and the script sailed on to print "local CI passed". Verified directly — `bash -c 'set -e; false && true; echo hi'` prints hi and exits 0, while `false; true` exits 1. This was not hypothetical. It hid a real gen:api crash introduced by RB-09 (dotnet swagger's design-time host defaults to Production, which that ticket made throw at startup). .github/workflows/ci.yml would have caught it, since it runs each step as its own `- run:` — so the local gate was strictly weaker than the remote one, which is the opposite of its stated purpose. Six steps were affected. The worst was `ng build ssp --localize && ng build behandelportal --localize`: a missing English translation in ssp — the exact thing the second-locale gate exists to catch — could not fail the run. The one `( cd backend && ... )` step is safe as-is and left alone: a subshell propagates its own non-zero status, so errexit sees it. Co-Authored-By: Claude Opus 5 --- scripts/ci-local.sh | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 63ecd0c..f5f1eb4 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -9,6 +9,14 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Steps chain with `;`, NOT `&&`. Under `set -e`, bash exempts every command of an +# AND-OR list except the last, so in `gen && git diff --exit-code` a CRASH in `gen` +# is silently swallowed — the diff never runs and the script sails on. That is not +# hypothetical: it hid a real `gen:api` crash (RB-09), which .github/workflows/ci.yml +# would have caught because it runs each step as its own `- run:`. With `;` errexit +# fires on the first failure. The one `( cd backend && ... )` below is safe as-is: +# a subshell propagates its own non-zero status, so errexit sees it. + step() { printf '\n\033[1;36m▶ %s\033[0m\n' "$1"; } step "lint"; npm run lint @@ -18,16 +26,16 @@ step "format:check (prettier)"; npm run format:check step "check:tokens"; npm run check:tokens step "check:seam"; npm run check:seam step "test (vitest + coverage)"; npm run test:coverage -step "build --localize (nl+en)"; npx ng build ssp --localize && npx ng build behandelportal --localize +step "build --localize (nl+en)"; npx ng build ssp --localize; npx ng build behandelportal --localize step "npm audit (shipped deps)"; npm audit --omit=dev step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration" ) -step "showcase snippets drift"; npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts -step "behaviour spec drift"; npm run gen:behaviour-spec && git diff --exit-code libs/shared/docs/behaviour-spec.mdx -step "api-client drift"; npm run gen:api && git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json +step "showcase snippets drift"; npm run gen:snippets; git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts +step "behaviour spec drift"; npm run gen:behaviour-spec; git diff --exit-code libs/shared/docs/behaviour-spec.mdx +step "api-client drift"; npm run gen:api; git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json if [[ "${1:-}" == "--full" ]]; then - step "storybook build + axe (ssp)"; npm run build-storybook && npm run test-storybook:ci - step "storybook build + axe (behandelportal)"; npm run build-storybook:behandelportal && npm run test-storybook:ci:behandelportal + step "storybook build + axe (ssp)"; npm run build-storybook; npm run test-storybook:ci + step "storybook build + axe (behandelportal)"; npm run build-storybook:behandelportal; npm run test-storybook:ci:behandelportal fi printf '\n\033[1;32m✔ local CI passed\033[0m\n' From 988612cd7e914c91b92f05bc579fe12fb99757a1 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:37:20 +0200 Subject: [PATCH 29/61] docs(backlog): CD batch 2 complete All five tickets merged and green on the fixed gate (13/13 steps, exit 0). RB-07 unblocks ADR-C-009; RB-09 unblocks RB-13 in batch 3. Adds a "Gate integrity" section recording that every earlier "ci green" in this file predates the ci-local.sh errexit fix and is weaker than it reads. Batch 1 has not been re-verified under the honest gate, and the note says so rather than leaving a reader to assume it was. Also records what batch 2 leaves open: RB-01's residual is NOT solved by RB-09 (the upload-content link is still a plain browser navigation with no credential), and a non-Development non-Production environment fails fast at GetRequiredService rather than at RB-09's deliberate throw. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 4 +-- .../refactor-backlog/_status.md | 36 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index e779836..13a945f 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -109,8 +109,8 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | | **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | | **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index f26225a..10535bc 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,16 +14,36 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ---------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **in progress** | **RB-07, RB-10 and RB-11 done.** RB-07 unblocks signing **ADR-C-009** (clause 4 now holds) and closes CQ-004's outstanding half. RB-08/RB-09 (chained — both edit `Program.cs`) still running. RB-11 corrected a factual error in BIO-012: the proefbrief error mapping in `org-template.adapter.ts` was **not** already a separate function needing only `export`; it was inlined in a `try/catch` and had to be extracted first. Its residual: the step-up is still a literal `true`, moved one layer up to `BriefStore.revealBigNummer()` (the only caller, reachable only after the confirm gesture) — BIO-006(c), binding the reveal to an app-overlay attribute, stays a production gate. RB-10 landed `parseStoredSession` **twice, once per app**, deliberately — TE-001 and BL-002 both say extract-to-shared here contradicts ADR-0002; **RB-13** is what differentiates the two auth contexts. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | -| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | +| CD batch | Tickets | Status | Notes | +| -------- | ---------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | +| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, which needs a live OpenZaak container. It fails identically on a stashed tree — it is not caused by any of these tickets. `npm run ci` does not run it. + +## Gate integrity — read before trusting any "ci green" in this file + +`scripts/ci-local.sh` chained six of its steps as `cmd1 && cmd2` under `set -e`. Bash exempts +every command of an AND-OR list **except the last** from `errexit`, so a crash in `cmd1` was +swallowed: the paired check never ran and the script still printed "local CI passed". Verified +directly — `bash -c 'set -e; false && true; echo hi'` prints `hi` and exits 0. + +This hid a **real** `gen:api` crash introduced by RB-09 (`dotnet swagger`'s design-time host +defaults to Production, which RB-09 made throw). `.github/workflows/ci.yml` runs each step as +its own `- run:` and would have caught it, so the local gate was strictly **weaker** than the +remote one — the opposite of its purpose. The worst instance was +`ng build ssp --localize && ng build behandelportal --localize`: a missing English translation +in ssp could not fail the run. + +Fixed in `build: stop ci-local.sh swallowing the first half of every paired step`. **Every +"ci green" recorded for batch 1 and for RB-07/RB-10/RB-11 predates that fix** and is therefore +weaker than it reads; the batch-2 completion run above is the first one made on the honest gate +(13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been +re-verified under the fixed gate either. From adfaa32a4210d22d7861049e05f208d845074874 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:24:16 +0200 Subject: [PATCH 30/61] ci: gate on known advisories in the .NET dependency tree (RB-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm audit --omit=dev gates the shipped frontend bundle; nothing equivalent existed for the backend, so the entire .NET dependency tree — direct and transitive — was unscanned (BIO-016 lists it first under "Absent"). The ticket's literal wording would not have worked. `dotnet list package --vulnerable` is a reporting command: it prints the advisory table and exits 0 regardless. Verified with a throwaway project on System.Net.Http 4.3.0 — severity High, GHSA-7jgj-8wvc-jh57, exit code 0. A bare `- run: dotnet list package --vulnerable` would have added a line that reads like coverage in a compliance review and enforces nothing, which is worse than leaving the gap visible. scripts/dotnet-audit.sh runs the scan and matches "has the following vulnerable packages" — the exact sentence dotnet prints per project on a hit. One script, two callers (ci.yml and ci-local.sh), so the workflow and the local gate cannot drift apart. No severity threshold and no suppression list: picking either before a real advisory forces the question would be guessing at a policy nobody needs yet. Secret scanning, BIO-016's other named absence, stays on the checklist. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 ++ .../refactor-backlog/implementation/rb-14.md | 57 +++++++++++++++++++ scripts/ci-local.sh | 1 + scripts/dotnet-audit.sh | 25 ++++++++ 4 files changed, 88 insertions(+) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md create mode 100755 scripts/dotnet-audit.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011aab4..21fedab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,11 @@ jobs: # run manually against backend/openzaak/ (see its README), never in CI. - run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration" if: needs.changes.outputs.backend == 'true' + # RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency + # tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate, + # because `dotnet list package --vulnerable` exits 0 even on a High advisory. + - run: ./scripts/dotnet-audit.sh + if: needs.changes.outputs.backend == 'true' e2e: needs: changes diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md new file mode 100644 index 0000000..89b6c71 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md @@ -0,0 +1,57 @@ +# RB-14 — scan the .NET dependency tree for known advisories + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016 · `99-backlog.md` RB-14 + +## What was wrong + +`npm audit --omit=dev` gates the shipped frontend bundle. Nothing equivalent existed for the +backend, so **the entire .NET dependency tree — direct and transitive — was unscanned.** BIO-016 +lists it first under "Absent". + +## The trap the ticket walked into + +The backlog row says: `dotnet list package --vulnerable --include-transitive` **as a failing +step**. Implemented literally, that step cannot fail. `dotnet list package --vulnerable` is a +_reporting_ command: it prints the advisory table and exits 0 regardless. + +Verified rather than assumed — a throwaway project with `System.Net.Http 4.3.0`: + +``` +Project `vulntest` has the following vulnerable packages + > System.Net.Http 4.3.0 4.3.0 High https://github.com/advisories/GHSA-7jgj-8wvc-jh57 +EXITCODE=0 +``` + +A **High** severity advisory, exit code **0**. A bare `- run: dotnet list package --vulnerable` +would have added a line to `ci.yml` that reads like coverage in a compliance review and enforces +nothing — which is worse than leaving the gap visible. + +## What changed + +| File | Change | +| -------------------------- | -------------------------------------------------------------------------- | +| `scripts/dotnet-audit.sh` | **new** — runs the scan, matches its output, exits 1 on a hit | +| `.github/workflows/ci.yml` | new backend step calling the script (same `changes.outputs.backend` guard) | +| `scripts/ci-local.sh` | new `backend dependency audit` step calling the same script | + +**One script, two callers**, rather than the same four lines pasted into a workflow and a shell +script that would then drift. The guard matches `has the following vulnerable packages` — the +exact sentence `dotnet list` prints per project on a hit; the clean case prints +`has no vulnerable packages given the current sources` instead. + +## Verification + +- Against the real solution: passes, both projects clean (exit 0). +- Against the marker sentence `dotnet list` actually emits: the guard fires and exits 1. +- The exit-0-on-High behaviour that motivates the whole script is reproduced above. + +## Residual + +`--include-transitive` means a vulnerable package pulled in by a dependency turns CI red with no +direct upgrade available. The fix in that case is a direct `PackageReference` pinning a patched +version; the script's failure message says so. There is deliberately **no severity threshold and +no suppression list** — adding one before a real advisory forces the question would be guessing +at a policy nobody has needed yet. + +Secret scanning (gitleaks/trufflehog), BIO-016's other named absence, is **not** in this ticket +and remains on the pre-production checklist. diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index f5f1eb4..d80e2ac 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -29,6 +29,7 @@ step "test (vitest + coverage)"; npm run test:coverage step "build --localize (nl+en)"; npx ng build ssp --localize; npx ng build behandelportal --localize step "npm audit (shipped deps)"; npm audit --omit=dev step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration" ) +step "backend dependency audit"; ./scripts/dotnet-audit.sh step "showcase snippets drift"; npm run gen:snippets; git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts step "behaviour spec drift"; npm run gen:behaviour-spec; git diff --exit-code libs/shared/docs/behaviour-spec.mdx step "api-client drift"; npm run gen:api; git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json diff --git a/scripts/dotnet-audit.sh b/scripts/dotnet-audit.sh new file mode 100755 index 0000000..7774c93 --- /dev/null +++ b/scripts/dotnet-audit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fail if any NuGet package (direct or transitive) has a known advisory — the .NET half of +# `npm audit --omit=dev`, which only ever covered the frontend (RB-14/BIO-016). +# +# `dotnet list package --vulnerable` is a REPORTING command: it prints the advisory table and +# still exits 0. Verified against a deliberately vulnerable project — System.Net.Http 4.3.0, +# GHSA-7jgj-8wvc-jh57, severity High, exit code 0. So `- run: dotnet list package --vulnerable` +# on its own is a gate that enforces nothing, which is worse than no gate: it reads like +# coverage in the workflow file. Matching its output is what makes it block. +# +# Shared by .github/workflows/ci.yml and scripts/ci-local.sh so the two cannot drift. +set -euo pipefail +cd "$(dirname "$0")/.." + +report=$(dotnet list backend/BigRegister.slnx package --vulnerable --include-transitive) +echo "$report" + +# The exact sentence `dotnet list` prints per project when it finds something; the clean case +# prints "has no vulnerable packages given the current sources" instead. +if grep -q "has the following vulnerable packages" <<<"$report"; then + echo + echo "✖ Vulnerable NuGet packages found (table above)." >&2 + echo " Transitive hits can be pinned with a direct PackageReference to a patched version." >&2 + exit 1 +fi From 80de2612994fb94164ab3b55510fa76c2fbd1060 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:31:13 +0200 Subject: [PATCH 31/61] refactor(shared): split runResult out of runSubmit (RB-17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runSubmit did two things at once: fold a call into a Result, and mint an Idempotency-Key for it. Five call sites are reads and had no business minting one — brief.adapter.ts:load, org-template.adapter.ts :list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's own docstring already said "Both endpoints are reads … There is no write method" while both called runSubmit; that mismatch is the sharpest evidence, and the reason the baseline's original "~13 mutations" count (derived from the helper's name, not the code) was wrong by five in one direction. Split submit.ts in place: runResult is the try/catch + problemDetail fold with no mint; runSubmit is runResult wrapping withIdempotencyKey. Zero behaviour change for the 8 real mutations (brief save/submit/approve/reject/send/reset, org-template save/publish/rollback) — same fold, same mint, same timing. The five reads now run the fold with no pendingIdempotencyKey touched. submit.spec.ts asserts the split behaviourally via currentIdempotencyKey() (two reads inside the same call agree only when a key was minted and reused) rather than mocking a relative import, matching this repo's existing vitest convention. Verified red without the fix by temporarily reintroducing the mint into runResult. ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and FeatureFlagStore.set are out of scope and untouched — the latter already calls runSubmit correctly. Co-Authored-By: Claude Opus 5 --- .../app/brief/infrastructure/brief.adapter.ts | 9 +- .../infrastructure/org-template.adapter.ts | 6 +- .../refactor-backlog/implementation/rb-17.md | 130 ++++++++++++++++++ .../src/infrastructure/stamdata.adapter.ts | 6 +- libs/shared/docs/behaviour-spec.mdx | 10 +- libs/shared/src/application/submit.spec.ts | 58 +++++++- libs/shared/src/application/submit.ts | 28 ++-- 7 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md diff --git a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts index 9bef9c2..ad3aec3 100644 --- a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { runSubmit } from '@shared/application/submit'; +import { runResult, runSubmit } from '@shared/application/submit'; import { ApiClient, BriefDecisionsDto, @@ -33,8 +33,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric * The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire * uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention); * the `parse*` boundary narrows them into the domain's proper discriminated unions - * and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails → - * error string), then parse the returned brief. + * and rejects malformed shapes. `load` (the only read) folds through `runResult`; + * every mutation folds through `runSubmit` (ProblemDetails → error string, plus the + * Idempotency-Key mint), then parses the returned brief. */ export interface BriefView { @@ -53,7 +54,7 @@ export class BriefAdapter { private client = inject(ApiClient); async load(): Promise> { - const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED); + const r = await runResult(() => this.client.briefGET(), BRIEF_LOAD_FAILED); return r.ok ? parseBriefView(r.value) : r; } diff --git a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts index fef9891..339c0c2 100644 --- a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { runSubmit } from '@shared/application/submit'; +import { runResult, runSubmit } from '@shared/application/submit'; import { currentRole } from '@shared/infrastructure/role'; import { problemDetail } from '@shared/infrastructure/api-error'; import { environment } from '@shared/environments/environment'; @@ -39,7 +39,7 @@ export class OrgTemplateAdapter { private client = inject(ApiClient); async list(): Promise> { - const r = await runSubmit(() => this.client.orgTemplates(), FAILED); + const r = await runResult(() => this.client.orgTemplates(), FAILED); if (!r.ok) return r; const out: SubOrgSummary[] = []; for (const s of r.value ?? []) { @@ -51,7 +51,7 @@ export class OrgTemplateAdapter { } async load(subOrgId: string): Promise> { - const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED); + const r = await runResult(() => this.client.orgTemplateGET(subOrgId), FAILED); return r.ok ? parseAdminView(r.value) : r; } diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md new file mode 100644 index 0000000..e38c6e5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md @@ -0,0 +1,130 @@ +# RB-17 — split `runResult` (fold) from `runSubmit` (fold + idempotency mint) + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-003 + CQ-005 · +`00-baseline.md` BL-007 (+ its §10 amendment) · `99-backlog.md` RB-17 + +## What was wrong + +`runSubmit` (`libs/shared/src/application/submit.ts`) did two things in one function: fold a +call into a `Result`, and mint an Idempotency-Key for it +(`withIdempotencyKey(crypto.randomUUID(), fn)`). Its own docstring called that mint "the one +place a logical submit's Idempotency-Key is minted". Five call sites are reads and had no +business minting one: + +| Adapter | Method | Wire call | +| ---------------------------- | -------------------- | --------------------- | +| `brief.adapter.ts:56` | `load()` | `briefGET()` | +| `org-template.adapter.ts:42` | `list()` | `orgTemplates()` | +| `org-template.adapter.ts:54` | `load(subOrgId)` | `orgTemplateGET(...)` | +| `stamdata.adapter.ts:27` | `list()` | `stamdataTables()` | +| `stamdata.adapter.ts:42` | `load(tableId, ...)` | `stamdataTable(...)` | + +That is **exactly five** — verified by grepping every `runSubmit` call site in +`libs/shared/src/application` plus the `brief` and `beheer` scopes (13 call sites total) and +reading each one's wire call for a request body / non-GET verb. The other 8 are genuine +writes (`brief.adapter.ts` save/submit/approve/reject/send/reset, +`org-template.adapter.ts` save/publish/rollback) and stay on `runSubmit` unchanged. + +`stamdata.adapter.ts`'s own module docstring already said "Both endpoints are reads … There +is no write method" while both called `runSubmit` — the sharpest instance of the mismatch, +and the one BL-007's original "~13 mutations" count mis-classified because the count was +derived from the helper's name, not from what the call actually does. + +**Not this ticket, seen while auditing:** `ApplicationsStore.cancel`, `AdminCasesStore.delete` +(RB-20) and `FeatureFlagStore.set` reach `ApiClient` more directly; the baseline's §10 +amendment flags these as writes the original "~13" count missed. Grepping confirms +`FeatureFlagStore.set` (`libs/shared/src/application/feature-flags.store.ts:63`) already +calls `runSubmit` correctly and returns a `Result` — it is not broken, just outside this +ticket's five. `ApplicationsStore.cancel`/`AdminCasesStore.delete` were not touched; they are +RB-20's. + +## What changed + +| File | Change | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/application/submit.ts` | Split: `runResult` = the try/catch + `problemDetail` fold, no mint. `runSubmit` = `runResult` wrapping `withIdempotencyKey`. | +| `libs/shared/src/application/submit.spec.ts` | Specs for both, including one that would catch a read minting a key again (see below). | +| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()` → `runResult`; docstring updated to name both halves. | +| `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts` | `list()`, `load()` → `runResult`. | +| `libs/beheer/src/infrastructure/stamdata.adapter.ts` | `list()`, `load()` → `runResult`; import trimmed to `runResult` only (no writes in this file). | +| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `runResult` describe block and the extra `runSubmit` case. | + +`runSubmit`'s new body is exactly the minimal composition the ticket asked for: + +```ts +export function runSubmit(fn: () => Promise, fallback: string): Promise> { + return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback); +} +``` + +Zero behaviour change for the 8 write call sites — same fold, same mint, same timing (the key +is still minted before `fn` runs and cleared in `withIdempotencyKey`'s `.finally`). The five +reads now run the fold with no `pendingIdempotencyKey` touched at all. + +## The spec that would catch a regression + +`currentIdempotencyKey()` (`api-client.provider.ts`) returns the pending key while one is +"in flight" for the duration of a `withIdempotencyKey` call, and a fresh `crypto.randomUUID()` +on every call otherwise. That gives a real, mock-free way to assert "no key was minted": call +`currentIdempotencyKey()` twice inside the function passed to `runResult`/`runSubmit` — two +different reads means no pending key existed (each fell back to its own random UUID); two +equal reads means one pending key was minted and reused. + +```ts +it('mints no Idempotency-Key — the read fold', async () => { + let first = '', + second = ''; + await runResult(async () => { + first = currentIdempotencyKey(); + second = currentIdempotencyKey(); + return 'x'; + }, 'fallback'); + expect(first).not.toBe(second); +}); +``` + +This mirrors the house convention of not mocking relative imports under this repo's +Angular/vitest setup (see `role.interceptor.spec.ts`'s comment) — it asserts on real, +exported behaviour instead of a spy. + +**Verified red without the fix**: temporarily changed `runResult` to also call +`withIdempotencyKey` (i.e. reintroduced the bug it exists to prevent) and reran `ng test +shared`. Result: `runResult > mints no Idempotency-Key — the read fold` failed +(`expected 'd185d827-...' not to be 'd185d827-...'`), all 137 other tests stayed green. Then +reverted the temporary edit back to the real fix (an `Edit` undo, not `git checkout`, so the +rest of the change stayed in place) and reran — 138/138 green. + +## Judgement calls + +- **Docstring on `brief.adapter.ts`** was rewritten (it previously said only "Mutations go + through `runSubmit`") to name `load`'s `runResult` path explicitly, since the file mixes + both now and a future reader needs the split spelled out at the top, not just per-method. + `org-template.adapter.ts` and `stamdata.adapter.ts`'s docstrings needed no change — neither + named `runSubmit` specifically (`stamdata.adapter.ts`'s already correctly said "no write + method"). +- **No new concept, per the ticket's "minimal" framing** — `runSubmit` stays exported with + the same signature and the same call sites for the 8 real mutations; only its body changed + to delegate. +- **Left `runResult`'s JSDoc pointing at `runSubmit`** ("never route a read through that + one") rather than duplicating the Idempotency-Key explanation, so the two docs stay + synchronized by cross-reference instead of by copy. + +## Residuals (not this ticket) + +- RB-18 (key the `IdempotencyStore` on `{SubjectId}:{idemKey}`) is sequenced behind this one + per `99-backlog.md` and is unaffected by this split beyond it now landing on a correctly + write-only call set. +- RB-20 (`ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`) is + untouched, as scoped. + +## Verification + +`npm run ci` (foreground): **green** — lint, typecheck, `dep:check` (341 + 226 modules, 0 +violations), `format:check`, `check:tokens`, `check:seam`, tests (ssp 258/258, behandelportal +31/31, shared 138/138, beheer 23/23 — 450 total), `ng build --localize` (both apps), `npm +audit` (0 vulnerabilities), backend `dotnet test` (255/255 — the known +`OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not reproduce on +this run), `gen:snippets` drift clean, `gen:behaviour-spec` drift clean once the regenerated +file is committed alongside the code (the local gate compares the working tree to `HEAD`, so +it necessarily shows a diff pre-commit — this is the documented "will conflict at merge time" +behaviour, not a defect). diff --git a/libs/beheer/src/infrastructure/stamdata.adapter.ts b/libs/beheer/src/infrastructure/stamdata.adapter.ts index a4070ab..7728ae6 100644 --- a/libs/beheer/src/infrastructure/stamdata.adapter.ts +++ b/libs/beheer/src/infrastructure/stamdata.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { runSubmit } from '@shared/application/submit'; +import { runResult } from '@shared/application/submit'; import { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client'; import { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata'; @@ -24,7 +24,7 @@ export class StamdataAdapter { /** The tables in the catalog (schema only, no rows) — for the table switcher. */ async list(): Promise> { - const r = await runSubmit(() => this.client.stamdataTables(), FAILED); + const r = await runResult(() => this.client.stamdataTables(), FAILED); if (!r.ok) return r; const out: StamTable[] = []; for (const t of r.value ?? []) { @@ -39,7 +39,7 @@ export class StamdataAdapter { valid on that date; the editor uses it for a server-side cross-check, previewing locally for instant feedback (see `activeOn`). */ async load(tableId: string, peildatum?: string): Promise> { - const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED); + const r = await runResult(() => this.client.stamdataTable(tableId, peildatum), FAILED); return r.ok ? parseStamdataTable(r.value) : r; } } diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 37de06e..14d0283 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 440 frontend behaviours across +**is** the suite, reshaped for a business reader. 445 frontend behaviours across 9 contexts; 231 backend behaviours across 39 test classes. @@ -800,11 +800,19 @@ classes. - leaves an unrelated endpoint untouched +#### runResult + +- folds a resolved call into ok(value) +- maps a ProblemDetails rejection to err(detail) +- falls back when the rejection has no detail +- mints no Idempotency-Key — the read fold + #### runSubmit - folds a resolved call into ok(value) - maps a ProblemDetails rejection to err(detail) - falls back when the rejection has no detail +- mints exactly one Idempotency-Key for the whole call — the write fold #### satisfaction helpers diff --git a/libs/shared/src/application/submit.spec.ts b/libs/shared/src/application/submit.spec.ts index 32abe86..1a770d6 100644 --- a/libs/shared/src/application/submit.spec.ts +++ b/libs/shared/src/application/submit.spec.ts @@ -1,5 +1,48 @@ import { describe, it, expect } from 'vitest'; -import { runSubmit } from './submit'; +import { runResult, runSubmit } from './submit'; +import { currentIdempotencyKey } from '@shared/infrastructure/api-client.provider'; + +// currentIdempotencyKey() returns the pending key minted by withIdempotencyKey while +// one is in flight, else a fresh random UUID on every call (see api-client.provider.ts). +// So calling it twice inside the same `fn` tells us, behaviourally, whether a key was +// minted for this call: two reads agreeing means one pending key was reused; two reads +// disagreeing means there was no pending key at all — each call fell back to its own +// random one. This is the seam RB-17 exists to keep separated, so it is asserted +// directly rather than via a mock (relative-import mocking is off-limits under this +// repo's Angular/vitest setup — see role.interceptor.spec.ts). + +describe('runResult', () => { + it('folds a resolved call into ok(value)', async () => { + const r = await runResult(async () => 'BIG-123', 'fallback'); + expect(r).toEqual({ ok: true, value: 'BIG-123' }); + }); + + it('maps a ProblemDetails rejection to err(detail)', async () => { + const r = await runResult(async () => { + throw { detail: 'Aanvraag afgewezen.' }; + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' }); + }); + + it('falls back when the rejection has no detail', async () => { + const r = await runResult(async () => { + throw new Error('network'); + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'fallback' }); + }); + + it('mints no Idempotency-Key — the read fold', async () => { + let first = ''; + let second = ''; + await runResult(async () => { + first = currentIdempotencyKey(); + second = currentIdempotencyKey(); + return 'x'; + }, 'fallback'); + // No pending key: each read falls back to its own fresh random UUID. + expect(first).not.toBe(second); + }); +}); describe('runSubmit', () => { it('folds a resolved call into ok(value)', async () => { @@ -20,4 +63,17 @@ describe('runSubmit', () => { }, 'fallback'); expect(r).toEqual({ ok: false, error: 'fallback' }); }); + + it('mints exactly one Idempotency-Key for the whole call — the write fold', async () => { + let first = ''; + let second = ''; + await runSubmit(async () => { + first = currentIdempotencyKey(); + second = currentIdempotencyKey(); + return 'x'; + }, 'fallback'); + // One pending key reused across both reads inside this logical submit. + expect(first).toBe(second); + expect(first).not.toBe(''); + }); }); diff --git a/libs/shared/src/application/submit.ts b/libs/shared/src/application/submit.ts index 4ad4acf..6d5f59c 100644 --- a/libs/shared/src/application/submit.ts +++ b/libs/shared/src/application/submit.ts @@ -3,26 +3,36 @@ import { problemDetail } from '@shared/infrastructure/api-error'; import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider'; /** - * Run a mutating API call and fold it into a `Result` — the one place the - * try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just - * its own payload mapping. The backend re-validates and returns a 422 - * ProblemDetails on rejection, surfaced here as the error string. + * Run an API call and fold it into a `Result` — the one place the try/catch + + * ProblemDetails-mapping lives, so every adapter method is just its own payload + * mapping. The backend re-validates and returns a 422 ProblemDetails on + * rejection, surfaced here as the error string. * - * Also the one place a logical submit's Idempotency-Key is minted — once per - * `runSubmit` call, not per HTTP attempt — so a retry of this same submit - * dedupes on the backend (see `withIdempotencyKey`). + * This is the **read** half: it mints no Idempotency-Key. Use it for GETs. + * `runSubmit` below wraps it for mutations — never route a read through that + * one, it mints a key for nothing. */ -export async function runSubmit( +export async function runResult( fn: () => Promise, fallback: string, ): Promise> { try { - return ok(await withIdempotencyKey(crypto.randomUUID(), fn)); + return ok(await fn()); } catch (e) { return err(problemDetail(e, fallback)); } } +/** + * `runResult` for a **mutating** call: also mints the Idempotency-Key for this + * logical submit — once per `runSubmit` call, not per HTTP attempt — so a + * retry of this same submit dedupes on the backend (see `withIdempotencyKey`). + * Reads must go through `runResult` instead, which mints nothing. + */ +export function runSubmit(fn: () => Promise, fallback: string): Promise> { + return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback); +} + // Single shared default for a failed submit; the @@id dedupes it at the // translation layer. export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`; From ee0d449510dd49d1e511e644ef8b1a83112dd042 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:36:30 +0200 Subject: [PATCH 32/61] test(backend): assert every route is authz-gated (RB-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BL-006: the backend has zero automated architecture enforcement. BIO-016 names the concrete consequence for authorization — nothing asserted the *set* of gated endpoints, so BIO-003's X-Admin gate (outside Authz) and BIO-004's two ungated endpoints were caught only by a human reading Program.cs, not by CI. Adds RouteInventoryTests: walks the real app's EndpointDataSource and asserts every mapped route either carries a .Gate("XAdmin") metadata marker (added at the 16 call sites that already call one of the five admin wrappers — OrgAdmin/StamdataAdmin/CasesAdmin/Beoordelen/ FlagsAdmin) or appears in a written-down, reasoned allow-list. Proved it's hard to fool by adding a throwaway unguarded route, watching the test go red, and reverting. The allow-list is not "public routes" as the ticket's shorthand put it — 19 of its 31 entries are ownership-scoped inline (ctx.Zorgverlener()/ ctx.Caller()) endpoints, not public ones, and labelling them public would misrepresent the exact property BIO-004 was about. Each entry instead carries its own reason. Implementation note has the full route-by-route breakdown and judgement calls. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 36 +++++ .../BigRegister.Tests/RouteInventoryTests.cs | 150 ++++++++++++++++++ .../refactor-backlog/implementation/rb-12.md | 115 ++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 backend/tests/BigRegister.Tests/RouteInventoryTests.cs create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index e630dd6..2d605d1 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -188,6 +188,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () => Results.Ok(StamdataCatalog.All.Select(t => new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList()))) +.Gate("StamdataAdmin") .WithName("stamdataTables") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); @@ -201,6 +202,7 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows(); return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows)); })) +.Gate("StamdataAdmin") .WithName("stamdataTable") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -298,6 +300,7 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => // unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07). api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () => DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())) +.Gate("CasesAdmin") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); @@ -454,6 +457,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re // --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) +.Gate("CasesAdmin") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); @@ -465,6 +469,7 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow) .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .ToList()))) +.Gate("Beoordelen") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); @@ -490,6 +495,7 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) var decisions = new BeoordelingDecisionsDto(canBesluiten); return Results.Ok(new BeoordelingViewDto(masked, docs, decisions)); })) +.Gate("Beoordelen") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); @@ -550,6 +556,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now))); })) +.Gate("Beoordelen") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) @@ -594,6 +601,7 @@ api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ct app.Logger.LogInformation("admin case delete id={Id}", id); return Results.NoContent(); })) +.Gate("CasesAdmin") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status403Forbidden); @@ -604,6 +612,7 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => Results.Ok(AuthzAuditStore.List() .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId)) .ToList()))) +.Gate("CasesAdmin") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); @@ -629,6 +638,7 @@ api.MapGet("/flags", () => api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () => FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) +.Gate("FlagsAdmin") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status403Forbidden); @@ -748,6 +758,7 @@ api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpConte var fixture = BriefSeed.NewBrief("proefbrief"); return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html"); })) +.Gate("OrgAdmin") .ExcludeFromDescription(); api.MapPost("/brief/reset", (HttpContext ctx) => @@ -766,12 +777,14 @@ api.MapPost("/brief/reset", (HttpContext ctx) => api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () => Results.Ok(OrgTemplateStore.List()))) +.Gate("OrgAdmin") .WithName("orgTemplates") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound())) +.Gate("OrgAdmin") .WithName("orgTemplateGET") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -783,6 +796,7 @@ api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRe if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest); return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound(); })) +.Gate("OrgAdmin") .WithName("orgTemplatePUT") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) @@ -797,6 +811,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont subOrgId, r.Version, r.AffectedUnsentBriefs); return r is not null ? Results.Ok(r) : Results.NotFound(); })) +.Gate("OrgAdmin") .WithName("orgTemplatePublish") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -804,6 +819,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () => OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound())) +.Gate("OrgAdmin") .WithName("orgTemplateRollback") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -988,5 +1004,25 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList(this TBuilder builder, string wrapper) + where TBuilder : IEndpointConventionBuilder + { + builder.WithMetadata(new AuthzGateMetadata(wrapper)); + return builder; + } +} + // Exposed so the integration tests can spin up the app with WebApplicationFactory. public partial class Program { } diff --git a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs new file mode 100644 index 0000000..8c284cf --- /dev/null +++ b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace BigRegister.Tests; + +/// RB-12/BIO-016 (BL-006 — "the backend has zero automated architecture enforcement"): the +/// only thing that used to keep an admin-shaped endpoint behind `Authz` was a human noticing +/// in review. BIO-003 (`X-Admin`, a second gate outside `Authz`) and BIO-004 (two endpoints +/// with no gate at all) are exactly the failure mode this test is a safety net for — and it is +/// the safety net RB-19 (a 900-line `Program.cs` reorder) leans on, so its value is entirely in +/// being hard to fool. +/// +/// Every mapped route must be accounted for exactly one of two ways: +/// - it carries an marker (.Gate("XAdmin"), added at the +/// call site in Program.cs) naming one of the five admin authz wrappers, or +/// - it is named, with a reason, in below. +/// +/// The allow-list is deliberately not "public routes" — most of its entries are NOT public. +/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN +/// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the +/// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point +/// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down") +/// generalised to every route that isn't wrapper-gated: the reviewer reads a name and a reason, +/// not silence. +public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixture +{ + // Not TestWebApplicationFactory's HttpClient — this never issues a request, only reads the + // route table off the host's DI container. Uses the shared per-class isolated db file (see + // TestWebApplicationFactory's own doc comment) rather than a bare `new + // WebApplicationFactory()`, which would share the mutable static Db.ConnectionString + // with whatever other test class last set it and race "table already exists" against it. + private TestWebApplicationFactory Factory { get; } = factory; + + private sealed record AllowListEntry(string Method, string Pattern, string Reason); + + private static readonly AllowListEntry[] AllowList = + [ + // --- Orchestrator probes: no data, no PII, run before any identity concern applies. --- + new("GET", "/health", "Liveness probe for orchestrators."), + new("GET", "/health/ready", "Readiness probe for orchestrators."), + + // --- Static/reference demo data (SeedData & friends): identical for every caller in + // this POC (one seeded citizen), nothing to scope by. --- + new("GET", "/api/v1/dashboard-view", "Static reference data (SeedData) — same for every caller in this POC."), + new("GET", "/api/v1/notes", "Static reference data (SeedData.Notes) — same for every caller in this POC."), + new("GET", "/api/v1/brp/address", "Static BRP reference fixture — same for every caller in this POC."), + new("GET", "/api/v1/duo/diplomas", "Static DUO reference fixture + manual-diploma policy — same for every caller."), + new("GET", "/api/v1/intake/policy", "Config VALUE shipped for instant FE feedback (ADR-0001); the server re-validates as authority."), + new("GET", "/api/v1/uploads/categories", "Static per-wizard category config, no PII, no per-caller distinction."), + new("GET", "/api/v1/flags", "Feature-flag catalog + state, readable by any principal by design (WP-47) — only the PUT toggle is admin-gated."), + new("GET", "/api/v1/me", "Reflects only the ACTING caller's own role-derived capabilities — no other caller's data to leak."), + + // --- Citizen-submitted writes / ownership-scoped inline (ctx.Zorgverlener()/ctx.Caller()), + // not a role-only admin wrapper because the boundary is resource ownership, not a role. --- + new("POST", "/api/v1/change-requests", "Citizen submission; Submit() records outcome + idempotency, attributed to the acting caller."), + new("POST", "/api/v1/uploads", "Upload is attributed to ctx.Zorgverlener() as owner — there is no pre-existing resource to own yet."), + new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."), + new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."), + new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."), + new("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), + new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), + new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), + new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), + new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), + new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), + + // --- External caller, not a Principal at all. --- + new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."), + + // --- Brief (letter composition): PRD-0002's own status-machine enforcement is the + // enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's + // Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers, + // not a missing one. --- + new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn)."), + new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."), + new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."), + new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."), + new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."), + new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."), + new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."), + new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); hand-written FE fetch."), + new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."), + ]; + + private static readonly HashSet KnownWrappers = + ["OrgAdmin", "StamdataAdmin", "CasesAdmin", "Beoordelen", "FlagsAdmin"]; + + private static IEnumerable RealRoutes(EndpointDataSource source) => + source.Endpoints.OfType() + // MapGroup's own catch-all/description endpoints carry no HTTP method — not a route + // an HTTP client can actually hit distinctly, so not this test's concern. + .Where(e => e.Metadata.GetMetadata() is not null); + + private static string Key(string method, string pattern) => $"{method} {pattern}"; + + [Fact] + public void Every_mapped_route_is_authz_gated_or_on_the_named_allow_list() + { + var source = Factory.Services.GetRequiredService(); + + var allowed = AllowList.ToDictionary(e => Key(e.Method, e.Pattern)); + var seenAllowListKeys = new HashSet(); + var unaccounted = new List(); + + foreach (var route in RealRoutes(source)) + { + var pattern = route.RoutePattern.RawText!; + foreach (var method in route.Metadata.GetMetadata()!.HttpMethods) + { + var key = Key(method, pattern); + var gated = route.Metadata.GetMetadata() is { } gate && KnownWrappers.Contains(gate.Wrapper); + var listed = allowed.ContainsKey(key); + if (listed) seenAllowListKeys.Add(key); + if (!gated && !listed) unaccounted.Add(key); + } + } + + Assert.True(unaccounted.Count == 0, + "Route(s) with no authz gate and no allow-list entry — either add `.Gate(\"XAdmin\")` " + + "at the mapping site, or add a named, reasoned entry to RouteInventoryTests.AllowList:\n" + + string.Join("\n", unaccounted)); + + // The allow-list is a decision log, not a wishlist — an entry for a route that no longer + // exists (renamed, removed) is exactly the kind of drift this test exists to catch. + var stale = allowed.Keys.Except(seenAllowListKeys).ToList(); + Assert.True(stale.Count == 0, + "Allow-list entry with no matching live route (stale — the route was renamed or " + + "removed):\n" + string.Join("\n", stale)); + } + + /// Every `.Gate(...)` call must name one of the five known wrappers — a typo here would + /// silently fall back to "unaccounted for" above, but pinning it down explicitly gives a + /// clearer failure than the generic route-mismatch message. + [Fact] + public void Every_gate_marker_names_a_known_admin_wrapper() + { + var source = Factory.Services.GetRequiredService(); + + var unknown = RealRoutes(source) + .Select(r => r.Metadata.GetMetadata()) + .Where(g => g is not null) + .Select(g => g!.Wrapper) + .Where(w => !KnownWrappers.Contains(w)) + .Distinct() + .ToList(); + + Assert.True(unknown.Count == 0, "Unknown wrapper name(s) in a .Gate(...) call: " + string.Join(", ", unknown)); + } +} diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md new file mode 100644 index 0000000..7b4d06b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md @@ -0,0 +1,115 @@ +# RB-12 — a route-table test: every route hits an authz wrapper or an explicit allow-list + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016, `00-baseline.md` BL-006 · `99-backlog.md` RB-12 + +## What was wrong + +BL-006, verbatim: "the backend has zero automated architecture enforcement … `Domain/` +purity currently holds by convention." BIO-016 names the specific consequence for +authorization: nothing asserted the **set** of gated endpoints, so an endpoint added +without a gate (BIO-003's `X-Admin` gate outside `Authz`, BIO-004's two endpoints with +no gate at all) failed no test. Both were caught by a human reading `Program.cs`, not by +CI. + +## What changed + +| File | Change | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` — 16 endpoint mappings | each chains a new `.Gate("XAdmin")` call, naming the admin wrapper (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`) already used inside its handler | +| `Program.cs` — new types, end of file | `public sealed record AuthzGateMetadata(string Wrapper)` + a `Gate(...)` extension method on `IEndpointConventionBuilder` that attaches it via `.WithMetadata(...)` | +| `tests/BigRegister.Tests/RouteInventoryTests.cs` | **new** — walks the real app's `EndpointDataSource`, asserts every route carries either an `AuthzGateMetadata` naming a known wrapper, or an entry in a written-down allow-list; a second test asserts every `.Gate(...)` name is one of the five known wrappers | + +## Design: metadata at mapping time, not reflection over the compiled lambda + +The ticket left the detection mechanism open, noting the wrappers are local functions +in `Program.cs`. Reflecting over a compiled minimal-API lambda to determine which local +function its closure calls is fragile-to-impossible (the call is inside IL a test would +have to disassemble, and a local function's identity isn't easily recoverable from the +delegate's `MethodInfo`). Endpoint **metadata**, attached at the same call site where the +route is mapped, is exactly what `EndpointDataSource` hands back to a test host and +doesn't depend on inspecting compiled code at all — so a `.Gate("XAdmin")` extension +method was added and chained onto each of the 16 mappings that call one of the five +wrappers. + +This is a **declaration**, not a **derivation**: the test does not verify that +`.Gate("CasesAdmin")` and an actual `CasesAdmin(ctx, …)` call inside the handler agree — +it only verifies that a marker is present. A handler that swapped its `CasesAdmin(ctx, +…)` call for a no-op without updating `.Gate(...)` would go undetected here. What _is_ +caught, reliably, is the actual BIO-003/BIO-004 failure mode: a new endpoint mapped with +**no** marker and **no** allow-list entry — verified below by adding one and watching the +test go red. + +## Judgement call: the allow-list is not "public routes" + +The ticket's literal framing — every route "goes through one of the authz wrappers … +or appears in an explicit, named allow-list of deliberately-public routes" — doesn't fit +this codebase as read. Only 16 of the app's 47 routes go through one of the five admin +wrappers. The other 31 are not uniformly public: + +- **10 are genuinely public** — orchestrator health probes and static/reference demo + data (`SeedData`, the DUO/BRP fixtures, the scholing-threshold config value, the + feature-flag catalog, `/me`'s reflection of the caller's own capabilities) that reads + the same for every caller in this one-seeded-citizen POC. +- **19 are ownership-scoped inline**, not public and not wrapper-gated: `GET +/applications/{id}`, the upload endpoints, every brief transition, etc. all key off + `ctx.Zorgverlener().Bsn` / `ctx.Caller()` — an authenticated citizen (or, for the + uploads-content endpoint, a behandelaar) reading or writing only their own resource. + Calling these "public" in an allow-list would misrepresent exactly the property + BIO-004 was about — object-level authorization existing at all. +- **1 (`POST /zgw/notificaties`) uses a different mechanism entirely** — a fixed-time + shared-secret comparison for a non-Principal external caller (OpenZaak's + notifications), audited the same way but never going through `Authz`. +- **1 (`POST /brief/reset`) is deliberately, literally unguarded** — the endpoint's own + pre-existing comment says so ("No guards — showcase affordance only"). + +The allow-list (`RouteInventoryTests.AllowList`) keeps all 31 as one array for the test's +sake, but every entry carries its own reason string rather than a blanket "public" label — +preserving BIO-016's actual intent ("makes 'this endpoint is public' a decision someone +wrote down rather than an omission") generalised to "this endpoint's access boundary is +_X_, deliberately," which is true of all 31 and false of "public" for 20 of them. This is +recorded here rather than silently reinterpreted, per this task's brief: implementing the +literal "public" framing would have been actively misleading about which endpoints have no +access control at all. + +## Other judgement calls + +- **`AuthzGateMetadata` and its extension method are `public`, not `internal`.** The test + project has no `InternalsVisibleTo` wired up for `BigRegister.Api` (checked — none + exists anywhere in `backend/`), and adding one for a single marker type was more + machinery than the alternative. Both types carry a comment stating why. +- **A second test (`Every_gate_marker_names_a_known_admin_wrapper`) guards against a typo + in a `.Gate(...)` call.** Without it, a call like `.Gate("CasesAdmn")` would just fall + through to "unaccounted for" in the main test with a less specific failure message — + fine, but a dedicated assertion names the actual mistake. +- **The main test also asserts the reverse direction: no stale allow-list entries.** An + allow-list entry for a route that was renamed or removed is exactly the kind of drift + a "decision someone wrote down" ledger needs to catch, not just silently keep. Verified + this fires: temporarily added one extra `AllowList` entry for a route that doesn't + exist (via Edit, not committed) — every real route was still covered, so only the + stale-entry assertion tripped, naming exactly that bogus entry. Reverted the same way. +- **`RouteInventoryTests` uses the house `TestWebApplicationFactory` + `IClassFixture` + idiom**, not a bare `new WebApplicationFactory()` per test. The first draft did + the latter and immediately hit `SQLite Error 1: 'table "Applications" already exists'` + — `Db.ConnectionString` (`Data/Db.cs`) is a shared mutable **static** field, and a bare + factory doesn't override `ConnectionStrings:AppDb`, so two such factories in the same + class end up pointed at the same file, and the second one's `Migrate()` collides with + the first's already-created tables (the first factory's default `Dispose()` doesn't + delete that file — only `TestWebApplicationFactory`'s override does, to its own + per-instance temp path). This is exactly the hazard `TestWebApplicationFactory`'s own + doc comment describes; switching to it (as every other endpoint-test class in this + suite already does) fixed it outright — no product code involved, purely a test-fixture + choice. + +## Verification + +- **Proved the test is hard to fool**, per the ticket's explicit ask: added a throwaway + `api.MapDelete("/rb12-throwaway-unguarded/{id}", …)` with no `.Gate(...)` and no + allow-list entry (via Edit, not `git checkout`) — `Every_mapped_route_is_authz_gated_or_ +on_the_named_allow_list` failed red, naming exactly that route. Reverted the same way; + re-ran green. Repeated once more after switching to `TestWebApplicationFactory` to + confirm the fixture change didn't weaken the check — same red, same green. +- `dotnet build` (both `src/BigRegister.Api` and `tests/BigRegister.Tests`): clean, 0 + warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **257 passed, 0 failed** (255 + pre-existing + 2 new). From a93218e8ac1a2c5893c0a07d7a31b4833a0df395 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:40:23 +0200 Subject: [PATCH 33/61] fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so the full OpenAPI document (every route + request/response shape) and SwaggerUI's interactive "Try it out" were reachable in every environment, including a real deployment. Both now run only inside `if (app.Environment.IsDevelopment())`. AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI registration only, no HTTP surface by itself. RB-09 already made a non-Development environment throw at startup, which broke `npm run gen:api` until that script pinned ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This change sits in the same pipeline, so it was verified rather than assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight out of DI and never sends an HTTP request through this middleware, so gating it can't affect that tool by construction. Ran the real `npm run gen:api` to confirm — exit 0, regenerated files byte-identical to what's committed. New tests exercise the gate on a third ("Staging") environment name, not Production — Production already can't boot at all post-RB-09, so a Production-environment test would only re-prove that unrelated startup throw, not this gate. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 17 +++- .../BigRegister.Tests/SwaggerGateTests.cs | 48 ++++++++++ .../refactor-backlog/implementation/rb-15.md | 93 +++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/SwaggerGateTests.cs create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 2d605d1..208be87 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -142,8 +142,21 @@ app.Use(async (ctx, next) => await next(ctx); }); -app.UseSwagger(); -app.UseSwaggerUI(); +// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to +// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it +// out") let a caller fire requests straight from the browser. Development-only, like the +// dev-role/scenario-toggle hatches this POC already keeps out of production builds +// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs +// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI +// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it +// never sends an HTTP request through this pipeline, so it never touches this middleware at +// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's +// note that this exact file has already broken that tool once. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} app.UseCors(SpaCors); // Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. diff --git a/backend/tests/BigRegister.Tests/SwaggerGateTests.cs b/backend/tests/BigRegister.Tests/SwaggerGateTests.cs new file mode 100644 index 0000000..90fb36a --- /dev/null +++ b/backend/tests/BigRegister.Tests/SwaggerGateTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace BigRegister.Tests; + +/// RB-15/BIO-015: `app.UseSwagger()`/`app.UseSwaggerUI()` used to run unconditionally — the +/// OpenAPI document (every route + request/response shape) and SwaggerUI's "Try it out" were +/// reachable in every environment, including a real deployment. Both are now gated behind +/// `app.Environment.IsDevelopment()`. +public class SwaggerGateTests(TestWebApplicationFactory factory) : IClassFixture +{ + [Fact] + public async Task Swagger_document_is_served_in_development() + { + // The default test environment (WebApplicationFactory defaults to "Development" when + // nothing overrides it — same fact RB-09's implementation note relies on) — this is the + // regression guard that the gate didn't also break the documented `npm run gen:api` / + // local-dev-Swagger-UI experience. + var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + /// Production cannot boot at all today (RB-09: no real IIdentityProvider exists yet), which + /// is a *stronger* guarantee than "no Swagger in Production" — but it also means a plain + /// `UseEnvironment("Production")` host never reaches this middleware to prove the gate + /// itself works, only that the whole app refuses to start. This uses a third environment + /// name (neither "Development" nor "Production") with a test-supplied `IIdentityProvider` — + /// the one thing Program.cs doesn't register outside those two branches — so the host + /// actually boots and this test exercises the real gate, not RB-09's unrelated startup throw. + [Fact] + public async Task Swagger_document_is_not_served_outside_development() + { + // Built on top of the shared `factory` fixture (via WithWebHostBuilder), not a bare `new + // WebApplicationFactory()` — that keeps this host on the fixture's own per-class + // isolated AppDb temp path (see TestWebApplicationFactory's doc comment; RB-12's + // implementation note records the "table already exists" collision a bare factory hits + // by sharing the mutable static Db.ConnectionString instead). + using var staging = factory.WithWebHostBuilder(builder => builder + .UseEnvironment("Staging") + .ConfigureTestServices(services => services.AddSingleton())); + + var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + } +} diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md new file mode 100644 index 0000000..a555e25 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md @@ -0,0 +1,93 @@ +# RB-15 — Swagger and the OpenAPI document behind `IsDevelopment()` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-015 · `99-backlog.md` RB-15 + +## What was wrong + +`Program.cs:145-146` (pre-change) ran `app.UseSwagger(); app.UseSwaggerUI();` +unconditionally — the full OpenAPI document (every route, every request/response shape) +and SwaggerUI's interactive "Try it out" were reachable in every environment, including a +real deployment, with no `app.Environment.IsDevelopment()` guard. BIO-015's own evidence +notes this is one line of genuine attack-surface reduction with no POC cost. + +## What changed + +| File | Change | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `Program.cs` | `app.UseSwagger(); app.UseSwaggerUI();` now run only inside `if (app.Environment.IsDevelopment()) { … }` | +| `tests/BigRegister.Tests/SwaggerGateTests.cs` | **new** — asserts `/swagger/v1/swagger.json` is served in Development and 404s outside it | + +`builder.Services.AddSwaggerGen(...)` and `AddEndpointsApiExplorer()` were left +unconditional — they only register DI services (the swagger-generation machinery), +expose nothing over HTTP by themselves, and (see below) are exactly what `npm run +gen:api` depends on staying registered in every environment it might run against. + +## The hazard, checked rather than assumed + +RB-09 made a non-Development environment throw during `builder.Build()` (no +`IIdentityProvider` registered for a bare/unset environment, which defaults to +Production), which crashed `dotnet swagger tofile` until `package.json`'s `gen:api` +script was pinned to `ASPNETCORE_ENVIRONMENT=Development` for that one invocation +(`docs/.../implementation/rb-09.md`). This ticket's change sits in exactly the same +pipeline, so it needed the same empirical check, not an assumption. + +**Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:** +`dotnet swagger tofile` (`Swashbuckle.AspNetCore.Cli`) loads the built DLL through +.NET's design-time `HostFactoryResolver`, builds the host, and then resolves +`ISwaggerProvider` **directly out of the DI container** to produce `swagger.json` — it +never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's +`if (app.Environment.IsDevelopment())` guard lives in. Gating `UseSwagger()`/ +`UseSwaggerUI()` therefore cannot affect it, in any environment, by construction — those +are pipeline middleware; the CLI tool bypasses the pipeline entirely. + +**Verified, not assumed:** ran `npm run gen:api` for real. It exited 0, printed "Swagger +JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag +client. `git status`/`git diff` on both `backend/swagger.json` and +`libs/shared/src/infrastructure/api-client.ts` showed **zero changes** — the regenerated +files are byte-identical to what's already committed, confirming the gate has no effect +on the generated contract at all. + +## Judgement calls + +- **The guard wraps both `UseSwagger()` and `UseSwaggerUI()` together**, not just one — + the ticket's own wording lists both, and gating only the document while leaving the UI + reachable (or vice versa) would be a strange half-measure: SwaggerUI without the + document 404s on load anyway, and the document without the UI still leaks the same + route/shape enumeration BIO-015 is about. +- **`AddSwaggerGen`/`AddEndpointsApiExplorer` were left unconditional.** They're + DI-registration-time calls with no HTTP surface, and — now confirmed rather than + assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever + environment it runs the host under (pinned to Development by `gen:api`'s own script, + but nothing stops a future non-Development invocation), so conditioning those + registrations on `IsDevelopment()` would risk breaking the CLI tool for no + attack-surface benefit — nobody can reach a DI-registered-but-never-routed service + over HTTP. +- **New tests build the "non-Development" case on a third environment name + ("Staging"), not `"Production"`.** RB-09 already made Production fail at startup + entirely (no real `IIdentityProvider` exists yet) — a stronger guarantee than "no + Swagger in Production," but one that means a `UseEnvironment("Production")` host + never reaches this middleware to prove the gate itself works; it only proves RB-09's + unrelated startup throw, which already has its own test. A `"Staging"` environment + satisfies neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no + `IIdentityProvider` for it — the test supplies one via `ConfigureTestServices` + (`StubIdentityProvider`, the same one Development uses) so the host actually boots, + and the test exercises this ticket's real gate rather than a different ticket's. +- **The Staging host is built via `factory.WithWebHostBuilder(...)`** (layering on the + shared `TestWebApplicationFactory` fixture), not a bare `new +WebApplicationFactory()` — RB-12's implementation note already records the + "table already exists" collision a bare factory hits by sharing the mutable static + `Db.ConnectionString` instead of the fixture's own per-class isolated temp path; + layering avoids repeating that mistake here. + +## Verification + +- **Reverted the guard only** (unwrapped `UseSwagger()`/`UseSwaggerUI()` back to + unconditional, via Edit, tests left in place) and ran `SwaggerGateTests`: + `Swagger_document_is_not_served_outside_development` failed red (`Expected: NotFound, +Actual: OK`). Restored the fix (via Edit) and re-ran: both green. +- **`npm run gen:api`, run for real**: exit 0; `backend/swagger.json` and + `libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean on + both) — see "The hazard" above. +- `dotnet build` (both projects): clean, 0 warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **259 passed, 0 failed** (257 + 2 new). From 2627799284a83cf254a31201351c6227c25f90d0 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:53:29 +0200 Subject: [PATCH 34/61] fix(backend): 400 instead of 500 on an unparseable peildatum (RB-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BIO-019: GET /stamdata/{table}?peildatum= called DateOnly.Parse directly, which throws FormatException on anything unparseable — an unhandled 500 (leaking exception detail in Development) instead of the 400-with-problem-details every other bad-input check in this file returns. §3c named backend/Stamdata's 71.7% branch coverage (BL-005) as the weak spot this bug lived in. Switched to DateOnly.TryParse; an unparseable value now returns Results.Problem(detail: ..., statusCode: 400), matching the shape the upload/change-request endpoints already use. Endpoint doc gained .ProducesProblem(400), so the OpenAPI doc + generated client were regenerated and committed in this same diff (RB-09's note records a prior incident where a response-shape change shipped without this and the drift went unnoticed). No FE change needed: libs/beheer's stamdata adapter already funnels every call through runSubmit, which folds any thrown ApiException (now including this 400) into a generic Result error — ADR-0001's "the FE renders the decision" already covers "the server rejected this input". Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 14 +++- backend/swagger.json | 10 +++ .../StamdataEndpointTests.cs | 10 +++ .../refactor-backlog/implementation/rb-16.md | 82 +++++++++++++++++++ libs/shared/src/infrastructure/api-client.ts | 6 ++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 208be87..c80d2fe 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -212,12 +212,24 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct { var t = StamdataCatalog.Find(table); if (t is null) return Results.NotFound(); - var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows(); + DateOnly? peildatumWaarde = null; + // RB-16/BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as + // an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an + // admin-gated but still user-supplied string needs the same 400 path every other bad-input + // check in this file uses, not a crash. + if (peildatum is { Length: > 0 } p) + { + if (!DateOnly.TryParse(p, out var parsed)) + return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest); + peildatumWaarde = parsed; + } + var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows(); return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows)); })) .Gate("StamdataAdmin") .WithName("stamdataTable") .Produces() +.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); diff --git a/backend/swagger.json b/backend/swagger.json index dbad12d..1a0dad1 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -194,6 +194,16 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "403": { "description": "Forbidden", "content": { diff --git a/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs b/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs index ac197ae..a676cc1 100644 --- a/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs @@ -59,6 +59,16 @@ public class StamdataEndpointTests(TestWebApplicationFactory factory) : IClassFi Assert.Empty(table.Rows); } + /// RB-16/BIO-019: DateOnly.Parse used to throw FormatException on unparseable input, + /// surfacing as an unhandled 500 instead of the 400-with-problem-details every other + /// bad-input check in this endpoint file returns. + [Fact] + public async Task Unparseable_peildatum_is_400_not_500() + { + var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions?peildatum=not-a-date", role: "admin")); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + [Fact] public async Task Unknown_table_is_404() { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md new file mode 100644 index 0000000..557c4ff --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md @@ -0,0 +1,82 @@ +# RB-16 — `DateOnly.TryParse` on `?peildatum=` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-019 · `99-backlog.md` RB-16 + +## What was wrong + +`Program.cs:215` (pre-change) — +`var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`. +`DateOnly.Parse` throws `FormatException` on anything unparseable; there was no +`TryParse`, no 400 path, and `.Produces` on the endpoint declared only 200/403/404 — so +an unparseable `?peildatum=` value 500'd, and in Development the exception detail was +returned to the caller. §3c's baseline named `backend/Stamdata` 96.8% line but **71.7% +branch** (BL-005) — this was one of the unentered branches. + +## What changed + +| File | Change | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` — `GET /stamdata/{table}` | `DateOnly.Parse` replaced with `DateOnly.TryParse`; an unparseable value now returns `Results.Problem(detail: …, statusCode: 400)` instead of throwing; endpoint doc gained `.ProducesProblem(StatusCodes.Status400BadRequest)` | +| `tests/BigRegister.Tests/StamdataEndpointTests.cs` | **new** `Unparseable_peildatum_is_400_not_500` | +| `backend/swagger.json`, `libs/shared/src/infrastructure/api-client.ts` | regenerated (`npm run gen:api`) — the new 400 response is now part of the documented contract | + +## What the fix looks like + +```csharp +DateOnly? peildatumWaarde = null; +if (peildatum is { Length: > 0 } p) +{ + if (!DateOnly.TryParse(p, out var parsed)) + return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest); + peildatumWaarde = parsed; +} +var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows(); +``` + +Matches the shape every other bad-input check in this file already uses (e.g. the +upload endpoint's `Results.Problem(detail: …, statusCode: 400)` for a malformed +multipart request) — a `Results.Problem` with a Dutch detail message, not a bespoke +response shape. + +## Judgement calls + +- **No FE change needed, and none made.** `libs/beheer/src/infrastructure/ +stamdata.adapter.ts`'s `load()` already routes every call through `runSubmit` + (`libs/shared/src/application/submit.ts`), which try/catches any thrown + `ApiException` — including the client's new 400 branch — into a generic `Result` + error string via `problemDetail`. There is no status-code-specific branching to + extend; ADR-0001's "the FE renders the decision, it does not recompute the rule" + already covers "the server rejected this input" as a case the generic error path + handles, same as the existing 403. +- **Regenerated the API client and committed it in this ticket's diff**, rather than + leaving it to drift. RB-09's implementation note records a real prior incident where + a response-shape change (RB-08's 403 → `ProducesProblem`) landed without a + regeneration and the drift went unnoticed until the next ticket's `gen:api` run. This + ticket's `.ProducesProblem(400)` is exactly that same category of change, so + `npm run gen:api` was run immediately as part of implementing it, not deferred. +- **The Dutch detail message follows the file's own convention** (`$"Ongeldige +peildatum '{p}'."`) rather than English — every other `Results.Problem(detail: …)` + call in `Program.cs` (change-request rejection, upload validation, submit rejection) + is Dutch; this is server-internal wire text, not `$localize`-wrapped UI copy (the FE + never renders it verbatim — CLAUDE.md's `$localize` rule is about user-facing copy + the FE owns, not backend `ProblemDetails.detail` strings), so no locale entry was + needed. + +## Verification + +- **Reverted the fix only** (`DateOnly.Parse` restored, ternary un-nested, via Edit — + test left in place) and ran `StamdataEndpointTests`: + `Unparseable_peildatum_is_400_not_500` failed red (`Expected: BadRequest, Actual: +InternalServerError` — confirming the endpoint really did 500, not some other status). + Restored the fix (via Edit) and re-ran: all 6 tests in the class green. +- `dotnet build`: clean, 0 warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **260 passed, 0 failed** (259 + 1 + new). +- `npm run gen:api`: exit 0; `backend/swagger.json` gained the 400 response shape for + this one endpoint; `libs/shared/src/infrastructure/api-client.ts` gained the + matching `status === 400` branch. Both regenerated files committed alongside the + code change. +- `npm test` (all four Vitest projects — ssp/behandelportal/shared/beheer): **445 + passed, 0 failed**, confirming the regenerated client doesn't break any existing FE + consumer of `stamdataTable(...)`. diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 3d4da57..04e7639 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -341,6 +341,12 @@ export class ApiClient { result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as StamdataTableDto; return result200; }); + } else if (status === 400) { + return response.text().then((_responseText) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Bad Request", status, _responseText, _headers, result400); + }); } else if (status === 403) { return response.text().then((_responseText) => { let result403: any = null; From b617d2f09ad057d6496dc87873db2a64d9a3f121 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:53:52 +0200 Subject: [PATCH 35/61] docs: regenerate behaviour-spec for RB-12/RB-15/RB-16 New backend test classes (RouteInventoryTests, SwaggerGateTests) plus one added case to StamdataEndpointTests. Co-Authored-By: Claude Opus 5 --- libs/shared/docs/behaviour-spec.mdx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 37de06e..6fb2678 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 440 frontend behaviours across -9 contexts; 231 backend behaviours across 39 test +9 contexts; 236 backend behaviours across 41 test classes. ## Frontend (by context) @@ -1136,12 +1136,18 @@ classes. - A closed mapping is absent from its geldigTot onwards - ByProgram is evaluated per call not captured at type load +### RouteInventoryTests + +- Every mapped route is authz gated or on the named allow list +- Every gate marker names a known admin wrapper + ### StamdataEndpointTests - Stamdata reads are admin only - Table list exposes the reflected schema - Table returns all rows without a peildatum - Peildatum before the seed windows hides every row +- Unparseable peildatum is 400 not 500 - Unknown table is 404 ### StamdataValidationTests @@ -1172,6 +1178,11 @@ classes. - Worked hours are accepted - Phone change is validated +### SwaggerGateTests + +- Swagger document is served in development +- Swagger document is not served outside development + ### UploadAccessTests - The owner can read the bytes From f19185ed81d1d7a26d11e9c55f049595961ae8a3 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:54:26 +0200 Subject: [PATCH 36/61] refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13) ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal variants with different login flows. Actor #2 (apps/behandelportal) landed in WP-61/67 and the union never followed: grep -rn "Principal" returned one hit, a comment. Both apps' auth/domain/session.ts stayed byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar carried a BSN and logged into the backoffice as a citizen, by DigiD, under a fabricated citizen's name (login.page.ts). The divergence ADR-0002 predicted took an orthogonal side door instead (medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never touches SessionStore) -- which is why ssp/auth and bhp/auth still measured as 100%/84% duplicated after ADR-C-006 shared the route guards. RB-09 (landed the day before) made the backend's IIdentityProvider able to say "no identity" and fail closed; this ticket is its named FE half. Each app's auth/domain/session.ts becomes principal.ts, holding the one Principal variant that app actually has an actor for: ssp keeps `{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId, naam, rollen }` (no BSN to strip -- G2 shape validation only). A new MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving the existing MEDEWERKER_ID/currentRollen() dev stand-in into a Principal; because there is no credential to check, it returns Principal directly rather than a Result whose error variant could never occur. login.page.ts stops being a BSN/wachtwoord form -- one explainer line and an "Inloggen met SSO" button -- and its dead error-handling branch goes with the Result wrapper that justified it. Measured with tools/baseline-scan.mjs --dup: auth duplication drops from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the backlog's <40 target. What remains is the ADR-C-006 route-guard re-export (deliberately identical), generic test/story-file boilerplate, and one shared fragment of the root-singleton-store idiom -- not re-converged identity or login-flow logic. SS3's prediction that the two actors would authenticate differently enough to justify not sharing auth has now actually been tested, not just asserted, and held. Also: renamed Session.bsn to Principal.bsn in two doc comments (libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts) that cited the old type name; regenerated libs/shared/docs/behaviour-spec.mdx (generated file, per its own banner); recorded the resolution in ADR-0002 as a new amendment, replacing its "Known debt" section. Co-Authored-By: Claude Opus 5 --- .../src/app/auth/application/session.store.ts | 55 +++--- .../src/app/auth/domain/principal.spec.ts | 84 ++++++++ .../src/app/auth/domain/principal.ts | 71 +++++++ .../src/app/auth/domain/session.spec.ts | 33 ---- .../src/app/auth/domain/session.ts | 27 --- .../app/auth/infrastructure/digid.adapter.ts | 16 -- .../auth/infrastructure/medewerker.adapter.ts | 32 +++ .../ui/login-form/login-form.component.ts | 57 ++---- .../src/app/auth/ui/login.page.ts | 27 ++- .../behandelportal/src/locale/messages.en.xlf | 54 ++--- .../src/app/auth/application/session.store.ts | 42 ++-- .../ssp/src/app/auth/domain/principal.spec.ts | 33 ++++ apps/ssp/src/app/auth/domain/principal.ts | 39 ++++ apps/ssp/src/app/auth/domain/session.spec.ts | 33 ---- apps/ssp/src/app/auth/domain/session.ts | 27 --- .../app/auth/infrastructure/digid.adapter.ts | 6 +- .../debug-state/debug-state.component.ts | 6 +- .../refactor-backlog/implementation/rb-13.md | 186 ++++++++++++++++++ .../0002-user-groups-and-bounded-contexts.md | 52 ++--- libs/shared/docs/behaviour-spec.mdx | 23 ++- .../src/infrastructure/subject.interceptor.ts | 2 +- libs/shared/src/infrastructure/subject.ts | 2 +- 22 files changed, 588 insertions(+), 319 deletions(-) create mode 100644 apps/behandelportal/src/app/auth/domain/principal.spec.ts create mode 100644 apps/behandelportal/src/app/auth/domain/principal.ts delete mode 100644 apps/behandelportal/src/app/auth/domain/session.spec.ts delete mode 100644 apps/behandelportal/src/app/auth/domain/session.ts delete mode 100644 apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts create mode 100644 apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts create mode 100644 apps/ssp/src/app/auth/domain/principal.spec.ts create mode 100644 apps/ssp/src/app/auth/domain/principal.ts delete mode 100644 apps/ssp/src/app/auth/domain/session.spec.ts delete mode 100644 apps/ssp/src/app/auth/domain/session.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md diff --git a/apps/behandelportal/src/app/auth/application/session.store.ts b/apps/behandelportal/src/app/auth/application/session.store.ts index 88ed651..d865c3a 100644 --- a/apps/behandelportal/src/app/auth/application/session.store.ts +++ b/apps/behandelportal/src/app/auth/application/session.store.ts @@ -1,51 +1,50 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; -import { Result } from '@shared/kernel/fp'; -import { Session, parseStoredSession } from '../domain/session'; -import { DigidAdapter } from '../infrastructure/digid.adapter'; +import { Principal, parseStoredPrincipal } from '../domain/principal'; +import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). - The parse + shape validation (G1/G2) lives in `parseStoredSession` - (`../domain/session`) — pure, spec'd, and testable without stubbing - `localStorage`; this just supplies the raw value. */ -function restore(): Session | null { - return parseStoredSession(localStorage.getItem(STORAGE_KEY)); +/** Restore a persisted principal (best-effort; corrupt entry → logged out). + The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in + `parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable + without stubbing `localStorage`; this just supplies the raw value. */ +function restore(): Principal | null { + return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY)); } /** - * Holds the current session for the whole app. Because it is providedIn:'root' - * there is exactly one instance — every component that injects it sees the same - * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, - * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, - * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — - * sessionStorage's per-tab clearing dropped the login on the cross-bundle language - * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth - * in an httpOnly cookie/token, not web storage. + * Holds the current medewerker principal for the whole backoffice app. One + * `providedIn: 'root'` instance, so logging in is instantly visible everywhere + * (the guard, the header). Persisted to localStorage — a refresh or the + * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in — + * which is safe to do verbatim here: a medewerker principal carries no BSN or + * other national identifier, unlike the SSP's `SessionStore`, whose equivalent + * comment explains why *that* app strips a field before writing. A real + * deployment keeps auth in an httpOnly cookie/token, not web storage, regardless. */ @Injectable({ providedIn: 'root' }) export class SessionStore { - private digid = inject(DigidAdapter); - private _session = signal(restore()); + private medewerker = inject(MedewerkerAdapter); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); constructor() { effect(() => { - const s = this._session(); - // G1: persist only `naam` — never write the BSN (national ID) to storage. - if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); + const p = this._session(); + if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); else localStorage.removeItem(STORAGE_KEY); }); } - /** Effectful command: authenticate, then store the session on success. */ - async login(bsn: string): Promise> { - const r = await this.digid.authenticate(bsn); - if (r.ok) this._session.set(r.value); - return r; + /** Effectful command: authenticate via the SSO stand-in, then store the + resulting principal. No credential to pass in, and nothing that can fail + today — see `MedewerkerAdapter`. */ + async login(): Promise { + const p = await this.medewerker.authenticate(); + this._session.set(p); + return p; } logout() { diff --git a/apps/behandelportal/src/app/auth/domain/principal.spec.ts b/apps/behandelportal/src/app/auth/domain/principal.spec.ts new file mode 100644 index 0000000..3cd76d8 --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/principal.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal'; + +const principal: Principal = { + kind: 'medewerker', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: ['behandelaar'], +}; + +describe('isAuthenticated', () => { + it('narrows a present principal to Principal', () => { + expect(isAuthenticated(principal)).toBe(true); + }); + + it('reports no principal as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); + +describe('parseStoredPrincipal', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredPrincipal(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredPrincipal('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect( + parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })), + ).toBeNull(); + }); + + it('returns null when kind is not medewerker', () => { + expect( + parseStoredPrincipal( + JSON.stringify({ + kind: 'zorgverlener', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: [], + }), + ), + ).toBeNull(); + }); + + it('returns null when rollen holds an unrecognized token', () => { + expect( + parseStoredPrincipal( + JSON.stringify({ + kind: 'medewerker', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: ['geen'], + }), + ), + ).toBeNull(); + }); + + it('restores a well-shaped stored principal as-is (no BSN to strip)', () => { + const restored = parseStoredPrincipal(JSON.stringify(principal)); + expect(restored).toEqual(principal); + }); +}); + +describe('parseRollen', () => { + it('parses a single recognized rol', () => { + expect(parseRollen('behandelaar')).toEqual(['behandelaar']); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']); + }); + + it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => { + expect(parseRollen('geen')).toEqual([]); + }); + + it('returns an empty list for an empty string', () => { + expect(parseRollen('')).toEqual([]); + }); +}); diff --git a/apps/behandelportal/src/app/auth/domain/principal.ts b/apps/behandelportal/src/app/auth/domain/principal.ts new file mode 100644 index 0000000..fd68bb2 --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/principal.ts @@ -0,0 +1,71 @@ +/** + * Who is logged in. Framework-free domain type. + * + * The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has + * exactly one actor kind (an employee, authenticated via SSO), so this app's own copy + * of the union only ever holds this one member. Unlike the SSP's `zorgverlener` + * variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this + * unrepresentable-by-construction distinction as the whole point of the union. + * `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts` + * already stamps onto every backend request — it does not itself grant anything; + * `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority + * on what this principal may do (ADR-0001, ADR-0002 §3). + */ +export type Rol = 'behandelaar'; + +const ROLLEN: readonly Rol[] = ['behandelaar']; +export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol); + +export interface Principal { + readonly kind: 'medewerker'; + readonly medewerkerId: string; + readonly naam: string; + readonly rollen: readonly Rol[]; +} + +export function isAuthenticated(p: Principal | null): p is Principal { + return p !== null; +} + +/** + * Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into + * typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`: + * comma-separated, case-insensitive, unrecognized tokens dropped — so + * `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than + * a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can + * stay a thin wire-up instead of holding logic of its own. + */ +export function parseRollen(raw: string): Rol[] { + return raw + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(isRol); +} + +/** + * Parse a persisted principal out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the shape + * before trusting it. Unlike the zorgverlener variant there is no G1 field to strip + * — a medewerker carries no national identifier — so a well-shaped record is + * restored as-is rather than reconstructed field-by-field. + */ +export function parseStoredPrincipal(raw: string | null): Principal | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return parsed?.kind === 'medewerker' && + typeof parsed.medewerkerId === 'string' && + typeof parsed.naam === 'string' && + Array.isArray(parsed.rollen) && + parsed.rollen.every(isRol) + ? { + kind: 'medewerker', + medewerkerId: parsed.medewerkerId, + naam: parsed.naam, + rollen: parsed.rollen, + } + : null; + } catch { + return null; + } +} diff --git a/apps/behandelportal/src/app/auth/domain/session.spec.ts b/apps/behandelportal/src/app/auth/domain/session.spec.ts deleted file mode 100644 index af90034..0000000 --- a/apps/behandelportal/src/app/auth/domain/session.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isAuthenticated, parseStoredSession, Session } from './session'; - -const session: Session = { bsn: '19012345601', naam: 'Test' }; - -describe('isAuthenticated', () => { - it('narrows a present session to Session', () => { - expect(isAuthenticated(session)).toBe(true); - }); - - it('reports no session as not authenticated', () => { - expect(isAuthenticated(null)).toBe(false); - }); -}); - -describe('parseStoredSession', () => { - it('returns null when nothing is stored', () => { - expect(parseStoredSession(null)).toBeNull(); - }); - - it('returns null for a non-JSON string', () => { - expect(parseStoredSession('not json')).toBeNull(); - }); - - it('returns null when the stored shape is wrong (no naam)', () => { - expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); - }); - - it('G1: a stored bsn is never restored, even if present in the raw value', () => { - const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); - expect(restored).toEqual({ bsn: '', naam: 'Test' }); - }); -}); diff --git a/apps/behandelportal/src/app/auth/domain/session.ts b/apps/behandelportal/src/app/auth/domain/session.ts deleted file mode 100644 index abbbbf6..0000000 --- a/apps/behandelportal/src/app/auth/domain/session.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Who is logged in. Framework-free domain type. */ -export interface Session { - readonly bsn: string; - readonly naam: string; -} - -export function isAuthenticated(s: Session | null): s is Session { - return s !== null; -} - -/** - * Parse a persisted session out of a raw `localStorage` string (best-effort; - * anything that isn't a well-shaped record → logged out). G2: validate the - * shape before trusting it. G1: even if a stored entry carries a `bsn`, the - * restored session's `bsn` is always `''` — the BSN is never persisted (see - * the `SessionStore` effect that writes it), so a legacy or tampered entry - * cannot resurrect one. - */ -export function parseStoredSession(raw: string | null): Session | null { - try { - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } -} diff --git a/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts deleted file mode 100644 index a4956d0..0000000 --- a/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Result, ok } from '@shared/kernel/fp'; -import { parseBsn } from '@shared/kernel/bsn'; -import { Session } from '../domain/session'; - -/** Infrastructure: talks to the (mock) DigiD identity provider. */ -@Injectable({ providedIn: 'root' }) -export class DigidAdapter { - // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. - // Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity - // for a real OIDC redirect flow when there's an IdP. - async authenticate(bsn: string): Promise> { - const r = parseBsn(bsn); - return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; - } -} diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts new file mode 100644 index 0000000..85cc7ee --- /dev/null +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Principal, parseRollen } from '../domain/principal'; +import { MEDEWERKER_ID, currentRollen } from './medewerker'; + +/** + * Infrastructure: resolves the current medewerker identity into a `Principal` + * (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, + * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s + * BSN check, no format to reject, so `authenticate()` takes no input and returns the + * `Principal` directly rather than a `Result` with an error variant that can never + * actually occur. A real SSO callback (which *can* fail — session expired, access + * denied) swaps in behind this same method; that is the point where this return + * type would gain a `Result`, not before. + * + * Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only + * `medewerkerInterceptor` already stamps onto every backend request as + * `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the + * frontend (the guard, the header, `SessionStore`'s persisted principal), it does + * not change what the backend resolves or authorizes. + */ +@Injectable({ providedIn: 'root' }) +export class MedewerkerAdapter { + // ponytail: fake employee SSO — a fixed medewerker, no credential exchange. + async authenticate(): Promise { + return { + kind: 'medewerker', + medewerkerId: MEDEWERKER_ID, + naam: 'H. (Hassan) Bakker', + rollen: parseRollen(currentRollen()), + }; + } +} diff --git a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts index d11c5d3..6d9b5ce 100644 --- a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts +++ b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts @@ -1,50 +1,27 @@ import { Component, output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; -import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; -/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ +/** + * Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, + * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no + * BSN, and this app has no password of its own to check either way. There is + * nothing to compose beyond one button, which is itself evidence for the ADR — the + * two apps' login flows are meant to look this different. + */ @Component({ selector: 'app-login-form', - imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], + imports: [ButtonComponent], template: ` -
-
-
- * verplichte velden -
-
- - - - - - - - - - Inloggen met DigiD -
+
+

+ U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig. +

+ + Inloggen met SSO + +
`, }) export class LoginFormComponent { - bsn = ''; - password = ''; - submitted = output(); + submitted = output(); } diff --git a/apps/behandelportal/src/app/auth/ui/login.page.ts b/apps/behandelportal/src/app/auth/ui/login.page.ts index 59671f9..aca9da5 100644 --- a/apps/behandelportal/src/app/auth/ui/login.page.ts +++ b/apps/behandelportal/src/app/auth/ui/login.page.ts @@ -1,36 +1,35 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { AlertComponent } from '@shared/ui/alert/alert.component'; import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; import { SessionStore } from '@auth/application/session.store'; +/** + * No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has + * nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where + * this page would grow one back. + */ @Component({ selector: 'app-login-page', - imports: [PageShellComponent, AlertComponent, LoginFormComponent], + imports: [PageShellComponent, LoginFormComponent], template: ` - @if (error()) { - {{ error() }} - } - + `, }) export class LoginPage { private store = inject(SessionStore); private router = inject(Router); - error = signal(''); - async login(bsn: string) { - const r = await this.store.login(bsn); - if (r.ok) this.router.navigate(['/dashboard']); - else this.error.set(r.error); + async login() { + await this.store.login(); + this.router.navigate(['/dashboard']); } } diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index 6977b09..e538c31 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -26,65 +26,33 @@ 27
- - * verplichte velden - * required fields + + U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig. + You sign in through your organization's SSO — no password is needed. src/app/auth/ui/login-form/login-form.component.ts - 15,18 - - - src/app/registratie/ui/change-request-form/change-request-form.component.ts - 44,46 - - - src/app/shared/layout/wizard-shell/wizard-shell.component.ts - 90,92 - - - - BSN - BSN - - src/app/auth/ui/login-form/login-form.component.ts - 22,23 - - - - 9-cijferig BSN, elfproef-geldig (demo: 123456782) - 9-digit BSN, valid eleven-test checksum (demo: 123456782) - - src/app/auth/ui/login-form/login-form.component.ts - 25,28 - - - - Wachtwoord - Password - - src/app/auth/ui/login-form/login-form.component.ts - 36,37 + 17,19 - Inloggen met DigiD - Log in with DigiD + Inloggen met SSO + Log in with SSO src/app/auth/ui/login-form/login-form.component.ts - 41,43 + 20,21 - Inloggen - Log in + Inloggen bij het behandelportal + Log in to the treatment portal src/app/auth/ui/login.page.ts 14,16 - Log in op uw persoonlijke BIG-register omgeving. - Log in to your personal BIG register environment. + Voor medewerkers die aanvragen beoordelen. + For staff who assess applications. src/app/auth/ui/login.page.ts 17,19 diff --git a/apps/ssp/src/app/auth/application/session.store.ts b/apps/ssp/src/app/auth/application/session.store.ts index 88ed651..dd891b1 100644 --- a/apps/ssp/src/app/auth/application/session.store.ts +++ b/apps/ssp/src/app/auth/application/session.store.ts @@ -1,48 +1,50 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; -import { Session, parseStoredSession } from '../domain/session'; +import { Principal, parseStoredPrincipal } from '../domain/principal'; import { DigidAdapter } from '../infrastructure/digid.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). - The parse + shape validation (G1/G2) lives in `parseStoredSession` - (`../domain/session`) — pure, spec'd, and testable without stubbing +/** Restore a persisted principal (best-effort; corrupt entry → logged out). + The parse + shape validation (G1/G2) lives in `parseStoredPrincipal` + (`../domain/principal`) — pure, spec'd, and testable without stubbing `localStorage`; this just supplies the raw value. */ -function restore(): Session | null { - return parseStoredSession(localStorage.getItem(STORAGE_KEY)); +function restore(): Principal | null { + return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY)); } /** - * Holds the current session for the whole app. Because it is providedIn:'root' - * there is exactly one instance — every component that injects it sees the same - * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, - * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, - * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — - * sessionStorage's per-tab clearing dropped the login on the cross-bundle language - * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth - * in an httpOnly cookie/token, not web storage. + * Holds the current zorgverlener principal for the whole SSP. One + * `providedIn: 'root'` instance, so logging in is instantly visible everywhere + * (the guard, the header). Persisted to localStorage — a refresh or the + * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in — + * but never the BSN itself (G1 in the `effect` below): this principal carries a + * citizen's national identifier, which the behandelportal's equivalent store does + * not have to guard against, because its `medewerker` principal has no BSN. + * ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing + * dropped the login on the cross-bundle language switch. Trade-off: the demo + * session now survives tab close; a real portal keeps auth in an httpOnly + * cookie/token, not web storage. */ @Injectable({ providedIn: 'root' }) export class SessionStore { private digid = inject(DigidAdapter); - private _session = signal(restore()); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); constructor() { effect(() => { - const s = this._session(); + const p = this._session(); // G1: persist only `naam` — never write the BSN (national ID) to storage. - if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); + if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam })); else localStorage.removeItem(STORAGE_KEY); }); } - /** Effectful command: authenticate, then store the session on success. */ - async login(bsn: string): Promise> { + /** Effectful command: authenticate, then store the principal on success. */ + async login(bsn: string): Promise> { const r = await this.digid.authenticate(bsn); if (r.ok) this._session.set(r.value); return r; diff --git a/apps/ssp/src/app/auth/domain/principal.spec.ts b/apps/ssp/src/app/auth/domain/principal.spec.ts new file mode 100644 index 0000000..386111a --- /dev/null +++ b/apps/ssp/src/app/auth/domain/principal.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, parseStoredPrincipal, Principal } from './principal'; + +const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' }; + +describe('isAuthenticated', () => { + it('narrows a present principal to Principal', () => { + expect(isAuthenticated(principal)).toBe(true); + }); + + it('reports no principal as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); + +describe('parseStoredPrincipal', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredPrincipal(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredPrincipal('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); + }); + + it('G1: a stored bsn is never restored, even if present in the raw value', () => { + const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); + expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' }); + }); +}); diff --git a/apps/ssp/src/app/auth/domain/principal.ts b/apps/ssp/src/app/auth/domain/principal.ts new file mode 100644 index 0000000..3672e07 --- /dev/null +++ b/apps/ssp/src/app/auth/domain/principal.ts @@ -0,0 +1,39 @@ +/** + * Who is logged in. Framework-free domain type. + * + * The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly + * one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of + * the union only ever holds this one member. `kind` is still a discriminant, not + * decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a + * genuinely different type rather than a same-shaped coincidence, and what a future + * third actor (§4 — admin/auditor/institution-rep) would add a member to. + */ +export interface Principal { + readonly kind: 'zorgverlener'; + readonly bsn: string; + readonly naam: string; +} + +export function isAuthenticated(p: Principal | null): p is Principal { + return p !== null; +} + +/** + * Parse a persisted principal out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the + * shape before trusting it. G1: even if a stored entry carries a `bsn`, the + * restored principal's `bsn` is always `''` — the BSN is never persisted (see + * the `SessionStore` effect that writes it), so a legacy or tampered entry + * cannot resurrect one. + */ +export function parseStoredPrincipal(raw: string | null): Principal | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed?.naam === 'string' + ? { kind: 'zorgverlener', bsn: '', naam: parsed.naam } + : null; + } catch { + return null; + } +} diff --git a/apps/ssp/src/app/auth/domain/session.spec.ts b/apps/ssp/src/app/auth/domain/session.spec.ts deleted file mode 100644 index af90034..0000000 --- a/apps/ssp/src/app/auth/domain/session.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isAuthenticated, parseStoredSession, Session } from './session'; - -const session: Session = { bsn: '19012345601', naam: 'Test' }; - -describe('isAuthenticated', () => { - it('narrows a present session to Session', () => { - expect(isAuthenticated(session)).toBe(true); - }); - - it('reports no session as not authenticated', () => { - expect(isAuthenticated(null)).toBe(false); - }); -}); - -describe('parseStoredSession', () => { - it('returns null when nothing is stored', () => { - expect(parseStoredSession(null)).toBeNull(); - }); - - it('returns null for a non-JSON string', () => { - expect(parseStoredSession('not json')).toBeNull(); - }); - - it('returns null when the stored shape is wrong (no naam)', () => { - expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); - }); - - it('G1: a stored bsn is never restored, even if present in the raw value', () => { - const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); - expect(restored).toEqual({ bsn: '', naam: 'Test' }); - }); -}); diff --git a/apps/ssp/src/app/auth/domain/session.ts b/apps/ssp/src/app/auth/domain/session.ts deleted file mode 100644 index abbbbf6..0000000 --- a/apps/ssp/src/app/auth/domain/session.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Who is logged in. Framework-free domain type. */ -export interface Session { - readonly bsn: string; - readonly naam: string; -} - -export function isAuthenticated(s: Session | null): s is Session { - return s !== null; -} - -/** - * Parse a persisted session out of a raw `localStorage` string (best-effort; - * anything that isn't a well-shaped record → logged out). G2: validate the - * shape before trusting it. G1: even if a stored entry carries a `bsn`, the - * restored session's `bsn` is always `''` — the BSN is never persisted (see - * the `SessionStore` effect that writes it), so a legacy or tampered entry - * cannot resurrect one. - */ -export function parseStoredSession(raw: string | null): Session | null { - try { - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } -} diff --git a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts index a4956d0..652622c 100644 --- a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts +++ b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Result, ok } from '@shared/kernel/fp'; import { parseBsn } from '@shared/kernel/bsn'; -import { Session } from '../domain/session'; +import { Principal } from '../domain/principal'; /** Infrastructure: talks to the (mock) DigiD identity provider. */ @Injectable({ providedIn: 'root' }) @@ -9,8 +9,8 @@ export class DigidAdapter { // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity // for a real OIDC redirect flow when there's an IdP. - async authenticate(bsn: string): Promise> { + async authenticate(bsn: string): Promise> { const r = parseBsn(bsn); - return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; + return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; } } diff --git a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts index 8a6f190..b3faf88 100644 --- a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts +++ b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts @@ -1,7 +1,7 @@ import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core'; import { JsonPipe } from '@angular/common'; import { SessionStore } from '@auth/application/session.store'; -import { Session } from '@auth/domain/session'; +import { Principal } from '@auth/domain/principal'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { map } from '@shared/application/remote-data'; import { Role } from '@shared/domain/role'; @@ -172,6 +172,6 @@ export class DebugStateComponent { } } -function maskSession(s: Session | null): Session | null { - return s ? { ...s, bsn: maskBsn(s.bsn) } : null; +function maskSession(p: Principal | null): Principal | null { + return p ? { ...p, bsn: maskBsn(p.bsn) } : null; } diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md new file mode 100644 index 0000000..f2c2185 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md @@ -0,0 +1,186 @@ +# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form + +Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13 + +## What was wrong + +ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated +`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker'; +medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands." +Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow. + +Verified before this ticket: + +- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in + `libs/shared/src/infrastructure/role.ts:8`. No such type existed. +- `apps/ssp/src/app/auth/domain/session.ts` and + `apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical: + `interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar + carrying a `bsn`, which §3 names as precisely the state the union exists to make + unrepresentable. +- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw +persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` → + `DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de +Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD, + under a citizen's name. +- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already + stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of + `SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door + instead of the `Principal` union, which is why the two `auth` contexts still measured + as identical. +- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after + ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%), + `bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's + "auth stays duplicated because it's expected to diverge" claim had never actually been + tested, only asserted. + +RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider` +able to say "no identity" and fail closed; this ticket is its stated FE half — without +it, a production behandelportal falls through to the seeded zorgverlener by default, +open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket +does not touch that backend behaviour — it makes the FE identity model honest about +who is actually authenticating. + +## What changed + +| File | Change | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/auth/domain/session.ts` → `principal.ts` | `Session` → `Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession` → `parseStoredPrincipal` (G1/G2 unchanged) | +| `apps/ssp/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape | +| `apps/ssp/src/app/auth/application/session.store.ts` | `Session` → `Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) | +| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` | +| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session` → `Principal` (the one other consumer of the domain type) | +| `apps/behandelportal/src/app/auth/domain/session.ts` → `principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) | +| `apps/behandelportal/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) | +| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts` → `medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) | +| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it | +| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output()` | +| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) | +| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) | +| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) | +| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure | +| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases | + +## Judgement calls + +- **Each app's `Principal` holds only the one variant it has an actor for**, not the + full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own + proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`: + replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener` + variant"), and it matches how the codebase already splits `auth` per app. `kind` stays + on both single-member types anyway — it is what makes the two types genuinely + different rather than a same-shaped coincidence, and it is where a third actor (§4 — + admin/auditor/institution-rep) would add a member. +- **`MedewerkerAdapter.authenticate()` returns `Promise`, not + `Promise>`.** The first draft mirrored `DigidAdapter`'s + `Result`-returning shape for symmetry, but that `Result`'s error variant could never + actually be produced — there is no credential to check, so wrapping the return in a + type that claims to have a failure mode was itself a small instance of the thing + CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a + direct `Promise` and dropped the now-dead error-handling branch from + `login.page.ts` (`error` signal, the ``, the `AlertComponent` + import) — a real SSO integration is where that branch would come back, not before. + This was also the change that did the most to bring the duplication figure down (see + below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two + pages' control flow, not just their copy, actually differed. +- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen` + lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an + unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed + `Rol[]` is pure string logic with no Angular dependency, so it belongs in + `domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`) + stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and + hands them to a pure function. `parseRollen` deliberately mirrors the backend's own + `StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so + `?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule + (ADR-0001's boundary is about _authorization decisions_, which still come only from + `GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the + same header value it is about to send, for display, the same way `DigidAdapter` + already fabricates its own fake identity. +- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a + stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN") + doesn't apply here — a `medewerker` principal has no national identifier — so there is + nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and + restores it as-is. This was a deliberate choice against an alternative: reconstructing + `medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every + restore, which would have made `domain/principal.ts` depend on + `infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency + rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing + `?rollen=` mid-session does not retroactively change an already-restored `Principal` + until the next `login()`/`logout()` — the same way changing the DigiD demo BSN + requires a fresh login in the SSP. The backend's own authorization is unaffected + either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP + request regardless of what `SessionStore` holds. +- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left + unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside + `auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change, + not as something the change renames. `libs/shared/src/application/session.port.ts`'s + `SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and + `isAuthenticated`, neither of which is `kind`-dependent. +- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments + updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why + `?subject=` exists instead of reading the store directly; renaming the type these + comments describe without updating the comment would have left them citing a type + that no longer exists. +- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is + explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it + reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for + this ticket to change there. +- **No backend change.** RB-09 already made `IIdentityProvider` nullable and + Production-fail-fast; this ticket is purely the frontend counterpart it named. The + residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation + callers carrying no identity header once a real, non-stub `IIdentityProvider` exists) + is unaffected by anything here — it is about a _future_ real provider replacing the + Development-only stub, which this ticket does not touch. + +## Duplication, measured (`tools/baseline-scan.mjs --dup`) + +| When | `ssp/auth` dup lines | `bhp/auth` dup lines | +| ----------------------------------- | -------------------: | -------------------: | +| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — | +| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) | +| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** | + +Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under +target. The full clone-pair listing (the script's own output truncates to the top 15 +pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was +hiding below that cut) resolves to exactly four remaining pairs: + +- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before + trusting a stored shape" concept with a parallel `describe`/`it` structure (including + the shared `import { describe, it, expect } from 'vitest';` line); the assertions + themselves differ (BSN-stripping vs. kind/rollen validation). +- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default` + scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject. +- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is + meant to stay identical. +- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small + shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root + singleton store in this codebase shares). + +None of what remains is re-converged identity or login-flow logic — the domain type, +the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction +("the two groups authenticate differently") has been tested for the first time by this +ticket, not just asserted, and it held. + +## Verification + +Confirmed each non-trivial change is red without its fix (edited in place, verified red, +edited back — never `git checkout`): + +- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''` → + `G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …} +to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected. +- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'` + clause → `returns null when kind is not medewerker` failed, returning the parsed + zorgverlener-shaped object instead of `null`. Reverted. +- **bhp `parseRollen`:** dropped `.filter(isRol)` → `drops unrecognized tokens` and + `returns an empty list for an empty string` both failed (`['geen']`/`['']` returned + instead of `[]`). Reverted. + +`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) + +133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs. +`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp +--localize` and `ng build behandelportal --localize`: both succeed (the new +`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro` +sources all resolve to an English ``). `npm run ci`: green (see the commit this +doc ships with). diff --git a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md index 67774bc..f4c12e6 100644 --- a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md +++ b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md @@ -1,6 +1,6 @@ # ADR 0002 — User groups as actors, not bounded contexts -Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67) +Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13) ## Problem @@ -167,28 +167,34 @@ status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67): `AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`), `Domain/Authorization/Authz.cs`. -## Known debt: `Session → Principal` was never built +A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid +off by RB-13 the next day. See the amendment below for the historical record and what +landed. -§3's `Principal` union is the one decision here that has **not** been executed, and it is now -debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union -did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in -`libs/shared/src/infrastructure/role.ts`. There is no such type. +## Amendment (RB-13, 2026-08-27): `Session → Principal` landed -What that omission actually costs, measured 2026-08-26: +§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the +"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit +(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the +`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`); +`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant +(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one +member of the union it actually has an actor for, per this ADR's own proposed resolution. +`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the +dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` — +unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into +a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN +field) rather than the citizen DigiD form it used to share with the SSP verbatim. -- `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical — - `diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being - two extra files in behandelportal. -- `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`. - A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent. -- The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`, - a dev-only `X-Medewerker` header stamp that never touches `Session`. - -The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is -"expected to diverge". That reasoning still holds — but it has never been **tested**, because -the change that would test it is this one. Read the two identical copies as evidence that -§3 is unexecuted, not as evidence that §3 was wrong. - -ponytail: this ADR draws the boundaries so nothing has to be undone later. The original -"YAGNI until the backoffice work starts" call was right when written and has now expired — -the backoffice started. `Principal` is owed. +The two `auth` contexts, measured 2026-08-27 after the change +(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the +2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What +remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional +verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope) +plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj` +scaffold) that any two spec or story files share regardless of subject. The prediction in +§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would +turn out to authenticate differently enough that sharing `auth` would have been the wrong +call — has now actually been tested, not just asserted, and held: the two contexts diverge +in domain type, adapter, and login UI as soon as the union exists to make that +divergence possible. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 37de06e..6f265f5 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 440 frontend behaviours across +**is** the suite, reshaped for a business reader. 446 frontend behaviours across 9 contexts; 231 backend behaviours across 39 test classes. @@ -30,17 +30,26 @@ classes. #### isAuthenticated -- narrows a present session to Session -- reports no session as not authenticated -- narrows a present session to Session -- reports no session as not authenticated +- narrows a present principal to Principal +- reports no principal as not authenticated +- narrows a present principal to Principal +- reports no principal as not authenticated -#### parseStoredSession +#### parseRollen + +- parses a single recognized rol +- is case-insensitive and trims whitespace +- drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen) +- returns an empty list for an empty string + +#### parseStoredPrincipal - returns null when nothing is stored - returns null for a non-JSON string - returns null when the stored shape is wrong (no naam) -- G1: a stored bsn is never restored, even if present in the raw value +- returns null when kind is not medewerker +- returns null when rollen holds an unrecognized token +- restores a well-shaped stored principal as-is (no BSN to strip) - returns null when nothing is stored - returns null for a non-JSON string - returns null when the stored shape is wrong (no naam) diff --git a/libs/shared/src/infrastructure/subject.interceptor.ts b/libs/shared/src/infrastructure/subject.interceptor.ts index 9890495..a25e002 100644 --- a/libs/shared/src/infrastructure/subject.interceptor.ts +++ b/libs/shared/src/infrastructure/subject.interceptor.ts @@ -12,7 +12,7 @@ import { currentSubject } from './subject'; * middleware resolves a `CallerIdentity` for every request, not just some endpoints. * * **BSN source — a deliberate compromise, read before changing:** the "obvious" - * source would be the authenticated `Session.bsn` held by each app's own + * source would be the authenticated `Principal.bsn` held by each app's own * `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context * (the import-direction rule), and the one sanctioned cross-context seam — * `SessionPort` (`@shared/application/session.port`) — deliberately exposes only diff --git a/libs/shared/src/infrastructure/subject.ts b/libs/shared/src/infrastructure/subject.ts index ec64d7f..0d261aa 100644 --- a/libs/shared/src/infrastructure/subject.ts +++ b/libs/shared/src/infrastructure/subject.ts @@ -3,7 +3,7 @@ import { isDevMode } from '@angular/core'; /** * Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see * `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This - * POC has no real DigiD identity — `Session.bsn` lives only in each app's own + * POC has no real DigiD identity — `Principal.bsn` lives only in each app's own * in-memory `SessionStore` and is deliberately never persisted (see that store's G1 * comment) — so `subject.interceptor.ts` can't reach it without a layering * violation (`libs/shared` may not depend on an app-local `auth` context). Instead a From bc5b2c4b2dfd65309f11146915c1ea5566b973ee Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 17:01:41 +0200 Subject: [PATCH 37/61] docs(backlog): CD batch 3 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six merged, gate green (14 steps, backend 260/260). Records the three tickets that could not be built as written — RB-12's wrapper/public binary does not fit the route table, RB-14's command exits 0 on a High advisory, and RB-15 needed a third environment name because RB-09 makes Production fail to boot — plus RB-13's measured duplication drop (168 -> 32 lines per side). Adds a section on dispatching implementation agents. Four of six agent-runs were handed a worktree branched from a stale ancestor; batch 3 was three for three. That, the background-task parking, and the git-checkout-destroys-work trap are all cheap to prevent in the prompt and expensive to discover. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 12 ++--- .../refactor-backlog/_status.md | 48 +++++++++++++++---- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 13a945f..fcd230b 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -113,12 +113,12 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | | **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | open | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | open | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | | **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | | **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | | **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 10535bc..4bc5e83 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,14 +14,14 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ---------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. | -| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | +| CD batch | Tickets | Status | Notes | +| -------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | +| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, @@ -47,3 +47,35 @@ Fixed in `build: stop ci-local.sh swallowing the first half of every paired step weaker than it reads; the batch-2 completion run above is the first one made on the honest gate (13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been re-verified under the fixed gate either. + +## Dispatching implementation agents — what actually goes wrong + +Batches 2 and 3 ran tickets as parallel agents in git worktrees. Six of seven agent-runs hit at +least one of these. Put all of it in the prompt. + +1. **The worktree base is not reliable.** **Four of the six** agents were handed a worktree + branched from a stale ancestor — batch 3 was **three for three**, all landing on `ae7781e`, + an unrelated lineage missing every RB ticket _and_ this backlog directory. Make step zero: + `git log --oneline -8`, confirm a **named expected commit**, `git merge` the target branch if + absent, and report which it was. The one agent that was not told to do this found out by luck. +2. **Agents park on background tasks.** Two agents in batch 2 ran `npm run ci` in the background, + then ended their turn waiting for a notification that never usefully arrived; one finished its + work twice and never committed it. Ban `run_in_background` and Monitor, **and say explicitly to + pass the Bash tool's own `timeout: 600000`** — a batch-3 agent still auto-backgrounded because + "run it in the foreground" alone does not defeat the 120s default. +3. **`git checkout ` destroys the work.** Agents verify a test is red by undoing the fix; + restoring it with `git checkout` reverts the whole file. Tell them to undo and redo with edits. +4. **`behaviour-spec.mdx` conflicts on nearly every merge.** It is generated and every agent + regenerates it. Resolve centrally with `npm run gen:behaviour-spec && git add`; tell agents to + expect it and never hand-edit. +5. **Concurrent `dotnet test` runs flake** — a burst of `SQLite Error 1: 'no such table: Documents'`. + Partly host pressure, but RB-12 found a real cause: a bare `new WebApplicationFactory()` + races on the static `Db.ConnectionString`. Use the house `TestWebApplicationFactory` + + `IClassFixture` idiom. Do not trust a backend green while another agent is testing. +6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by + ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`. + +**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong +about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and +RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review +of the diff alone. From 7fbac8fca57db5c0e9a2593851c8a622fb82ad6f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 17:03:37 +0200 Subject: [PATCH 38/61] docs: write English prose in Simplified Technical English Adds a Conventions rule for Simplified Technical English (ASD-STE100). It covers documentation, code comments, commit messages, ADRs, and the backlog notes. STE is a controlled language. It makes text easy to read for people who do not have English as a first language, and easy to translate. The readers of this project are mostly non-native English readers. The rule states that STE governs form, not content. Split a long sentence. Never remove a caveat, a measurement, or a precise term to make text shorter. The rule does not apply to Dutch identifiers, $localize copy, quoted output, or existing documents that you are not already editing. It therefore does not change the Naming convention above it, which keeps domain contexts in Dutch. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 88fa614..8bc7e7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own - **Naming:** shared/reusable UI is **English** (language-agnostic: `button`, `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, `*.machine.ts`). Pick the language by which side of the seam the code is on. +- **English prose uses Simplified Technical English (STE).** This covers documentation, + code comments, commit messages, ADRs, and the backlog notes. One idea per sentence; + 20 words or fewer in a procedure, 25 in a description. Active voice, present tense. + One word for one meaning — pick a term and repeat it, do not vary it for style. Keep + articles ("the test fails"). Three nouns together at most. No idioms and no humour. + Six sentences per paragraph at most. Write a procedure as numbered steps, one action + per step. + **STE governs form, not content.** Split a long sentence; never drop a caveat, a + measurement, or a precise term to make it shorter. + **STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or + existing documents you are not already editing. - **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's first-party `$localize` (no third-party i18n lib), with a stable custom id (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a From d518a1466c0509169e2054e807c9bf29347e9c30 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 18:23:13 +0200 Subject: [PATCH 39/61] refactor(registratie): extract the read half of createDraftSync (RB-21) createDraftSync mixed a read path (findConcept, load, the read half of resume) with its write path (ensureId, flush, submit, reset) in one 187-line function -- CQ-001's finding. Move findConcept and loadConcept into a new application/find-concept.ts as free functions that take the adapter, so they get a direct spec with no Angular TestBed. createDraftSync keeps the closure state (id, ensuring, resumeGate) and the whole write path unchanged -- this is a move, not a redesign. The resumeGate coupling that lets the write path wait for the read path stays exactly where it was. createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is unchanged -- it never called resume()/load() directly, and its 409 recovery test for submit() still exercises the extracted findConcept through ensureId's catch branch. Co-Authored-By: Claude Opus 5 --- .../app/registratie/application/draft-sync.ts | 42 ++----- .../application/find-concept.spec.ts | 108 ++++++++++++++++ .../registratie/application/find-concept.ts | 48 +++++++ .../refactor-backlog/99-backlog.md | 70 +++++------ .../refactor-backlog/implementation/rb-21.md | 119 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 17 ++- 6 files changed, 337 insertions(+), 67 deletions(-) create mode 100644 apps/ssp/src/app/registratie/application/find-concept.spec.ts create mode 100644 apps/ssp/src/app/registratie/application/find-concept.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md diff --git a/apps/ssp/src/app/registratie/application/draft-sync.ts b/apps/ssp/src/app/registratie/application/draft-sync.ts index ff533dd..eec4426 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.ts @@ -8,10 +8,8 @@ import type { SubmitApplicationResponse, } from '@shared/infrastructure/api-client'; import { AanvraagType } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { findConcept, loadConcept } from './find-concept'; /** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ export interface DraftSnapshot { @@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) { // server's guard (409) — recover by adopting the existing Concept instead of // erroring. Only recover when one actually exists; otherwise surface the failure. .catch(async (e) => { - const existing = await findConcept(); + const existing = await findConcept(adapter, deps.type); if (existing) return existing; throw e; }) @@ -140,32 +138,14 @@ export function createDraftSync(deps: DraftSyncDeps) { // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft. const load = (linked: string): Promise => { id = linked; - return adapter - .detail(linked) - .then((dto) => { - if (dto.status && dto.status.tag !== 'Concept') { - id = undefined; - applyResume(null); - return; - } - applyResume(dto.draft ?? null); - }) - .catch(() => { + return loadConcept(adapter, linked).then((result) => { + if (result.tag === 'not-concept') { id = undefined; - applyResume(null); // unknown/deleted id → start fresh - }); - }; - - // Find the user's existing Concept of this type (at most one), if any. - const findConcept = async (): Promise => { - try { - const parsed = parseApplications(await adapter.list()); - return parsed.ok - ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id - : undefined; - } catch { - return undefined; - } + applyResume(null); + return; + } + applyResume(result.draft); + }); }; return { @@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) { await load(linked); return; } - const existing = await findConcept(); + const existing = await findConcept(adapter, deps.type); if (existing) { await load(existing); // Stamp the id into the URL so a reload resumes the same Concept. diff --git a/apps/ssp/src/app/registratie/application/find-concept.spec.ts b/apps/ssp/src/app/registratie/application/find-concept.spec.ts new file mode 100644 index 0000000..c617ad0 --- /dev/null +++ b/apps/ssp/src/app/registratie/application/find-concept.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { findConcept, loadConcept } from './find-concept'; + +// Free functions taking the adapter as a parameter (no inject()) — a plain fake +// object is enough, no Angular TestBed needed. +function fakeAdapter(overrides: Partial): ApplicationsAdapter { + return overrides as ApplicationsAdapter; +} + +describe('findConcept', () => { + it('returns the id of the existing Concept of the given type', async () => { + const adapter = fakeAdapter({ + list: async () => [ + { + id: 'a1', + type: 'registratie', + status: { tag: 'Concept', stepIndex: 0, stepCount: 3 }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBe('a1'); + }); + + it('returns undefined when the list has no application of the given type', async () => { + const adapter = fakeAdapter({ list: async () => [] }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); + + it('returns undefined when the matching type is not a Concept', async () => { + const adapter = fakeAdapter({ + list: async () => [ + { + id: 'a1', + type: 'registratie', + status: { tag: 'Ingediend', referentie: 'R1' }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); + + it('returns undefined when adapter.list() resolves with an unparsable shape', async () => { + const adapter = fakeAdapter({ list: async () => 'not-an-array' as unknown as [] }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); + + it('returns undefined when adapter.list() rejects', async () => { + const adapter = fakeAdapter({ + list: async () => { + throw new Error('network down'); + }, + }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); +}); + +describe('loadConcept', () => { + it('reads the draft off a Concept', async () => { + const adapter = fakeAdapter({ + detail: async () => ({ + id: 'a1', + status: { tag: 'Concept', stepIndex: 1, stepCount: 3 }, + draft: { step: 1 }, + }), + }); + + await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ + tag: 'concept', + draft: { step: 1 }, + }); + }); + + it('reports a missing draft as null', async () => { + const adapter = fakeAdapter({ + detail: async () => ({ id: 'a1', status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } }), + }); + + await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'concept', draft: null }); + }); + + it('reports not-concept when the id has moved past Concept (submitted)', async () => { + const adapter = fakeAdapter({ + detail: async () => ({ id: 'a1', status: { tag: 'Ingediend', referentie: 'R1' } }), + }); + + await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'not-concept' }); + }); + + it('reports not-concept when the id is unknown or deleted (detail rejects)', async () => { + const adapter = fakeAdapter({ + detail: async () => { + throw new Error('404'); + }, + }); + + await expect(loadConcept(adapter, 'gone')).resolves.toEqual({ tag: 'not-concept' }); + }); +}); diff --git a/apps/ssp/src/app/registratie/application/find-concept.ts b/apps/ssp/src/app/registratie/application/find-concept.ts new file mode 100644 index 0000000..6b0cf75 --- /dev/null +++ b/apps/ssp/src/app/registratie/application/find-concept.ts @@ -0,0 +1,48 @@ +import { AanvraagType } from '@registratie/domain/aanvraag'; +import { + ApplicationsAdapter, + parseApplications, +} from '@registratie/infrastructure/applications.adapter'; + +/** + * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs + * before it can start writing (RB-21 / CQ-001). Free functions that take the adapter + * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed. + * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path; + * these two functions only read. + */ + +/** Find the user's existing Concept of a given type (at most one), if any. */ +export async function findConcept( + adapter: ApplicationsAdapter, + type: AanvraagType, +): Promise { + try { + const parsed = parseApplications(await adapter.list()); + return parsed.ok + ? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id + : undefined; + } catch { + return undefined; + } +} + +/** Outcome of loading one Concept by id: its draft (or null when it has none), or + `not-concept` when the id is not an editable Concept (submitted/gone) or the + lookup failed (unknown/deleted id) — the caller treats both the same way, as + "start fresh". */ +export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' }; + +/** Load a specific Concept by id and report whether it is still editable. */ +export async function loadConcept( + adapter: ApplicationsAdapter, + id: string, +): Promise { + try { + const dto = await adapter.detail(id); + if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' }; + return { tag: 'concept', draft: dto.draft ?? null }; + } catch { + return { tag: 'not-concept' }; + } +} diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fcd230b..6a6fc80 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | implemented | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md new file mode 100644 index 0000000..c908e0a --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md @@ -0,0 +1,119 @@ +# RB-21 — extract the read half of `createDraftSync` into `find-concept.ts` + +Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-001 · +`00-baseline.md` §4a (`createDraftSync` 143 lines, the largest function in the repo), §9 +(`fn > 40` threshold) · `99-backlog.md` RB-21 + +## What was wrong + +`createDraftSync` (`apps/ssp/src/app/registratie/application/draft-sync.ts`) was registered +as a command factory but owned three read paths (`load`, `findConcept`, and the read half of +`resume`) mixed into the same function as the write path (`ensureId`, `flush`, `submit`, +`reset`). CQ-001 named three pieces of shared mutable closure state — `id`, `ensuring`, +`resumeGate` — as load-bearing: `resumeGate` exists only so the write path (`ensureId`) can +wait for the read path (`resume`) to finish. That coupling is genuine and stays in place. + +## What changed + +CQ-001's proposal, applied as a pure move, no redesign. + +| File | Change | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/registratie/application/find-concept.ts` (new) | `findConcept(adapter, type)` and `loadConcept(adapter, id)` — free functions taking `ApplicationsAdapter`, no `inject()`. `loadConcept` returns a `LoadedConcept` union (`{tag:'concept', draft}` \| `{tag:'not-concept'}`) instead of the boolean-shaped branching the inline version had. | +| `apps/ssp/src/app/registratie/application/find-concept.spec.ts` (new) | Direct spec, no TestBed — a fake `ApplicationsAdapter` object passed straight to the functions. | +| `apps/ssp/src/app/registratie/application/draft-sync.ts` | Removed the inline `findConcept` closure and the body of `load`; both now call the free functions. `createDraftSync` keeps `id`, `ensuring`, `resumeGate`, and the whole write path, unchanged. | +| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `find-concept.spec.ts` describe blocks. | + +`createDraftSync` shrank from 187 lines (`export function createDraftSync` to its closing +brace, HEAD~1) to 169 lines. The whole file went from 236 to 216 lines. + +The two call sites that used the old inline `findConcept()` now pass the adapter and type +explicitly: + +```ts +// ensureId's 409-recovery catch (WP-35) +const existing = await findConcept(adapter, deps.type); +``` + +```ts +// resume(), no ?aanvraag in the URL +const existing = await findConcept(adapter, deps.type); +``` + +`load` keeps setting the closure `id` and calling `applyResume` (both closure-dependent), but +delegates the actual read to `loadConcept`: + +```ts +const load = (linked: string): Promise => { + id = linked; + return loadConcept(adapter, linked).then((result) => { + if (result.tag === 'not-concept') { + id = undefined; + applyResume(null); + return; + } + applyResume(result.draft); + }); +}; +``` + +## `draft-sync.spec.ts` — unchanged + +`draft-sync.spec.ts` was not edited. It never called `resume()`/`load()` directly — its +coverage is the debounce, `submit()` (including the 409-recovery path, which exercises the +extracted `findConcept` indirectly through `ensureId`'s catch), and `flushPending`. All of +that stayed in `createDraftSync`, so the spec is unchanged and still exercises the wiring +between `createDraftSync` and the two new free functions (the 409-recovery test would fail if +that wiring were wrong). It passed unchanged, 8/8. + +## The new spec, and its verified red + +`find-concept.spec.ts` covers the branches CQ-001 named: + +- `findConcept`: match found → id returned; no match of that type → `undefined`; match found + but not `Concept` status → `undefined`; `adapter.list()` resolves to an unparsable shape + (`parseApplications` fails) → `undefined`; `adapter.list()` rejects → `undefined`. +- `loadConcept`: `Concept` with a draft → `{tag:'concept', draft}`; `Concept` with no draft → + `{tag:'concept', draft:null}`; a non-`Concept` status (e.g. `Ingediend`, submitted) → + `{tag:'not-concept'}`; `adapter.detail()` rejects (unknown/deleted id) → + `{tag:'not-concept'}`. + +**Verified red without the fix.** Used `Edit` (not `git checkout`) to invert one condition in +`loadConcept` — `dto.status.tag !== 'Concept'` → `dto.status.tag === 'Concept'` — reran `ng +test ssp --include find-concept.spec.ts`. Result: 3 of 9 tests failed — + +``` +loadConcept > reads the draft off a Concept + AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: { step: 1 } } +loadConcept > reports a missing draft as null + AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: null } +loadConcept > reports not-concept when the id has moved past Concept (submitted) + AssertionError: expected { tag: 'concept', draft: null } to deeply equal { tag: 'not-concept' } +``` + +Then used `Edit` again to flip the condition back to `!==`, reran the same command: 9/9 +green. `findConcept`'s and `loadConcept`'s other branches were not separately mutated — the +inverted condition alone was enough to prove the spec is sensitive to the extraction being +correct, and re-verifying full green after the revert confirmed no collateral change was left +in the file. + +## Scope held + +- No restructuring of the write path (`ensureId`, `flush`, `submit`, `reset`) — untouched + beyond the two call-site updates shown above. +- `applications.adapter.ts` was not split (CQ-002's "Not filed" note rules that out for this + design; out of scope here regardless). +- `resume()`'s semantics (URL-param precedence, the `resumeGate` release-in-`finally`, the + navigate-to-stamp-the-id side effect) are unchanged — only its two `findConcept()`/`load()` + calls now go through the free functions. +- No wire change, no DTO change, no behaviour change. + +## Verification + +`npm run ci` (foreground): **green** — lint, typecheck, `dep:check`, `format:check`, +`check:tokens`, `check:seam`, tests (ssp includes `find-concept.spec.ts` 9/9 new, +`draft-sync.spec.ts` 8/8 unchanged), `ng build --localize` (both apps), `npm audit`, backend +`dotnet test` (the known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure +is expected and outside `npm run ci`'s scope), `gen:snippets` drift clean, `gen:behaviour-spec` +drift clean once the regenerated file is committed alongside the code. Full counts are in the +commit's `npm run ci` run — see the session note for the exact step-by-step output. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index a334174..38e9cb9 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 451 frontend behaviours across +**is** the suite, reshaped for a business reader. 460 frontend behaviours across 9 contexts; 236 backend behaviours across 41 test classes. @@ -470,6 +470,14 @@ classes. - lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected - reference falls back to em dash for a Concept +#### findConcept + +- returns the id of the existing Concept of the given type +- returns undefined when the list has no application of the given type +- returns undefined when the matching type is not a Concept +- returns undefined when adapter.list() resolves with an unparsable shape +- returns undefined when adapter.list() rejects + #### hasProgress - is false for a fresh wizard @@ -486,6 +494,13 @@ classes. - derives the beroep from the chosen diploma and flags origin duo +#### loadConcept + +- reads the draft off a Concept +- reports a missing draft as null +- reports not-concept when the id has moved past Concept (submitted) +- reports not-concept when the id is unknown or deleted (detail rejects) + #### manual diploma fallback - KiesHandmatig flags handmatig with the maximal question set and no beroep yet From 4631556e6858bf0629c734759266799590ba9992 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 18:27:52 +0200 Subject: [PATCH 40/61] fix(backend): key IdempotencyStore on caller + idem key (RB-18) IdempotencyStore keyed a replayed submission on the raw Idempotency-Key header alone. Two different callers who send the same header value shared one cache slot: the second caller received the first caller's cached reference instead of running its own submission. Program.cs now composes the key as "{SubjectId}:{idemKey}" in the Submit helper, so the cache is scoped per caller. Add a test that proves a caller cannot replay another caller's idempotency key and receive their cached result. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 6 +- .../BigRegister.Tests/IdempotencyTests.cs | 25 ++++ .../refactor-backlog/99-backlog.md | 70 +++++------ .../refactor-backlog/implementation/rb-18.md | 115 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 3 +- 5 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index c80d2fe..ce38f5e 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -987,12 +987,14 @@ void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, Brief // generated reference and the caller's correlation id (the observability seam — a // real system ships this to structured logging / an audit store). A repeated // Idempotency-Key short-circuits to the first call's result — see IdempotencyStore -// — so a retried submit dedupes instead of minting a second reference. +// — so a retried submit dedupes instead of minting a second reference. The key is +// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same +// client-chosen header value do not share a cached result. IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList? documents = null) { var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k) - ? k.ToString() + ? $"{ctx.Caller().SubjectId}:{k}" : null; if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached)) diff --git a/backend/tests/BigRegister.Tests/IdempotencyTests.cs b/backend/tests/BigRegister.Tests/IdempotencyTests.cs index 91ca226..d86ba6e 100644 --- a/backend/tests/BigRegister.Tests/IdempotencyTests.cs +++ b/backend/tests/BigRegister.Tests/IdempotencyTests.cs @@ -45,6 +45,31 @@ public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie); } + // RB-18/BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so + // caller B replaying caller A's Idempotency-Key got caller A's cached reference back — + // a cross-caller leak of a value caller B never submitted. The store now keys on + // "{SubjectId}:{idemKey}", so the same header value from two different callers is two + // independent submissions. + [Fact] + public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result() + { + var sharedKey = Guid.NewGuid().ToString(); + + var callerARequest = ChangeRequestWithKey(sharedKey); + callerARequest.Headers.Add("X-Subject", "111222333"); + var callerA = await _client.SendAsync(callerARequest); + callerA.EnsureSuccessStatusCode(); + var callerABody = await callerA.Content.ReadFromJsonAsync(); + + var callerBRequest = ChangeRequestWithKey(sharedKey); + callerBRequest.Headers.Add("X-Subject", "999888777"); + var callerB = await _client.SendAsync(callerBRequest); + callerB.EnsureSuccessStatusCode(); + var callerBBody = await callerB.Content.ReadFromJsonAsync(); + + Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie); + } + [Fact] public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry() { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fcd230b..c8fa567 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **implemented** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md new file mode 100644 index 0000000..749c4ab --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md @@ -0,0 +1,115 @@ +# RB-18 — key `IdempotencyStore` on `{SubjectId}:{idemKey}` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-018 · +`00-baseline.md` §7 (`IdempotencyStore` listed among the 7 stores "Not behind any port"), +agent 02's `backend/Data` note ("no `Reset()` and no TTL") · `99-backlog.md` RB-18 + +## What was wrong + +`Data/IdempotencyStore.cs` is a process-global `Dictionary` keyed only on +the raw `Idempotency-Key` header value. `Program.cs`'s `Submit` helper read and wrote it +with that raw value, never composed with the caller's identity: + +```csharp +var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k) + ? k.ToString() + : null; +``` + +The client picks the header value. Two different callers who happen to send the same +value shared one cache slot: the second caller's request short-circuited to the first +caller's cached `IResult` instead of running its own submission. BIO-018 rates this +**severity low** — the cached value is only a `ReferentieResponse` (a reference number) or +a `ProblemDetails`, never personal data — but flags it as a defect in an access-control +path with a trivial fix. + +**Location check against the finding.** BIO-018 cites `Program.cs:901-909` for the read/ +write and `Data/IdempotencyStore.cs:11-27` for the store. RB-17 (landed the day before, +same file, unrelated change) shifted line numbers; the real call sites are +`Program.cs:994` (read) and `:1028` (write), inside the local `Submit` helper starting at +`:991`. The store file itself is untouched by RB-17 and matches the finding's shape +exactly. `Submit` has exactly one call site (`POST /change-requests`, `:239`) — the +`ChangeRequestRequest` → `telefoonwijziging` endpoint — so the scoping change lands on a +single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to +produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used +this helper in the first place, so its ordering benefit does not change what this ticket +touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped +its residual to "this ticket is unaffected by this split beyond it now landing on a +correctly write-only call set" — true, and the call set was already this one endpoint +before and after RB-17. + +## What changed + +| File | Change | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/BigRegister.Api/Program.cs` | `Submit`'s `idemKey` is now `$"{ctx.Caller().SubjectId}:{k}"` instead of the raw header value `k.ToString()`; doc comment above `Submit` states the scoping and cites RB-18/BIO-018 | +| `tests/BigRegister.Tests/IdempotencyTests.cs` | **new** `A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result` | + +`ctx.Caller()` (`Domain/Authorization/CallerIdentity.cs`) is already in scope in +`Program.cs` — `ctx.Zorgverlener()` is used elsewhere in the same file — and it throws if +the identity middleware did not run, so this composition cannot silently fall back to an +unscoped key. `SubjectId` is the BSN for a `ZorgverlenerCaller` and the medewerkerId for a +`MedewerkerCaller`; either way it is stable per caller and never empty. + +This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound, +no `Reset()`, no port/interface extraction. `IdempotencyStore`'s own `ponytail:` comment +("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory +leak at scale") is untouched — the store is still unbounded and still keyed on a +client-supplied string, only now composed with a server-resolved one first. The comment +stays accurate; this ticket did not touch the part it would need to correct. + +## The test + +`IdempotencyTests.cs` already existed (RB-17's predecessor work, not this ticket) with +three cases exercising same-caller replay/independence. Added a fourth: + +```csharp +[Fact] +public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result() +{ + var sharedKey = Guid.NewGuid().ToString(); + + var callerARequest = ChangeRequestWithKey(sharedKey); + callerARequest.Headers.Add("X-Subject", "111222333"); + var callerA = await _client.SendAsync(callerARequest); + callerA.EnsureSuccessStatusCode(); + var callerABody = await callerA.Content.ReadFromJsonAsync(); + + var callerBRequest = ChangeRequestWithKey(sharedKey); + callerBRequest.Headers.Add("X-Subject", "999888777"); + var callerB = await _client.SendAsync(callerBRequest); + callerB.EnsureSuccessStatusCode(); + var callerBBody = await callerB.Content.ReadFromJsonAsync(); + + Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie); +} +``` + +`X-Subject` is `StubIdentityProvider`'s existing header for setting the caller's BSN in a +test (the same idiom `ApplicationTests.cs` and `UploadAccessTests.cs` use), so caller A and +caller B are two different `ZorgverlenerCaller`s sending the identical `Idempotency-Key`. + +**Verified red without the fix.** Reverted `Program.cs`'s `idemKey` line to +`k.ToString()` with an `Edit` (not `git checkout`, so the rest of the working tree stayed +intact), reran `dotnet test --filter "FullyQualifiedName~IdempotencyTests"`: + +``` +Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms] + Error Message: + Assert.NotEqual() Failure: Strings are equal +Expected: Not "BIG-2026-476969" +Actual: "BIG-2026-476969" +Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4 +``` + +Caller B received caller A's cached reference. Then reapplied the fix with a second +`Edit` and reran: `Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4`. + +## Verification + +`dotnet test` (full suite): **261 passed, 1 failed** — the failure is +`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, +which needs a live OpenZaak container and fails identically on a stashed tree; it predates +this change and is not run by `npm run ci`. + +`npm run ci` (foreground): green — see the commit's own record for the full step list. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index a334174..f0d1d35 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 451 frontend behaviours across -9 contexts; 236 backend behaviours across 41 test +9 contexts; 237 backend behaviours across 41 test classes. ## Frontend (by context) @@ -1049,6 +1049,7 @@ classes. - Replaying the same idempotency key returns the same reference not a new one - Different idempotency keys are independent submissions +- A caller replaying another callers idempotency key does not get their cached result - A rejected submission replays the same rejection not a retry ### IntakeRuleTests From 25a5d415a5d21c3b141f766a2257c4d443930500 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 18:29:05 +0200 Subject: [PATCH 41/61] docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and ADR-C-009 The architect approved the four ADR-fix tickets. All four change what the architecture documents claim. No code changes. ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend. It rewrites against `backend/src/BigRegister.Api`. Every path it named is repointed. The out-of-scope list drops two discharged bullets: 33 `parse*` boundaries exist, and `npm run gen:api` is real. ADR-0001, ADR-C-003: a new section states that the generated client is the wire contract. A hand-written `contracts/*.dto.ts` is the exception for two cases only. The four survivors stay, because NSwag emits every property as optional and flattens `RegistrationStatusDto` into five optional strings. The `parse*` trust boundary stays mandatory, because a generated type is a compile-time claim about the wire and not a runtime guarantee. ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept the principle and changed its example to `skeleton` and `spinner`. Two of its claims were false and the amendment says so: `app-alert` wraps the vendored `.feedback` classes, and `site-header` composes the vendored `.titlebar`. ADR-0004, ADR-C-009: the exception section states a four-part test instead of one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07 gated this ticket, because clause 4 needs an audited allow path. RB-07 landed that, so the ADR does not ratify a control that the code lacks. Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md section 2 loses the false `alert` example. Section 4 gets the generated-client rule and the four-part test. Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that reads "SessionStore is in-memory". The session persists to `localStorage` now, so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4 and missed that the other half is equally false. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 24 +++- .../refactor-backlog/99-backlog.md | 8 +- .../refactor-backlog/_status.md | 17 +-- .../implementation/adr-c-001.md | 69 +++++++++++ .../implementation/adr-c-003.md | 64 ++++++++++ .../implementation/adr-c-007.md | 65 ++++++++++ .../implementation/adr-c-009.md | 71 +++++++++++ .../0001-bff-lite-decision-dtos.md | 111 +++++++++++++----- .../architecture/0003-cibg-huisstijl.md | 47 +++++--- .../architecture/0004-stamdata-as-code.md | 50 ++++++-- 10 files changed, 458 insertions(+), 68 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md diff --git a/CLAUDE.md b/CLAUDE.md index 8bc7e7f..3c7401a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ should be **composition of existing blocks** — adding building blocks is the exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API, -the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a -small hand-rolled surface built from the token bridge; see ADR-0003.) +the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`, +`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a +`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the +vendored `.feedback feedback-*` classes.) ### 3. State: make illegal states unrepresentable @@ -175,8 +177,14 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie for instant feedback, server re-validates as authority — e.g. scholing threshold). FE keeps only **format** validation, never as authority. -DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/` -validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend +The generated client +(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is** +the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written +`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types +it too loosely (the four survivors are all the latter — the generator emits every property as +optional and flattens unions); such a file must still import nothing. Either way a hand-written +`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain — +**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned rules live **only** on the server, with no FE mirror to drift from it — the FE may mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but @@ -185,8 +193,12 @@ never reimplements the _algorithm_. **Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the business controls (profession↔diploma map, thresholds, policy-question text) live as typed C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit -fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception -(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004. +fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate +exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in +code, an unknown key fails closed, the value is operational rather than a shared business rule, +and writes are admin-capability-gated **and** audited. Two surfaces pass it today — +`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in +SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004. ### 5. Testing diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fcd230b..e8ba3e0 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -251,10 +251,10 @@ diff** (CLAUDE.md's own precedence rule: "the docs win — update this file"). | ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status | | ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- | -| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | pending | -| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | pending | -| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | pending | -| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | pending | +| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | **done** | +| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | **done** | +| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | **done** | +| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | **done** | | **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** | **No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 4bc5e83..e7cb9f0 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,14 +14,15 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | -| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | +| CD batch | Tickets | Status | Notes | +| -------- | ------------------------------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | +| 4 | RB-18..RB-23 | in progress | Split into three waves to keep the merge order honest, because three of the six tickets touch `Program.cs`. **Wave A (dispatched, parallel):** RB-18, RB-20, RB-21, RB-22 — no file overlap between them. **Wave B:** RB-23, which must merge after RB-22 (expand/contract pair: the FE must tolerate the 404 before the BE returns it). **Wave C:** RB-19 alone and last — it is the only **High**-risk ticket, it reorders all 48 endpoints in `Program.cs`, and landing it last means it reorders the final content instead of conflicting with RB-18's and RB-23's edits to the same file. RB-19 also needs RB-12's route-table test as its safety net; read `rb-12.md` first, including its stated limitation that detection is a declaration, not a derivation. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | +| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md new file mode 100644 index 0000000..ef3c39a --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md @@ -0,0 +1,69 @@ +# ADR-C-001 — rewrite ADR-0001's worked example against the shipped system + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-001 + +## What was wrong + +ADR-0001's §"Worked example in this POC" opened with _"This POC has no real backend (static +mock JSON + fake submit timers), so the 'BFF output' is a static file"_. That premise is +false and every path the section cited was gone. The decision itself was intact; only the +description had drifted. + +## What changed + +| File | Change | +| -------------------------------------------------------- | --------------------------------------------------------------------------- | +| `docs/reference/architecture/0001-...md` §Worked example | rewritten against `backend/src/BigRegister.Api`; all six paths repointed | +| same file, §Out of scope here | 4 bullets → 2, plus a paragraph recording which two were discharged and why | + +No code changed. No CLAUDE.md edit was required for this finding. + +## Paths corrected, each verified + +| Claimed | Actual | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| "no real backend … static file" | `backend/src/BigRegister.Api`, `var api = app.MapGroup("/api/v1")` at `Program.cs:168` | +| `public/mock/dashboard-view.json` | `GET /api/v1/dashboard-view` (`Program.cs:172`) | +| `public/mock/intake-policy.json` | `GET /api/v1/intake/policy` (`Program.cs:193`) | +| `src/app/registratie/contracts/dashboard-view.dto.ts` | `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` | +| `src/app/registratie/infrastructure/dashboard-view.adapter.ts` | `apps/ssp/.../infrastructure/dashboard-view.adapter.ts`, `parseDashboardView` at `:50` | +| `src/app/herregistratie/contracts/intake-policy.dto.ts` | **deleted** — the DTO is now the generated `IntakePolicyDto`; the adapter is `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts` | + +`apps/ssp/public/mock/` does not exist (`ls`: no such directory). + +## The finding was wrong about one out-of-scope bullet + +ADR-C-001 said to _"reduce §Out of scope to the two items still genuinely open (the +`BigProfileStore` optimistic-update race, and session persistence / multi-tab sync)"_, +carrying the original bullet's parenthetical **"`SessionStore` is in-memory"**. That +parenthetical is no longer true, so the bullet could not be kept verbatim. + +- `apps/ssp/src/app/auth/application/session.store.ts:13` reads + `parseStoredPrincipal(localStorage.getItem(STORAGE_KEY))`, and `:41` writes it back. + Session persistence **has landed** (RB-10 extracted the parser, RB-13 renamed it + `parseStoredPrincipal`). The file even carries a `ponytail:` note explaining the choice of + `localStorage` over `sessionStorage`. +- Multi-tab sync has **not** landed: `grep` for a `storage` event listener across `apps` and + `libs` returns nothing. + +The bullet was therefore narrowed to multi-tab sync only, and states that the session itself +now persists. Recording this because the finding, taken literally, would have re-asserted a +false claim in the same edit that removed two others. + +The other two survivors were verified rather than assumed: `BigProfileStore` still holds +`pending` as a bare `signal(false)` with `begin`/`confirm`/`rollback` mutating it +(`big-profile.store.ts:61-74`), so the concurrent-submit race is real. + +## Discharged bullets, both verified + +- _"Runtime DTO validation on **every** endpoint (only the dashboard view has it)"_ — 33 + distinct `export function parse*` boundary functions exist across `apps` and `libs`. +- _"Real OpenAPI/TypeSpec codegen toolchain"_ — `npm run gen:api` (`package.json:12`) runs + `dotnet swagger tofile` then `nswag run`, emitting + `libs/shared/src/infrastructure/api-client.ts` (2329 lines). CI's `api-client-drift` job + regenerates and runs `git diff --exit-code` (`.github/workflows/ci.yml:319-321`). + +## Scope discipline + +Descriptive drift only, as the finding states. The decision, the options table, the two +policy shapes and the migration sequence are untouched. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md new file mode 100644 index 0000000..c1731df --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md @@ -0,0 +1,64 @@ +# ADR-C-003 — state that the generated client is the wire contract + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-003 + +## What was wrong + +ADR-0001 set "one source of truth that generates types for both sides" as the target state. +The code reached it. CLAUDE.md §4 still stated the pre-codegen rule — _"DTO lives in +`contracts/`"_ — as standing law, so §4 could be cited to justify both deleting the four +survivors and adding new hand-written DTOs for already-generated endpoints. + +## What changed + +| File | Change | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `docs/reference/architecture/0001-...md` | **new** §"Where the contract lives, after codegen" | +| `CLAUDE.md` §4 | the flat "DTO lives in `contracts/`" rule replaced with the generated-client rule + the two exceptions | + +No code changed. Per CLAUDE.md's own precedence rule, the ADR was amended first and +CLAUDE.md corrected to match, in one diff. + +## The decision the finding asked for: the four survivors stay + +ADR-C-003 required an explicit, recorded decision on the four remaining hand-written DTOs. +**They stay**, all four under exception case 2 ("the generator types the shape too loosely"). +This is not a preference — adopting the generated shapes would violate CLAUDE.md §3. + +Evidence. NSwag emits every property as optional, and flattens a discriminated union into a +bag of optional fields: + +| | generated (`api-client.ts`) | hand-written (`dashboard-view.dto.ts`) | +| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `DashboardViewDto` | `registration?`, `person?`, `decisions?` — all optional (`:2017`) | all three required | +| `RegistrationDto` | six optional fields (`:2202`) | six required fields | +| `RegistrationStatusDto` | **one flat record of five optional strings**, `tag?: string` (`:2211`) | a real union of three variants, `tag: 'Geregistreerd' \| 'Geschorst' \| 'Doorgehaald'`, per-variant fields required | + +The generated `RegistrationStatusDto` makes `{ tag: 'Geregistreerd', doorgehaaldOp: '…' }` +representable. That is precisely the illegal state CLAUDE.md §3 exists to forbid, and the +`parse*` boundary would have to reconstruct the union by hand anyway. + +The ADR therefore records that retiring these four is **not** a cleanup to schedule. It +becomes correct only if the backend annotates its DTOs so the generator emits required +properties and real unions — which names the actual prerequisite instead of leaving the +question open. + +## Verified counts, not carried over from the finding + +- Hand-written `contracts/*.dto.ts`: **4** — + `apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts` + and `libs/beheer/src/contracts/stamdata.dto.ts`. +- All four duplicate generated types **by the same names**: `BrpAddressDto` (`:1997`), + `DashboardViewDto` (`:2017`), `DuoLookupDto` (`:2056`), `DuoDiplomaDto` (`:2047`), + `PolicyQuestionDto` (`:2168`), `ManualDiplomaPolicyDto` (`:2106`), `StamdataColumnDto` + (`:2246`), `StamdataTableDto` (`:2253`), `StamdataTableSummaryDto` (`:2261`). None is a + codegen gap — the finding's "case 1" has no occupant today, which is worth knowing. +- The `parse*` boundary is restated as mandatory regardless of type provenance. The amendment + says why in one line: a generated type is a compile-time claim about the wire, not a + runtime guarantee. + +## Gate released + +ADR-C-003 blocked any ticket that would delete the four `contracts/*.dto.ts` files or add a +hand-written DTO for a generated endpoint. No open ticket needed it. The rule is now written +down, so a future one can be judged against it rather than against a stale §4. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md new file mode 100644 index 0000000..a3f2a3c --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md @@ -0,0 +1,65 @@ +# ADR-C-007 — repoint ADR-0003's WP-67 paths and fix its point 4 + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-007 + +## What was wrong + +Two separate defects in one ADR. Every file path in ADR-0003 predated WP-67's monorepo move, +and decision point 4 made a claim about `app-alert` that the code contradicts. + +## What changed + +| File | Change | +| ---------------------------------------- | ---------------------------------------------------------------------------------- | +| `docs/reference/architecture/0003-...md` | points 1, 2, 4 and both §Consequences bullets rewritten | +| `CLAUDE.md` §2 | the `alert` parenthetical corrected to `skeleton`/`spinner` + a denial for `alert` | + +No code changed. ADR first, CLAUDE.md to match, one diff. + +## Paths, each verified + +| Claimed | Actual | +| ------------------------ | ---------------------------------------------------------------------- | +| `src/styles.scss` | `libs/shared/styles.scss` — one copy, both apps' `angular.json:41,169` | +| `src/index.html` | `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html` | +| `.storybook/` | `.storybook-ssp/` and `.storybook-behandelportal/` | +| `src/docs/cibg-gaps.mdx` | `libs/shared/docs/cibg-gaps.mdx` | + +**One path in the finding's list needed no change.** ADR-C-007 implied point 1's +`public/cibg-huisstijl/` had moved with the rest. It has not: `public/` is still at the repo +root, and both apps' `angular.json` asset entries read `"input": "public"` (`:38`, `:166`). +Both Storybook configs serve it as `staticDirs: ['../public']`. Point 1's vendoring path is +left as written; only its `index.html` clause changed. + +## Point 4: the finding was right, and understated + +ADR-C-007 flagged the `.alert` half of point 4. Verified: `libs/shared/src/ui/alert/alert.component.ts` +documents itself as a _"Thin wrapper over the vendored `.feedback feedback-*` classes"_, its +template binds `.feedback-info/-success/-warning/-error`, its only local CSS is a 3-line flex +fix, and it carries **no** `CIBG-GAP EXTENSION` marker. `grep` confirms `feedback-error` is +present in `public/cibg-huisstijl/css/huisstijl.css` — the class is vendored, so `alert` is not +a gap. + +**The finding missed that the same sentence's second claim is also false.** Point 4 said "the +header/side-nav use `.nav` + a local blue bar". They do not: + +- `site-header.component.ts` composes the vendored `.titlebar` and `.logo__*` classes + (`grep` confirms `titlebar` in the vendored CSS) and its own comment says the titlebar + _"keeps its own robijn fill — `--ro-layout` — untouched"_. +- `shell.component.ts` emits only `.layout`, `.main`, `.content`, `.skip` — page scaffolding. +- No `.nav` class appears in either, and neither carries a gap marker. + +Both corrections are stated in the amended point 4 rather than silently dropped, so a reader +comparing the old text against the code can see which claim was retired and why. + +## Replacement example chosen + +`skeleton` and `spinner`, as the finding proposed. Both are in the gap register, both carry +markers reading "No loading-skeleton/spinner class in the vendored build", and both are +genuinely absent — the cleanest live illustration of the principle point 4 exists to state. + +## Noted, not fixed: the gap register is still one row short + +`grep` finds **9** `CIBG-GAP EXTENSION` markers; `libs/shared/docs/cibg-gaps.mdx` has **8** +rows. The missing one is `language-switcher`. That is **ADR-C-008 → RB-32** (batch 6), not +this ticket, and it was left alone. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md new file mode 100644 index 0000000..9ed51ca --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md @@ -0,0 +1,71 @@ +# ADR-C-009 — state the runtime-editable-config exception as a test, not a list + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-009 +· Gated on: **RB-07** (satisfied — batch 2) + +## What was wrong + +ADR-0004 said "never runtime-editable" and then named **one** exception in the singular, +justified narrowly ("specific to one sub-organization's identity"). WP-47 added a second +runtime-editable SQLite surface, `FeatureFlagStore`, whose own doc-comment states the +equivalence the ADR did not: _"SQLite-backed like `OrgTemplateStore`, same single-gate +idiom."_ + +The code is right; the ADR's text was wrong. A closed list of one leaves the next +operational-config surface with no principle to test itself against. + +## What changed + +| File | Change | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs/reference/architecture/0004-...md` | §"The deliberate exception: org-templates" → §"The deliberate exception: operational configuration" — a four-part test plus a table of the two passing surfaces | +| same file, §Context + the table | `src/locale/*.xlf` → `apps//src/locale/*.xlf` (two apps since WP-67) | +| `CLAUDE.md` §4 | the singular "Org-templates are the deliberate exception" replaced with the four-part test | + +No code changed — the finding says so outright, and verification confirmed it. + +## Why the RB-07 gate was real, verified clause by clause + +Clause (4) of the test is "writes are admin-capability-gated **and** audited". Signing this +ADR before RB-07 would have ratified a control the code did not implement. RB-07 has landed, +so the clause is now true. Read at `backend/src/BigRegister.Api/Program.cs:863-923`: each of +the five gates now computes `var ok = …`, calls `AuditAuthz(ctx, capability, resource, ok, +principal)` with the **real** boolean, and only then branches. `FlagsAdmin`'s own comment +names this ticket: _"this is the surface CQ-004/ADR-C-009 hinge on."_ + +All four clauses were checked against both surfaces rather than assumed: + +| Clause | `OrgTemplateStore` | `FeatureFlagStore` | +| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| (1) catalog in code | the `OrgTemplateDto` shape + `OrgTemplateRules` validate before save (`OrgTemplateStore.cs:49`) | `FeatureFlags.Catalog` (`Domain/Features/FeatureFlags.cs:15`) | +| (2) fails closed | unknown `subOrgId` → `null` → endpoint 404s (`:44-45,:55-56,:72-73,:94-96`) | `Set` returns false for an unlisted key (`:54`); `IsEnabled` returns false (`:42-43`) | +| (3) operational | one sub-organisation's letterhead | an on/off rollout switch | +| (4) gated + audited | `OrgAdmin` → `orgtemplate:edit` (`Program.cs:863`) | `FlagsAdmin` → `flags:manage` (`Program.cs:914`) | + +`FeatureFlagStore`'s own comment states clause (1) and (2) explicitly: _"The CATALOG … is +code … this store only holds the admin's on/off overrides. An unknown key is never +writable/enabled — the code catalog is the authority."_ + +## Judgement calls + +- **Clause (2) is about the write/enable path, not every read.** `OrgTemplateStore` has a + deliberate read-path fallback for briefs from before WP-23 (`:110-114`, its own `ponytail:` + comment): an empty `SubOrgId` falls back to the first seeded sub-org rather than failing a + whole screen. That is a preview convenience on a read; the four write entry points all + return `null` for an unknown sub-org. The clause is worded "cannot invent a setting, + enable a feature, or be written" so this read fallback is not caught by it. Recorded + because a reader checking clause (2) against `OrgTemplateStore.cs` will meet that + fallback first. +- **Org-templates' publish/rollback versioning is mentioned but excluded from the test.** It + is stronger than the test requires, and making it a fifth clause would block a legitimate + flag-style surface that has nothing to version. +- **The stale `src/locale/*.xlf` paths were fixed in the same diff**, though ADR-C-009 did + not flag them. They are two occurrences of the same WP-67 drift ADR-C-001 and ADR-C-007 + exist to correct, in the section being edited, and leaving a known-false path in a document + while amending it is the exact failure mode those two findings describe. Scope creep is + two words wide here; the alternative is filing a third ticket for it. + +## Gate released + +ADR-C-009 blocked "any ticket proposing a third runtime-editable config surface". Such a +ticket can now be judged against a written test rather than by analogy to org-templates. diff --git a/docs/reference/architecture/0001-bff-lite-decision-dtos.md b/docs/reference/architecture/0001-bff-lite-decision-dtos.md index 7e18872..7d68c69 100644 --- a/docs/reference/architecture/0001-bff-lite-decision-dtos.md +++ b/docs/reference/architecture/0001-bff-lite-decision-dtos.md @@ -69,44 +69,96 @@ the governance/transparency artifact. The frontend keeps only **format** validation (postcode shape, integer parsing) for instant feedback — never as the authority. +### Where the contract lives, after codegen + +The paragraph above says "manage it with one source of truth that generates types for +both sides". That target state has arrived, so this section states which artifact is now +the contract. + +**The generated client is the wire contract.** `libs/shared/src/infrastructure/api-client.ts` +is regenerated from the backend's OpenAPI document by `npm run gen:api`, and CI fails on +drift (the `api-client-drift` job regenerates it and runs `git diff --exit-code`). It is the +single source of truth for the shape of every endpoint. An adapter consumes its types +directly; 19 of the 20 infrastructure adapters do. + +**A hand-written `contracts/*.dto.ts` is the exception, for two cases only:** + +1. **Codegen does not reach the endpoint** — a hand-rolled `fetch`/XHR path that the + generator never sees. +2. **The generator types the shape too loosely** — the generated type compiles but is + weaker than the wire really is. + +In either case the hand-written file must still import nothing. It describes the wire, not +the domain. + +**The `parse*` trust boundary is unchanged and stays mandatory**, whichever way the type +arrived. A generated type is a compile-time claim about the wire, not a runtime guarantee: +the server can send anything. `infrastructure/` validates the untrusted shape and maps it +onto the domain, exactly as before. + +**The four surviving hand-written contracts stay.** They are +`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts` +and `libs/beheer/src/contracts/stamdata.dto.ts`. All four fall under case 2, and the +dashboard view shows why: the generator emits every property as optional, and it flattens +a discriminated union into a bag of optional fields. + +```ts +// generated — every field optional, `tag` a bare string, all variants merged +interface RegistrationStatusDto { + tag?: string | undefined; + herregistratieDatum?: string | undefined; + geschorstTot?: string | undefined; + reden?: string | undefined; + doorgehaaldOp?: string | undefined; +} + +// hand-written — a real discriminated union, per-variant fields required +type RegistrationStatusDto = + | { tag: 'Geregistreerd'; herregistratieDatum: string } + | { tag: 'Geschorst'; geschorstTot: string; reden: string } + | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string }; +``` + +Adopting the generated shape here would push `undefined` handling into every consumer and +make an illegal state representable, which CLAUDE.md §3 forbids. Retiring these four is +therefore **not** a cleanup to schedule; it becomes correct only if the backend annotates +its DTOs so the generator emits required properties and real unions. + ## Worked example in this POC -This POC has no real backend (static mock JSON + fake submit timers), so the -"BFF output" is a static file; the `decisions` block stands in for what the backend -would compute. Two slices were implemented to demonstrate **both** policy shapes: +Implemented against the real backend, `backend/src/BigRegister.Api`. Two slices demonstrate +**both** policy shapes. **A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).** -- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts` +- Endpoint: `GET /api/v1/dashboard-view` (`Program.cs`), one call replacing three. +- Contract: `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` (`DashboardViewDto` = registration + person + `decisions`). -- Endpoint: `public/mock/dashboard-view.json` (one call replaces three). - Boundary parse: `parseDashboardView()` in - `src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the - untrusted shape and maps DTO → domain (hand-written; no schema lib for one - contract). -- `BigProfileStore` now derives `profile` and `decisions` from the single - validated view (was a 3-resource `map2`). One request → one consistent snapshot. -- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of - computing it client-side. That rule is server-owned: it lives only in - `HerregistratieRule.cs`, with no FE mirror to drift from it (WP-75). -- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`, - `brp.json`) were deleted — those calls live behind the BFF now. + `apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the + untrusted shape and maps DTO → domain (hand-written; no schema lib). +- `BigProfileStore` derives `profile` and `decisions` from the single validated view (was a + 3-resource `map2`). One request → one consistent snapshot. +- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of computing + it client-side. That rule is server-owned: it lives only in `HerregistratieRule.cs`, with + no FE mirror to drift from it (WP-75). **B. Intake scholing threshold → config value.** -- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`. -- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`). +- Endpoint: `GET /api/v1/intake/policy` (`Program.cs`), serving + `IntakePolicy.ScholingThreshold`. +- Contract: the generated `IntakePolicyDto`; the adapter is + `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts`. - `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone; - `lageUren(a, scholingThreshold)` and validation take the value, which lives in - machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` - remains only as the offline fallback. + `lageUren(a, scholingThreshold)` and validation take the value, which lives in machine + state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` remains only + as the offline fallback. - `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`. - WP-69: the backend re-validates the threshold as the authority on submit — `IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit` - (intake-typed) writes anything, 400ing an incomplete scholing answer instead of - silently accepting a crafted POST that skips it. (WP-72 deleted the legacy - `POST /intakes` endpoint this once also covered — deleting the surface is a stronger - fix than 400ing on it.) + (intake-typed) writes anything, 400ing an incomplete scholing answer instead of silently + accepting a crafted POST that skips it. (WP-72 deleted the legacy `POST /intakes` endpoint + this once also covered — deleting the surface is a stronger fix than 400ing on it.) ## Migration sequence (for the real app) @@ -119,12 +171,17 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes ## Out of scope here (next steps, not built in the worked example) -- Runtime DTO validation on **every** endpoint (only the dashboard view has it). - Optimistic-update race fix in `BigProfileStore` (`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under concurrent submits). -- Session persistence / multi-tab sync (`SessionStore` is in-memory). -- Real OpenAPI/TypeSpec codegen toolchain. +- Multi-tab session sync. The session itself now persists (`localStorage`, read back + through `parseStoredPrincipal`), but a change in one tab does not reach another — no + `storage` listener exists. + +Two bullets were discharged and removed. Runtime DTO validation is no longer "only the +dashboard view": 33 `parse*` boundary functions exist. The OpenAPI codegen toolchain is +real: `npm run gen:api` generates `libs/shared/src/infrastructure/api-client.ts` and CI +drift-checks it. ponytail: build the pattern once on one slice; copy it across screens when the real backend lands, rather than scaffolding all of it up front. diff --git a/docs/reference/architecture/0003-cibg-huisstijl.md b/docs/reference/architecture/0003-cibg-huisstijl.md index 78edb54..8ddc4d5 100644 --- a/docs/reference/architecture/0003-cibg-huisstijl.md +++ b/docs/reference/architecture/0003-cibg-huisstijl.md @@ -19,28 +19,47 @@ layer — not a palette swap. ## Decision 1. **Vendor the package** under `public/cibg-huisstijl/` (not an npm dep — it was delivered as files), - loaded via a `` in `src/index.html` so the CSS's relative `url(../fonts|icons|images)` - references resolve at runtime. Storybook serves the same via `staticDirs`. -2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens + loaded via a `` in each app's `index.html` (`apps/ssp/src/index.html` and + `apps/behandelportal/src/index.html` — two since WP-67) so the CSS's relative + `url(../fonts|icons|images)` references resolve at runtime. `public/` stays at the repo + root and both apps' `angular.json` targets copy it. Both Storybook instances serve the + same via `staticDirs: ['../public']`. +2. **Token bridge over token rewrite.** `libs/shared/styles.scss` — one copy, both apps' + `angular.json` point at it (WP-67) — redefines the app's ~54 `--rhc-*` tokens onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and - keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from - `check:tokens`, so palette hex lives in that one file only.) + keeps the "components reference tokens" convention intact. (`libs/shared/styles.scss` is + exempt from `check:tokens`, so palette hex lives in that one file only.) 3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes (`app-button` → `btn btn-primary`, `text-input` → `form-control`, radio/checkbox → `form-check-*`); domain pages compose the same atoms and barely changed. -4. **Hand-roll what CIBG's build drops.** CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert` - is a small token-styled surface and the header/side-nav use `.nav` + a local blue bar. Local class - names that collide with Bootstrap components were renamed (`.card` → `.app-card`, badge → `.status-badge`). +4. **Hand-roll what CIBG's build drops, and mark it.** Where the vendored build has no class for a + concept, the component is a small token-styled surface carrying a `// CIBG-GAP EXTENSION:` marker. + The clearest live examples are `skeleton` and `spinner`: CIBG documents "Laadindicatie" but the + vendored build ships no loading-skeleton or loading-spinner class, so both are built from the token + bridge. Local class names that collide with Bootstrap components were renamed (`.card` → `.app-card`, + badge → `.status-badge`). + + Two claims this point used to make were wrong and are corrected here. **`.alert` is not a gap:** + `app-alert` is a thin wrapper over the vendored `.feedback feedback-*` classes — the design system + owns surface and icon, and the component adds only the icon's a11y label and a flex fix. It carries + no gap marker, correctly. **The header is not hand-rolled either:** `site-header` composes the + vendored `.titlebar` and `.logo__*` classes and leaves the robijn fill (`--ro-layout`) untouched. + The `shell` template's `.layout`/`.main`/`.content` classes are page scaffolding, not a substitute + for a missing design-system component, so they carry no marker either. + 5. **System-font stack; no licensed fonts.** `--bs-font-sans-serif` is overridden to `system-ui`; the licensed RO/Rijks **text** woffs are removed from the vendored copy (CIBG icon font kept). Logo stays a text wordmark. Interactivity stays Angular-driven (no Bootstrap JS). ## Consequences -- Wiring the design system touches `styles.scss` (token bridge), `index.html`, `angular.json` - (`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` + - `shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped. +- Wiring the design system touches `libs/shared/styles.scss` (token bridge), both apps' + `index.html`, both `angular.json` targets (`public/` already copied), and both Storybook config + dirs (`.storybook-ssp/` and `.storybook-behandelportal/` — separate since WP-67, because a single + merged tsconfig cannot resolve both apps' `@auth/*` at once) — plus the class strings in ~40 + `libs/shared/ui` + `libs/shared/layout` + a few domain components. The + `@rijkshuisstijl-community/*` deps are dropped. - `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply. - Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_ — Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied @@ -49,6 +68,6 @@ layer — not a palette swap. intentionally dropped, so we accept the warning. - Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope. - Hand-rolled components (point 4) are tracked in the **CIBG gap register** - (`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the - design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently - drifting. + (`libs/shared/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation + from the design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than + silently drifting. diff --git a/docs/reference/architecture/0004-stamdata-as-code.md b/docs/reference/architecture/0004-stamdata-as-code.md index 1e7d362..1a05976 100644 --- a/docs/reference/architecture/0004-stamdata-as-code.md +++ b/docs/reference/architecture/0004-stamdata-as-code.md @@ -20,7 +20,7 @@ was neither isolated nor validated: - All reference data and thresholds are **compiled-in C# constants**, served through screen-shaped BFF-lite endpoints; the frontend renders decisions and holds no reference data (ADR-0001). -- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a +- User-facing UI copy is already **`$localize`** (`apps//src/locale/*.xlf`) — git-tracked, and a second locale is a translation file, not a code change. That is already the compile-time model for text. - The profession↔diploma map lived as a _private_ `Dictionary` inside `DiplomaRules`, mixed @@ -62,17 +62,49 @@ production database, never runtime-editable. | Kind | Home | Gate | | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) | -| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | +| User-facing UI copy | `$localize` → `apps//src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | | Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests | -### The deliberate exception: org-templates +### The deliberate exception: operational configuration -Per-organization letterhead (return address, footer, signature, margins) **is** -runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is -intentional and does not contradict this ADR: it is _operational configuration_ owned by an -admin persona, versioned with publish/rollback inside the app, and specific to one -sub-organization's identity — not the shared business rules a wrong value would break for -everyone. Stamdata (the rules and reference tables the whole register runs on) stays code. +"Never runtime-editable" above is the rule for **stamdata** — the shared reference tables +and business rules the whole register runs on. It is not a ban on all persisted +configuration. Some configuration is operational rather than business-rule, and belongs to +an admin persona at runtime. + +This section states the **test** rather than a list, so the next surface can check itself +instead of arguing by analogy. Runtime-editable persistence is permitted only when all four +hold: + +1. **The catalog lives in code.** What may be set — the keys, the schema, the defaults, + the descriptions — is compiled in and reviewed through git. The store holds values, never + the definition of what a value means. +2. **An unknown or unlisted key fails closed.** A row the code catalog does not know cannot + invent a setting, enable a feature, or be written. A bad row is inert, not authoritative. +3. **The value is operational.** Per-organisation identity, or an on/off rollout switch — + not a shared business rule whose wrong value breaks the register for everyone. This is the + clause that keeps stamdata out. +4. **Writes are admin-capability-gated and audited.** The write path goes through an `Authz` + capability gate, and the gate records the decision — allow as well as deny — in + `AuthzAuditStore`. + +**Two surfaces pass this test today.** + +| Surface | (1) catalog in code | (2) fails closed | (3) operational | (4) gated + audited | +| ----------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | --------------------------------- | ------------------------------- | +| `OrgTemplateStore` (WP-23/26) | the `OrgTemplateDto` shape + `OrgTemplateRules` | unknown `subOrgId` → `null` → the endpoint 404s | one sub-organisation's letterhead | `OrgAdmin` → `orgtemplate:edit` | +| `FeatureFlagStore` (WP-47) | `Domain/Features/FeatureFlags.Catalog` | unknown key → `Set` returns false (404); `IsEnabled` → false | an on/off rollout switch | `FlagsAdmin` → `flags:manage` | + +Clause (4) became true for both only with RB-07, which moved `AuditAuthz` from each gate's +deny branch into the gate itself so the allow path is recorded too. Before that, both +surfaces were gated and **not** audited, and this ADR would have ratified a control the code +did not implement. + +Org-templates also carry publish/rollback versioning inside the app, which is stronger than +the test requires but not part of it. + +Stamdata itself — the rules and reference tables — fails clause (3) by construction and +stays code. ## Consequences From 7def4a7552490404f5305858347e73e2d332a667 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 18:32:51 +0200 Subject: [PATCH 42/61] fix(ssp): route cancel/delete through runSubmit, surface the error (RB-20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplicationsStore.cancel and AdminCasesStore.delete rolled an optimistic write back on failure but showed no message — a bare catch with no Result and no error channel (CQ-002). Both now call runSubmit and set a lastError signal on failure, mirroring createSubmitChangeRequest in the same folder. Each page renders the error with the existing app-alert atom, the same pattern brief.page.ts already uses for lastError. Added a spec file for ApplicationsStore (none existed) and extended AdminCasesStore's spec, each asserting the rollback AND the surfaced error. Verified both new assertions fail without the fix (an Edit undo/redo of the store method, not git checkout). Regenerated libs/shared/docs/behaviour-spec.mdx (gen:behaviour-spec) to pick up the new/renamed test names. Marked RB-20 done in 99-backlog.md and recorded the change in implementation/rb-20.md. Co-Authored-By: Claude Opus 5 --- .../application/admin-cases.store.spec.ts | 25 +++- .../application/admin-cases.store.ts | 21 +++- .../application/applications.store.spec.ts | 77 ++++++++++++ .../application/applications.store.ts | 20 ++- .../app/registratie/ui/admin-cases.page.ts | 3 + .../src/app/registratie/ui/dashboard.page.ts | 5 + .../refactor-backlog/99-backlog.md | 2 +- .../refactor-backlog/implementation/rb-20.md | 117 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 12 +- 9 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 apps/ssp/src/app/registratie/application/applications.store.spec.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts index 4b757dd..ffab1b2 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi } from 'vitest'; +import { SUBMIT_FAILED } from '@shared/application/submit'; import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; import { AdminCasesStore } from './admin-cases.store'; @@ -43,7 +44,10 @@ describe('AdminCasesStore', () => { expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); }); - it('rolls back the removal when the delete fails', async () => { + // RB-20: a failed delete must not be silent — the row rolls back AND the store + // surfaces the error the page renders. Before RB-20 this only rolled back + // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. + it('rolls back the removal and surfaces the error when the delete fails', async () => { const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny }); await store.load(); @@ -51,5 +55,24 @@ describe('AdminCasesStore', () => { await store.delete('a'); const s = store.cases(); expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears + expect(store.lastError()).toBe(SUBMIT_FAILED); + }); + + it('clears a stale error on the next delete attempt', async () => { + const deleteAny = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(undefined); + const store = setup({ + listAll: () => Promise.resolve([summary('a'), summary('b')]), + deleteAny, + }); + await store.load(); + + await store.delete('a'); + expect(store.lastError()).toBe(SUBMIT_FAILED); + + await store.delete('b'); + expect(store.lastError()).toBeNull(); }); }); diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.ts index 943dcb4..b4580b9 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.ts @@ -1,5 +1,6 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { ApplicationsAdapter, @@ -12,8 +13,9 @@ type Err = Error | undefined; * Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office * counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton * owns the list as a writable RemoteData signal, delete removes the row synchronously - * (optimistic) and rolls back on error. Admin delete removes any case (any owner, - * submitted or not — the server enforces the capability). + * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on + * failure (RB-20). Admin delete removes any case (any owner, submitted or not — the + * server enforces the capability). */ @Injectable({ providedIn: 'root' }) export class AdminCasesStore { @@ -22,6 +24,11 @@ export class AdminCasesStore { private state = signal>({ tag: 'Loading' }); readonly cases = this.state.asReadonly(); + /** Set on a failed delete (RB-20): the optimistic removal already rolled back by + then, this is only the message for the alert the page renders above the list. */ + private error = signal(null); + readonly lastError = this.error.asReadonly(); + /** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the last-good value on a resync (only shows Loading on the first load). */ async load() { @@ -42,16 +49,18 @@ export class AdminCasesStore { void this.load(); } - /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */ + /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error + AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */ async delete(id: string) { const before = this.state(); if (before.tag === 'Success') { this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); } - try { - await this.adapter.deleteAny(id); - } catch { + this.error.set(null); + const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED); + if (!r.ok) { this.state.set(before); // roll back: the row reappears + this.error.set(r.error); } } } diff --git a/apps/ssp/src/app/registratie/application/applications.store.spec.ts b/apps/ssp/src/app/registratie/application/applications.store.spec.ts new file mode 100644 index 0000000..f8860af --- /dev/null +++ b/apps/ssp/src/app/registratie/application/applications.store.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, vi } from 'vitest'; +import { SUBMIT_FAILED } from '@shared/application/submit'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { ApplicationsStore } from './applications.store'; + +const summary = (id: string) => ({ + id, + type: 'registratie', + status: { tag: 'Concept', stepIndex: 0, stepCount: 3 }, + documentIds: [], + createdAt: '2026-07-23T10:00:00Z', + updatedAt: '2026-07-23T10:00:00Z', +}); + +function setup(adapter: Partial): ApplicationsStore { + TestBed.configureTestingModule({ + providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + }); + // The store's own constructor kicks off `load()` (dashboard revisit refresh) — + // give every test a `list` so that initial call has something to resolve. + return TestBed.inject(ApplicationsStore); +} + +describe('ApplicationsStore', () => { + it('loads and parses the list', async () => { + const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) }); + await store.load(); + const s = store.applications(); + expect(s.tag).toBe('Success'); + expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']); + }); + + it('cancels optimistically and confirms via the DELETE endpoint', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + const store = setup({ + list: () => Promise.resolve([summary('a'), summary('b')]), + cancel, + }); + await store.load(); + + await store.cancel('a'); + expect(cancel).toHaveBeenCalledWith('a'); + const s = store.applications(); + expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']); + expect(store.lastError()).toBeNull(); + }); + + // RB-20: a failed cancel must not be silent — the row rolls back AND the store + // surfaces the error the page renders. Before RB-20 this only rolled back + // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. + it('rolls back the removal and surfaces the error when the cancel fails', async () => { + const cancel = vi.fn().mockRejectedValue(new Error('boom')); + const store = setup({ list: () => Promise.resolve([summary('a')]), cancel }); + await store.load(); + + await store.cancel('a'); + const s = store.applications(); + expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears + expect(store.lastError()).toBe(SUBMIT_FAILED); + }); + + it('clears a stale error on the next cancel attempt', async () => { + const cancel = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(undefined); + const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]), cancel }); + await store.load(); + + await store.cancel('a'); + expect(store.lastError()).toBe(SUBMIT_FAILED); + + await store.cancel('b'); + expect(store.lastError()).toBeNull(); + }); +}); diff --git a/apps/ssp/src/app/registratie/application/applications.store.ts b/apps/ssp/src/app/registratie/application/applications.store.ts index db96201..5157dad 100644 --- a/apps/ssp/src/app/registratie/application/applications.store.ts +++ b/apps/ssp/src/app/registratie/application/applications.store.ts @@ -1,5 +1,6 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { ApplicationsAdapter, @@ -15,7 +16,8 @@ type Err = Error | undefined; * the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is - * computed server-side on read). + * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus + * surfaces `lastError` on failure (RB-20). */ @Injectable({ providedIn: 'root' }) export class ApplicationsStore { @@ -24,6 +26,11 @@ export class ApplicationsStore { private state = signal>({ tag: 'Loading' }); readonly applications = this.state.asReadonly(); + /** Set on a failed cancel (RB-20): the optimistic removal already rolled back by + then, this is only the message for the alert the page renders above the list. */ + private error = signal(null); + readonly lastError = this.error.asReadonly(); + constructor() { void this.load(); } @@ -50,16 +57,19 @@ export class ApplicationsStore { } /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. - No resync — the delete succeeded, so the optimistic removal is authoritative. */ + No resync — the delete succeeded, so the optimistic removal is authoritative. On + failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the + user guessing why the block came back. */ async cancel(id: string) { const before = this.state(); if (before.tag === 'Success') { this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); } - try { - await this.adapter.cancel(id); - } catch { + this.error.set(null); + const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED); + if (!r.ok) { this.state.set(before); // roll back: the block reappears + this.error.set(r.error); } } } diff --git a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts index aa088a4..8f83fad 100644 --- a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts +++ b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts @@ -42,6 +42,9 @@ import { AdminCasesStore } from '@registratie/application/admin-cases.store'; } @else if (!canManage()) { {{ deniedText }} } @else { + @if (store.lastError(); as err) { + {{ err }} + } {{ failedText }} diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts index 1e17309..86c5a35 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts @@ -51,6 +51,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks'; intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken." >
+ @if (cancelError(); as err) { + {{ err }} + } @if (aanvragen().length) {
@for (a of concepten(); track a.id) { @@ -260,6 +263,8 @@ export class DashboardPage { protected cancelAanvraag(a: Aanvraag) { void this.apps.cancel(a.id); } + /** RB-20: the message from a failed cancel, rendered above the list. */ + protected cancelError = computed(() => this.apps.lastError()); /** Server-computed eligibility (rendered, not recomputed). */ private readonly eligible = computed(() => { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fcd230b..e1ededd 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -121,7 +121,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | | **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | | **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | | **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | | **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | | **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md new file mode 100644 index 0000000..c4b242b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md @@ -0,0 +1,117 @@ +# RB-20 — route `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`, surface the error + +Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-002 · +`00-baseline.md` BL-007 · `99-backlog.md` RB-20 · SIGN-OFF: consolidation approved +2026-08-27, HALT lifted + +## What was wrong + +`ApplicationsStore.cancel` and `AdminCasesStore.delete` both owned an optimistic write next +to their `RemoteData` read, and both reached `ApplicationsAdapter` directly instead of going +through `runSubmit` (the fold + Idempotency-Key mint every other mutation in the repo uses, +including `createSubmitChangeRequest` in the same folder). The failure path was a bare +`catch { this.state.set(before); }`: a failed cancel or delete rolled the row back, but the +user saw no message at all — no `ActionState`, no ProblemDetails `detail`, nothing. The +`Idempotency-Key` on the wire was also a fresh UUID per HTTP attempt (minted by +`api-client.provider.ts`'s default), not the per-logical-submit key `runSubmit` promises — +harmless today only because `Program.cs` happens to ignore the header outside the `Submit` +helper (CQ-005's note). + +## What changed + +CQ-002's option (a) — the smallest fix, applied identically to both stores. No new command +factory, no adapter split (CQ-002's own "Not filed" note reserves that split for option (b), +which this ticket does not take). + +| File | Change | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/registratie/application/applications.store.ts` | `cancel` now calls `runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED)`; added a private `error` signal, exposed read-only as `lastError`. On failure: roll back AND `this.error.set(r.error)`. On the next attempt, the error is cleared before the call so a stale message never survives a fresh action. | +| `apps/ssp/src/app/registratie/application/applications.store.spec.ts` (new) | 4 specs: load+parse, optimistic cancel, roll-back-and-surface-error on failure, stale-error-clears-on-next-attempt. No spec file existed for this store before RB-20. | +| `apps/ssp/src/app/registratie/application/admin-cases.store.ts` | Same shape as `applications.store.ts`: `delete` through `runSubmit`, `error`/`lastError` signal pair. | +| `apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts` | Existing "rolls back … when the delete fails" spec extended to also assert `lastError()`; one new stale-error-clears spec added. | +| `apps/ssp/src/app/registratie/ui/dashboard.page.ts` | One `@if (cancelError(); as err) { {{ err }} }` above the aanvragen list, mirroring `brief.page.ts`'s `lastError` rendering. `cancelError` is a `computed(() => this.apps.lastError())`. | +| `apps/ssp/src/app/registratie/ui/admin-cases.page.ts` | Same `@if (store.lastError(); as err) { {{ err }} }`, placed above `` inside the `canManage()` branch (`store` was already `protected`, so no new exposure needed). | + +`applications.adapter.ts` (`cancel`, `deleteAny`) is **unchanged** — the fix is entirely in +the two stores, which now wrap the existing thin adapter calls in `runSubmit` at the call +site, exactly as `createSubmitChangeRequest` wraps `ChangeRequestAdapter.changeRequest`. The +adapter methods still return a bare `Promise`; `runSubmit` is what folds that into a +`Result`. + +Neither UI change introduces a new user-facing string: the rendered text is either the +existing `SUBMIT_FAILED` constant (`@@submit.failed`, already translated in +`messages.en.xlf` since RB-17) or, when the backend sends one, a ProblemDetails `detail` +string carried verbatim from the server — never a new `$localize` id. `messages.en.xlf` did +not need a new ``. + +## The tests, and their red failures + +Both specs assert `store.lastError()` after a rejected adapter call, which only the fix can +satisfy — the old bare `catch { this.state.set(before) }` never touched an error signal, so +`lastError()` stayed `null` forever. + +**Verified red without the fix** (an `Edit` undo of the store method, not `git checkout`, so +the rest of the change — imports, the other store, the UI, the specs — stayed in place): + +- `applications.store.ts`: reverted `cancel` to `try { await this.adapter.cancel(id); } catch +{ this.state.set(before); }`. Reran `ng test ssp --include applications.store.spec.ts`: + 2 of 4 failed — + `rolls back the removal and surfaces the error when the cancel fails` and + `clears a stale error on the next cancel attempt`, both with + `AssertionError: expected null to be 'Het indienen is niet gelukt. Probeer het later opnieuw.'`. + The other two specs (load, optimistic-cancel-success) stayed green, as expected — they + don't touch the error path. Re-applied the fix (`Edit` back to the `runSubmit` version); + reran: 4/4 green. +- `admin-cases.store.ts`: same procedure on `delete`. Reran + `ng test ssp --include admin-cases.store.spec.ts`: 2 of 4 failed with the identical + `expected null to be '...'` shape. Reverted to the fix; reran: 4/4 green. + +## Judgement calls + +- **Signal naming**: private backing field `error`, public readonly `lastError` — matching + the name `BriefStore`/`OrgTemplateStore` already expose for exactly this purpose (CQ-002's + own citation), rather than inventing a new name per store. +- **Error cleared at the start of each write**, not only on success, so a second cancel/delete + attempt after a failure doesn't leave a stale banner up if the retry itself is still in + flight. Covered by the "clears a stale error on the next attempt" spec in each file. +- **No `ActionState`/`SaveState` pair** (the fuller shape `BriefStore` uses for busy-state and + save-state together) — CQ-002 explicitly scoped option (a) to "one `error` signal", and + neither store needs a busy indicator: the row already disappears optimistically the instant + the click happens, so there is nothing for a spinner to cover. +- **UI placement**: one alert per page, above the list the mutated row belongs to, using the + same `@if (x(); as err) { {{ err }} }` shape as + `brief.page.ts` — composition of an existing atom, no new building block (CLAUDE.md §2). +- **`applications.adapter.ts` left untouched, on purpose** — CQ-002's "Not filed" note ties + the read/write file split to option (b) only; taking option (a) means this ticket changes + no adapter code at all, matching the ticket's own framing ("(a) touches 2 files plus a UI + line each"). + +## Ticket accuracy + +CQ-002's description matched the code as found: both stores' `cancel`/`delete` reached the +adapter directly with a bare `catch { this.state.set(before); }`, no `Result`, no error +channel — no discrepancy to flag. + +## Residuals (not this ticket) + +- RB-18 (key `IdempotencyStore` on `{SubjectId}:{idemKey}`) is unaffected: `cancel`/`delete` + now mint a key through `runSubmit` like every other mutation, so it lands on the same + write-only call set RB-18 already targets. +- RB-21 (extract `createDraftSync`'s read half) is a separate CQRS-light finding in the same + context, untouched by this ticket. + +## Verification + +`npm run ci` (foreground, `timeout: 600000`): **green** — `✔ local CI passed`. Lint, +typecheck, `dep:check` (342 + 226 modules, 0 violations), `format:check`, `check:tokens`, +`check:seam`, tests (ssp 263/263 — 5 more than the pre-RB-20 258, from the new/extended +specs above — behandelportal 37/37, shared 138/138, beheer 23/23), `ng build --localize` +(both apps), `npm audit` (0 vulnerabilities), backend `dotnet format --verify-no-changes` + +`dotnet test --filter "Category!=Integration"` (260/260 — this filter is what keeps the +known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent test, which needs a live +OpenZaak container, out of `npm run ci` entirely; it is a standing caveat, not introduced by +this change, and not exercised by this run), backend dependency audit (0 vulnerable +packages), `gen:snippets` / `gen:behaviour-spec` / `gen:api` drift checks all clean once the +regenerated `behaviour-spec.mdx` was staged alongside the code (the local gate's +`git diff --exit-code` compares the working tree to the index, so it is clean once the file +is staged — this is the documented pre-commit behaviour from RB-17's note, not a defect). diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index a334174..eeafe30 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 451 frontend behaviours across +**is** the suite, reshaped for a business reader. 456 frontend behaviours across 9 contexts; 236 backend behaviours across 41 test classes. @@ -406,7 +406,15 @@ classes. - loads and parses the cross-owner list - deletes optimistically and confirms via the admin endpoint -- rolls back the removal when the delete fails +- rolls back the removal and surfaces the error when the delete fails +- clears a stale error on the next delete attempt + +#### ApplicationsStore + +- loads and parses the list +- cancels optimistically and confirms via the DELETE endpoint +- rolls back the removal and surfaces the error when the cancel fails +- clears a stale error on the next cancel attempt #### STEPS (fixed) From 7a29f5facc0a4dac38f0283e8241bcb342280b86 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 18:43:27 +0200 Subject: [PATCH 43/61] feat(brief): tolerate a 404 on GET /brief with a one-shot reset (RB-22) BriefStore.load() now treats a 404 from GET /brief as "no brief exists yet" and calls the existing reset() command once, instead of showing the generic load-failed error. BriefAdapter.load() gains a BriefLoadFailure error channel (notFound | error) so the store can tell a 404 apart from every other failure; every other adapter method stays on runSubmit, unchanged. The once-only bound is a field on the store, not a comment: a second 404 (from a later load() call) always falls through to the ordinary error path, and the recovery path never calls load() again, so no loop can form. This is the expand half of CQ-007's split (04-cqrs-light.md). Today's backend never 404s GET /brief, so the new branch is dead code until RB-23 (the backend contract half) ships in a later merge. Co-Authored-By: Claude Opus 5 --- .../app/brief/application/brief.store.spec.ts | 73 +++++++-- .../src/app/brief/application/brief.store.ts | 37 ++++- .../app/brief/infrastructure/brief.adapter.ts | 42 +++++- .../refactor-backlog/99-backlog.md | 70 ++++----- .../refactor-backlog/implementation/rb-22.md | 139 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 7 +- 6 files changed, 309 insertions(+), 59 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts index 145994a..b96941a 100644 --- a/apps/ssp/src/app/brief/application/brief.store.spec.ts +++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts @@ -3,7 +3,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { Result } from '@shared/kernel/fp'; import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief'; import { OrgTemplate } from '@brief/domain/org-template'; -import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; +import { + BRIEF_LOAD_FAILED, + BriefAdapter, + BriefLoadFailure, + BriefView, +} from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { BriefStore } from './brief.store'; @@ -60,7 +65,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, }; const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve({ ok: true, value: approved }), @@ -79,7 +85,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, }; const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve({ ok: true, value: approved }), @@ -93,7 +100,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { it('goes Busy then Failed on a failing transition, surfacing the error', async () => { const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve({ ok: false, error: 'niet toegestaan' }), @@ -108,7 +116,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { it('a subsequent successful transition clears a prior Failed state', async () => { let approveResult: Result = { ok: false, error: 'eerste poging mislukt' }; const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve(approveResult), }); @@ -156,8 +165,10 @@ function loadedBrief(store: BriefStore): Brief { } async function loadedStore(over: Partial = {}): Promise { - const ok = (v: BriefView): Promise> => - Promise.resolve({ ok: true, value: v }); + // Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one + // helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error + // channel `string`) — it only ever produces the `ok: true` branch. + const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over }); await store.load(); return store; @@ -255,8 +266,7 @@ describe('BriefStore rejection diff', () => { ...filledBrief, status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' }, }; - const ok = (v: BriefView): Promise> => - Promise.resolve({ ok: true, value: v }); + const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const); const store = setup({ load: () => ok({ ...filledView, brief: submitted }), save: () => ok(filledView), @@ -283,7 +293,8 @@ describe('BriefStore.previewLetter', () => { it('opens the composed letter in a new tab on success', async () => { const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), }); await store.load(); const blob = new Blob([''], { type: 'text/html' }); @@ -301,7 +312,8 @@ describe('BriefStore.previewLetter', () => { it('surfaces the error without opening a tab on failure', async () => { const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), }); await store.load(); const open = vi.spyOn(window, 'open').mockImplementation(() => null); @@ -377,3 +389,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => { expect(save).not.toHaveBeenCalled(); }); }); + +// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the +// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds +// that); this fake adapter is what exercises the branch until then. --- + +describe('BriefStore.load — 404 tolerance (RB-22)', () => { + const notFound: Result = { ok: false, error: { tag: 'notFound' } }; + const resetOk: Result = { ok: true, value: view }; + + it('a 404 drives exactly one reset(), which populates the store', async () => { + // Given GET /brief 404s (no brief exists yet) and reset() succeeds. + const load = vi.fn(() => Promise.resolve(notFound)); + const reset = vi.fn(() => Promise.resolve(resetOk)); + const store = setup({ load, reset }); + + // When the store loads... + await store.load(); + + // Then reset() ran exactly once, and the store ends up loaded from its result. + expect(reset).toHaveBeenCalledTimes(1); + expect(store.model().tag).toBe('loaded'); + }); + + it('a second 404 does not drive a second reset()', async () => { + // Given every load() attempt 404s (e.g. the brief still fails to appear). + const load = vi.fn(() => Promise.resolve(notFound)); + const reset = vi.fn(() => Promise.resolve(resetOk)); + const store = setup({ load, reset }); + + // When the store loads twice... + await store.load(); + await store.load(); + + // Then reset() ran exactly once — the once-only bound holds across calls, not + // just within one — and the second 404 surfaces as an ordinary load failure. + expect(reset).toHaveBeenCalledTimes(1); + expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED }); + }); +}); diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 88c2a1c..6bb8073 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -16,7 +16,7 @@ import { import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff'; import { OrgTemplate } from '@brief/domain/org-template'; -import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; +import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { uploadContentUrl } from '@shared/upload/upload.adapter'; @@ -119,13 +119,40 @@ export class BriefStore implements PendingSave { return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); }); + /** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand + half — see `recoverFromMissingBrief`). This is the structural once-only bound: + a repeated 404 falls straight to the `error` branch below and can never reach + `adapter.reset()` a second time, regardless of how many times `load()` runs. */ + private hasRecoveredFromMissingBrief = false; + async load() { const r = await this.adapter.load(); if (r.ok) { - this.orgTemplate.set(r.value.orgTemplate); - this.caseContext.set(r.value.caseContext); - this.history.clear(); - this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); + this.applyLoadedView(r.value); + } else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) { + this.hasRecoveredFromMissingBrief = true; + await this.recoverFromMissingBrief(); + } else { + const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason; + this.store.dispatch({ tag: 'BriefLoadFailed', reason }); + } + } + + private applyLoadedView(view: BriefView) { + this.orgTemplate.set(view.orgTemplate); + this.caseContext.set(view.caseContext); + this.history.clear(); + this.store.dispatch({ tag: 'BriefLoaded', ...view }); + } + + /** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the + existing `reset()` command directly (the same POST `resetDemo()` uses) and + applying whatever it returns; this NEVER calls `load()` again, so a second 404 + (e.g. `reset()` itself failing) cannot loop back into this method. */ + private async recoverFromMissingBrief() { + const r = await this.adapter.reset(); + if (r.ok) { + this.applyLoadedView(r.value); } else { this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); } diff --git a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts index ad3aec3..02d3fad 100644 --- a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { runResult, runSubmit } from '@shared/application/submit'; +import { runSubmit } from '@shared/application/submit'; +import { problemDetail } from '@shared/infrastructure/api-error'; import { ApiClient, BriefDecisionsDto, @@ -33,9 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric * The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire * uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention); * the `parse*` boundary narrows them into the domain's proper discriminated unions - * and rejects malformed shapes. `load` (the only read) folds through `runResult`; - * every mutation folds through `runSubmit` (ProblemDetails → error string, plus the - * Idempotency-Key mint), then parses the returned brief. + * and rejects malformed shapes. Every mutation folds through `runSubmit` + * (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the + * returned brief. `load` (the only read) does its own try/catch instead of the + * shared `runResult` fold, because it needs one extra bit `runResult` throws away: + * whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's + * expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the + * `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. */ export interface BriefView { @@ -46,16 +51,39 @@ export interface BriefView { readonly caseContext: CaseContext; } +/** + * Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept + * distinct from every other failure so `BriefStore.load()` can tolerate it (call + * `reset()` instead of showing an error banner) without conflating it with a real + * failure. See the class docstring above. + */ +export type BriefLoadFailure = + { readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string }; + export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`; export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`; +/** True when the thrown value carries an HTTP 404 status — matches both the + generic `SwaggerException` (today's shape, since `GET /brief` declares no 404 + response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once + RB-23 gives the endpoint a documented 404 response). */ +function isHttpNotFound(e: unknown): boolean { + return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; +} + @Injectable({ providedIn: 'root' }) export class BriefAdapter { private client = inject(ApiClient); - async load(): Promise> { - const r = await runResult(() => this.client.briefGET(), BRIEF_LOAD_FAILED); - return r.ok ? parseBriefView(r.value) : r; + async load(): Promise> { + try { + const dto = await this.client.briefGET(); + const parsed = parseBriefView(dto); + return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error }); + } catch (e) { + if (isHttpNotFound(e)) return err({ tag: 'notFound' }); + return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) }); + } } async save(sections: readonly LetterSection[]): Promise> { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fcd230b..e47ed4e 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **implemented** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md new file mode 100644 index 0000000..bce59a1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md @@ -0,0 +1,139 @@ +# RB-22 — `BriefStore.load()` tolerates a 404, calling `reset()` exactly once + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 · +`99-backlog.md` RB-22, "Tickets that were rejected and split" · `implementation/rb-17.md` +(the `runResult`/`runSubmit` seam this store already sits on) + +This is the **expand** half of an expand/contract pair. RB-23 (backend: `GET /brief` 404s +when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`) ships after this +ticket, in a later merge. Today's backend never 404s `GET /brief`, so this ticket's new +branch is dead code in the running app — provably backend-frontend-safe by construction. + +## What was wrong + +CQ-007 flags `GET /brief` (`Program.cs:603` → `BriefStore.GetOrCreate`, +`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a +persisted write, breaking the read/write split every other endpoint respects. The fix is +split across both sides of the seam because the FE must be ready to receive a 404 before +the backend can safely start sending one. This ticket is the FE half: `BriefStore.load()` +(`apps/ssp/src/app/brief/application/brief.store.ts`) had no notion of "no brief exists +yet" — every adapter failure, 404 included, dispatched `BriefLoadFailed` and showed the +generic error banner. `BriefAdapter.load()` (`brief.adapter.ts`) also had no way to tell +the store a failure was specifically an HTTP 404: it folded every failure through the +shared `runResult` helper (RB-17), which keeps only a human-readable string and throws +away the HTTP status. + +The ticket read as filed against the current code: `BriefStore.load()` is exactly where +CQ-007 says it is, `BriefAdapter.load()` is exactly the read `runResult` call RB-17 pointed +at it, and nothing about either file was factually wrong. Nothing to flag here. + +## What changed + +| File | Change | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()`'s error channel becomes `BriefLoadFailure` (`{tag:'notFound'} \| {tag:'error', reason:string}`) instead of a plain `string`. `load()` no longer routes through the shared `runResult` — it does its own try/catch so it can read the thrown value's HTTP `status` before folding it away, via the new local `isHttpNotFound` predicate. Every other method (`save`/`submit`/`approve`/`reject`/`send`/`reset`) is untouched, still on `runSubmit`. | +| `apps/ssp/src/app/brief/application/brief.store.ts` | `load()` branches on `BriefLoadFailure`: `notFound` (and not already recovered) calls the existing `reset()` command directly and applies its result; every other failure (including a repeated `notFound`) dispatches `BriefLoadFailed` as before. Extracted `applyLoadedView` (the success-path body shared by `load()` and the new recovery path) and added `recoverFromMissingBrief`. | +| `apps/ssp/src/app/brief/application/brief.store.spec.ts` | New `describe('BriefStore.load — 404 tolerance (RB-22)')` with the two required cases. Six pre-existing `load:` fakes' explicit `Result` return-type annotations updated to `Result` (they only ever produce the `ok: true` branch, so this is a type-only change); two shared `ok(v)` test helpers that build fakes for both `load` and `save` had their return-type annotation dropped in favour of `as const` inference, since one helper now serves two different error-channel types. | +| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the two new `it()` titles. | + +No `backend/` file was touched — `Program.cs` and `Data/BriefStore.cs` are RB-23's, per the +ticket's explicit scope. + +## How the once-only bound is structural + +`BriefStore` gains one field: `private hasRecoveredFromMissingBrief = false`. `load()` +takes the recovery branch only when `r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief`, +and the branch's first statement sets the flag before doing anything else. A second 404 — +whether from a second `load()` call, or in principle from `reset()` itself somehow also +404ing — falls through to the plain `BriefLoadFailed` branch instead, on every subsequent +call, for the life of the store instance. This is a field on the singleton store, not a +comment: nothing in the reachable call graph can flip it back to `false`. + +The loop CQ-007's proposed change warns about ("the reset's own load must not be able to +loop") is not merely bounded, it is **structurally absent**: `recoverFromMissingBrief` +calls `this.adapter.reset()` and applies its `BriefView` result directly (the same +`applyLoadedView` the success path uses) — it never calls `this.load()` again. There is no +recursive edge from the recovery path back into `load()` for the once-only flag to have to +stop; the flag exists only to stop a **second, separate** `load()` invocation (e.g. a +caller retrying navigation) from reaching `reset()` again. + +## Judgement calls + +- **`load()` no longer uses `runResult`, only for this one method.** `runResult` + (`libs/shared/src/application/submit.ts`, RB-17) intentionally keeps only a string — + every other read in the app is fine with that. This is the first read that needs one + more bit (the HTTP status) than `runResult` exposes, so `load()` does its own + try/catch instead, matching `runResult`'s shape (`problemDetail(e, fallback)` on the + non-404 path) but adding the 404 branch first. `libs/shared/src/application/submit.ts` + itself is untouched — changing a shared helper used by many call sites for one adapter's + need was out of scope and unjustified. +- **404 detection reads `(e as {status?:unknown}).status === 404`, not + `SwaggerException.isSwaggerException`.** The generated client throws a plain + `SwaggerException` for `GET /brief` today (no OpenAPI 404 response is declared for it + yet), but throws the parsed `ProblemDetails` object instead for an endpoint whose spec + **does** declare a 404 (both shapes carry a `status` field). Checking `status` alone, + not the `SwaggerException` type, means this predicate keeps working unchanged once + RB-23 regenerates the client with a documented 404 response for `briefGET()` — no + follow-up FE edit needed for detection to keep working. +- **`BriefLoadFailure` is a new exported type, not a sentinel string.** CLAUDE.md's + default reflex is a discriminated union over a second flag; a magic string + (`'__not_found__'`) compared by identity would have kept `load()`'s signature at + `Result` and touched fewer test lines, but it is exactly the kind + of stringly-typed control flow the union tool exists to avoid. The touched-test cost + was six type annotations plus two helper signatures, all in the one already-scoped + spec file — judged worth it for the correct shape. + `BriefLoadFailure` is exported. +- **On a repeated 404, the store shows `BRIEF_LOAD_FAILED`** (the same generic banner + text `load()` already used for every other failure), not a distinct "still missing" + message. No new user-facing copy was needed or added, so no new `$localize` id and no + `messages.en.xlf` change — confirmed by diffing for `$localize` occurrences: both + hits in the diff are unchanged context lines, not new additions. +- **`resetDemo()` (the "start over" button) was left untouched**, even though it + duplicates part of the same apply-a-fresh-view logic now factored into + `applyLoadedView`. It also manages `actionState`/`saveState`/`rejectionSnapshot` that + `recoverFromMissingBrief` correctly does not touch (an automatic recovery on first + load is not a user-initiated "start over" action), and refactoring it was not asked + for by this ticket. + +## Verification + +- **Verified red without the fix.** Temporarily replaced `load()`'s body (via `Edit`, + not `git checkout`) with the pre-fix shape — every failure, `notFound` included, + dispatches `BriefLoadFailed` straight away, no `reset()` call — and reran the spec + file. Both new tests failed: + `expected "vi.fn()" to be called 1 times, but got 0 times` on `reset`, for both "a 404 + drives exactly one reset()" and "a second 404 does not drive a second reset()"; the + other 18 tests in the file stayed green. Restored the real fix with a second `Edit` + and reran: all 20 tests in the file green, 30/30 across both touched spec files. +- `npm run ci` (foreground, no background/Monitor): **green**, exit 0 — lint, + typecheck, `dep:check` (341 + 226 modules, 0 violations), `format:check`, + `check:tokens`, `check:seam`, tests (ssp 260/260, behandelportal 37/37, shared + 138/138, beheer 23/23 — 458 total), `ng build --localize` (both apps), `npm audit` + (0 vulnerabilities), backend `dotnet test` (260/260 — the known + `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not + reproduce on this run, matching the standing caveat that it needs a live OpenZaak + container and is not this ticket's bug), backend dependency audit clean, `gen:snippets` + drift clean, `gen:behaviour-spec` drift clean once the regenerated file was staged (the + local gate diffs the working tree against the index, so it necessarily shows a diff + before the file is staged/committed — the same documented, expected behaviour RB-17 + recorded, not a defect). +- `npx prettier --check` on every touched file (including the reformatted + `99-backlog.md` table and the regenerated `behaviour-spec.mdx`): clean. + +## What RB-23 must do + +Once `GET /brief` in `Program.cs` returns a real 404 (no `ProblemDetails` body is +required — `BriefAdapter.load()`'s `isHttpNotFound` only reads the HTTP `status`, not the +response body), and `BriefStore.GetOrCreate` splits into `Get` (query) + the existing +`ResetAndCreate` (already there, already used by `POST /brief/reset`), this ticket's +`notFound` branch stops being dead code and starts running on first-ever page load for any +owner with no persisted brief. Run `npm run gen:api` as part of RB-23 so `briefGET()` +regenerates with a documented `status === 404` branch throwing the parsed `ProblemDetails` +(matching the shape most other endpoints already use) — the detection predicate here +already tolerates that shape and needs no FE follow-up change. Two things worth +re-verifying once RB-23 lands, not fixing preemptively here: first, that the resulting +double round-trip (404, then `reset()`) is an acceptable UX cost on a first visit, per +CQ-007's own framing of this as its least certain finding; second, that this store's +`hasRecoveredFromMissingBrief` field — private to one store instance, reset only by a full +reload — is still the right lifetime for the once-only guard once a real 404 can occur in +production traffic, not only in a test's fake adapter. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index a334174..230d599 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 451 frontend behaviours across +**is** the suite, reshaped for a business reader. 453 frontend behaviours across 9 contexts; 236 backend behaviours across 41 test classes. @@ -180,6 +180,11 @@ classes. - flushes a pending debounced edit immediately and clears the pending flag - is a no-op when no edit is pending +#### BriefStore.load — 404 tolerance (RB-22) + +- a 404 drives exactly one reset(), which populates the store +- a second 404 does not drive a second reset() + #### BriefStore.previewLetter - opens the composed letter in a new tab on success From d0fda08bccf4963c65ab625ed847cc9e03cf27f4 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 19:01:06 +0200 Subject: [PATCH 44/61] fix(brief): make GET /brief a pure query, 404 when absent (RB-23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the one endpoint in the backend where a read performed a persisted write. The FE retries GETs automatically, so a transient failure could enter the create path more than once; a lock prevented a duplicate row, but the safety depended on the lock, not on the endpoint being a query. Split GetOrCreate into Get (a pure query) and the already-existing ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s when the owner has no brief yet. GET /brief/preview used GetOrCreate too, so it gets the same Get + 404 treatment, forced by the split. RB-22 already made BriefStore.load() on the FE tolerate a 404 by calling reset() once; this ticket is what makes that branch live. Updated the brief/preview/org-template backend tests that assumed GET seeded a brief on first call to create one explicitly first, and added a test that GET 404s and writes no row without the fix (verified red beforehand). Regenerated the API client (npm run gen:api). Co-Authored-By: Claude Opus 5 --- .../src/BigRegister.Api/Data/AppDbContext.cs | 2 +- .../src/BigRegister.Api/Data/BriefStore.cs | 12 +- backend/src/BigRegister.Api/Program.cs | 18 +- backend/swagger.json | 3 + .../BigRegister.Tests/BriefEndpointTests.cs | 50 +++-- .../OrgTemplateEndpointTests.cs | 16 +- .../BigRegister.Tests/PreviewEndpointTests.cs | 5 +- .../BigRegister.Tests/RouteInventoryTests.cs | 4 +- .../refactor-backlog/99-backlog.md | 70 +++---- .../refactor-backlog/implementation/rb-23.md | 183 ++++++++++++++++++ e2e/brief-v2.spec.ts | 11 +- libs/shared/docs/behaviour-spec.mdx | 5 +- libs/shared/src/infrastructure/api-client.ts | 4 + 13 files changed, 305 insertions(+), 78 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs index 876789a..08f109d 100644 --- a/backend/src/BigRegister.Api/Data/AppDbContext.cs +++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs @@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon modelBuilder.Entity(e => { e.HasKey(b => b.BriefId); - e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant) + e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (ResetAndCreate's invariant) e.Property(b => b.Placeholders).HasConversion(Json>()); e.Property(b => b.Sections).HasConversion(Json>()); e.Property(b => b.Status).HasConversion(Json()); diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs index 7a3f636..f880d98 100644 --- a/backend/src/BigRegister.Api/Data/BriefStore.cs +++ b/backend/src/BigRegister.Api/Data/BriefStore.cs @@ -47,17 +47,15 @@ public static class BriefStore private static readonly object _gate = new(); - public static BriefEntity GetOrCreate(string owner) + /// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null — + /// the owner's first-ever draft is created only through the explicit `ResetAndCreate` + /// command (`POST /brief/reset`), never as a side effect of a read. + public static BriefEntity? Get(string owner) { lock (_gate) { using var db = Db.Create(); - var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner); - if (existing is not null) return existing; - var created = BriefSeed.NewBrief(owner); - db.Briefs.Add(created); - db.SaveChanges(); - return created; + return db.Briefs.FirstOrDefault(e => e.Owner == owner); } } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index ce38f5e..7fbea17 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -675,10 +675,15 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon api.MapGet("/brief", (HttpContext ctx) => { - var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); - return ToView(ctx, e); + // RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first + // draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate) + // — this GET is a pure query and 404s when there is nothing to read yet. + var e = BriefStore.Get(ctx.Zorgverlener().Bsn); + if (e is null) return Results.NotFound(); + return Results.Ok(ToView(ctx, e)); }) -.Produces(); +.Produces() +.Produces(StatusCodes.Status404NotFound); api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => { @@ -766,7 +771,12 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => // letters serve their frozen archive; anything else renders live with a watermark. api.MapGet("/brief/preview", (HttpContext ctx) => { - var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); + // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET + // must not create a brief as a side effect either, so it 404s under the same + // precondition as GET /brief — in the running app the FE only reaches this endpoint + // from the brief page, which has already loaded (and, if needed, reset) a brief. + var e = BriefStore.Get(ctx.Zorgverlener().Bsn); + if (e is null) return Results.NotFound(); if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) return Results.Content(archived, "text/html"); var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); diff --git a/backend/swagger.json b/backend/swagger.json index 1a0dad1..9985fce 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -1000,6 +1000,9 @@ } } } + }, + "404": { + "description": "Not Found" } } }, diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs index 22ec6ef..78cdd05 100644 --- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs @@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu return new SaveBriefRequest(sections); } - private async Task Get() + /// RB-23: `GET /brief` no longer seeds a brief on first call, so every test that + /// needs one present creates it explicitly through `POST /brief/reset` + /// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses. + private async Task SeedBrief() { BriefStore.Reset(); - var view = await _client.GetFromJsonAsync("/api/v1/brief"); + var res = await _client.PostAsync("/api/v1/brief/reset", null); + var view = await res.Content.ReadFromJsonAsync(); Assert.NotNull(view); return view.Brief; } @@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu return req; } + // --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. --- + [Fact] - public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty() + public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner() { - var brief = await Get(); + BriefStore.Reset(); + + var res = await _client.GetAsync("/api/v1/brief"); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + + // The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call. + // Assert directly against the store, not only the HTTP status, so a regression that + // reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404. + Assert.Null(BriefStore.Get(DocumentStore.DemoOwner)); + } + + [Fact] + public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty() + { + var brief = await SeedBrief(); Assert.Equal("draft", brief.Status.Tag); Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey)); // aanhef + slot are locked, predefined and prefilled; only kern is editable + empty. @@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages() { - await Get(); + await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); // global passages + the arts-scoped one; no other-beroep passages leak in. @@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked() { - await Get(); + await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); // Case context is joined onto the screen DTO for the behandel scherm header. @@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Save_is_drafter_only() { - var brief = await Get(); + var brief = await SeedBrief(); var save = FilledFrom(brief); var approver = Post("/api/v1/brief", role: "approver"); @@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Submit_blocks_on_empty_required_section() { - await Get(); + await SeedBrief(); // Nothing filled yet → required sections empty → 409. Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode); } @@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Submit_succeeds_when_required_sections_filled() { - await Get(); + await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief)); @@ -170,7 +190,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); @@ -187,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Reject_returns_comments() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); @@ -202,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Editing_a_rejected_letter_reopens_it_to_draft() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync( @@ -218,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Send_only_from_approved() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); @@ -236,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status() { - var brief = await Get(); + var brief = await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status @@ -269,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections() { - var brief = await Get(); + var brief = await SeedBrief(); // Advance out of draft so the reset back to draft is observable. await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); diff --git a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs index 01784b9..dcf26b2 100644 --- a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs @@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Publish_increments_the_version() { ResetStores(); - // One unsent brief for this sub-org (GetOrCreate on first read). - await _client.GetAsync("/api/v1/brief"); + // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly). + await _client.PostAsync("/api/v1/brief/reset", null); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); res.EnsureSuccessStatusCode(); @@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Publish_appends_to_the_version_history() { ResetStores(); - await _client.GetAsync("/api/v1/brief"); + await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); res.EnsureSuccessStatusCode(); @@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Publish_counts_the_unsent_briefs_it_affects() { ResetStores(); - // One unsent brief for this sub-org (GetOrCreate on first read). - await _client.GetAsync("/api/v1/brief"); + // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly). + await _client.PostAsync("/api/v1/brief/reset", null); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); res.EnsureSuccessStatusCode(); @@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas private async Task WalkBriefToSentThenRepublish() { ResetStores(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; + var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly + var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief; var filled = brief.Sections .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required && s.Blocks.Count == 0 @@ -210,7 +211,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Admin_cannot_slip_into_the_brief_review_flow() { ResetStores(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; + var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly + var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief; var filled = brief.Sections .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required && s.Blocks.Count == 0 diff --git a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs index 62369ec..d86c9df 100644 --- a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs @@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark() { ResetStores(); - await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft + await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly var res = await _client.GetAsync("/api/v1/brief/preview"); res.EnsureSuccessStatusCode(); @@ -54,7 +54,8 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish() { ResetStores(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; + var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly + var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief; await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit")); await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver")); diff --git a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs index 8c284cf..a010096 100644 --- a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs +++ b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs @@ -72,14 +72,14 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt // enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's // Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers, // not a missing one. --- - new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn)."), + new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."), new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."), new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."), new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."), new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."), new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."), new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."), - new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); hand-written FE fetch."), + new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."), new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."), ]; diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index a39a6f5..3ed6ac5 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **implemented** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md new file mode 100644 index 0000000..faf9fa4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md @@ -0,0 +1,183 @@ +# RB-23 — `GET /brief` 404s when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate` + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 · +`99-backlog.md` RB-23, "Tickets that were rejected and split" · `implementation/rb-22.md` +(the FE **expand** half this ticket **contracts** against) + +This is the **contract** half of the RB-22/RB-23 expand/contract pair. RB-22 shipped first +and made `BriefStore.load()` tolerate a 404 by calling `reset()` once, as a no-op against +the (then) still-seeding backend. This ticket is what makes that branch live: `GET /brief` +now 404s when the owner has no brief yet, and the endpoint no longer performs a persisted +write on a read. + +## What was wrong + +CQ-007 flagged `GET /brief` (`Program.cs:676` → `BriefStore.GetOrCreate`, +`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a +persisted write, breaking the read/write split every other endpoint respects. The FE +retries GETs automatically (`api-client.provider.ts`, `retry({ count: 2, delay: 500 })`, +GET-only, precisely because GETs are assumed safe), so a transient failure could enter the +create path more than once; `GetOrCreate`'s `lock` prevented a duplicate row today, but the +safety depended on the lock rather than on the endpoint being a query. + +The ticket read as filed against the current code: `GetOrCreate` was exactly at +`BriefStore.cs:50`, `GET /brief` called it exactly as described, and `ResetAndCreate` +already existed and was already the sole body of `POST /brief/reset`. One thing the +ticket's own text did not mention: `BriefStore.GetOrCreate` had a **second** call site, +`GET /brief/preview` (`Program.cs:769`, excluded from the OpenAPI doc — a hand-written FE +`fetch`, same seam as uploads). Splitting `GetOrCreate` away necessarily touches that +call site too, or the file does not compile. See "What changed" below — this was a forced +consequence of the split, not a new business decision, and it is reported here rather than +silently worked around. + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `GetOrCreate` removed. New `Get(string owner): BriefEntity?` — pure query, `lock`-guarded like every other method in this file for consistency, no write. `ResetAndCreate` is untouched. | +| `backend/src/BigRegister.Api/Program.cs` | `GET /brief`: calls `BriefStore.Get`; returns `Results.NotFound()` when null, `Results.Ok(ToView(ctx, e))` otherwise; declares `.Produces(StatusCodes.Status404NotFound)` (the same bare-404 pattern already used at 17 other call sites in this file). `GET /brief/preview`: same `Get` + 404 treatment — forced by the split (see above), not a scope decision made independently. | +| `backend/src/BigRegister.Api/Data/AppDbContext.cs` | One comment updated (`GetOrCreate's invariant` → `ResetAndCreate's invariant`) — the unique index on `Owner` it annotates is unchanged. | +| `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` | New `Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner` (the DoD-required test). The `Get()` seeding helper, used by nearly every other test in the file, renamed to `SeedBrief()` and changed to create the brief explicitly via `POST /brief/reset` instead of relying on `GET /brief`'s old side effect. One test renamed (`Get_creates_a_draft_with_expected_sections_locked_and_empty` → `SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty`) — it asserts on the shape of a freshly created brief, which is now `SeedBrief()`'s job, not `GET`'s. | +| `backend/tests/BigRegister.Tests/PreviewEndpointTests.cs` | Two tests explicitly create the brief (`POST /brief/reset`) before hitting `/brief/preview`, instead of relying on the old `GET /brief` implicit create. | +| `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` | Five call sites (three bare seeding `GetAsync` calls, two `GetFromJsonAsync` calls used as seeding) changed to an explicit `POST /brief/reset` first. One call site (`Sent_brief_keeps_its_pinned_template_after_a_republish`, reading a brief already created and sent by the shared `WalkBriefToSentThenRepublish` helper) needed no change — a brief already exists by the time it runs. | +| `backend/tests/BigRegister.Tests/RouteInventoryTests.cs` | Two `AllowList` reason strings updated (`GetOrCreate` → `Get`, 404 noted) — documentation text only, not itself a check the test enforces beyond "some reason is on record". | +| `e2e/brief-v2.spec.ts` | One header comment updated to name the current methods and to state explicitly that this spec's own first click ("Opnieuw beginnen (demo)") is fixture setup, not a workaround for the new 404 — see "e2e and seeding paths" below. | +| `libs/shared/src/infrastructure/api-client.ts` | Regenerated (`npm run gen:api`). `briefGET()` gains a `status === 404` branch. See "The generated client" below for the shape it actually took. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-23's status cell: `open` → `implemented`. | + +No `apps/ssp/src/app/brief/**` file was touched — RB-22's `BriefStore.load()` recovery and +`BriefAdapter.load()`'s `BriefLoadFailure`/`isHttpNotFound` are unchanged, per this +ticket's explicit scope. + +## The generated client + +RB-22's handoff note predicted `briefGET()` would regenerate "throwing the parsed +`ProblemDetails` (matching the shape most other endpoints already use)". That did not +happen, and the actual result is still correct. `Results.NotFound()` (this ticket's +implementation, and the pattern used at every one of the 17 other bare-404 call sites in +`Program.cs` — none of them use `ProducesProblem`/a typed body) declares a 404 with **no** +response body schema. With nothing to parse into, NSwag emits a generic branch that throws +a plain `SwaggerException` carrying `status: 404` — the same shape `briefGET()` already +threw before this ticket, for the same reason (no declared 404 body). `BriefAdapter.load()`'s +`isHttpNotFound` predicate (`(e as {status?:unknown}).status === 404`) already tolerates +both a `SwaggerException` and a parsed `ProblemDetails`, by design, precisely so this +detail would not matter — RB-22's own comment says as much. No FE follow-up was needed, and +none was made. + +## Judgement calls + +- **`GET /brief/preview` also moved off `GetOrCreate`, to `Get` + 404.** Not mentioned in + the ticket text, but unavoidable: `GetOrCreate` no longer exists once split, and this + was its only other caller. The alternative — leaving a private, undocumented + `GetOrCreate`-shaped helper only for this one endpoint — would have reintroduced + exactly the GET-writes-on-read pattern CQ-007 is about, in the one place nobody would + think to look for it. Returning 404 there too keeps both `/brief` GETs behaving the + same way. In the running app this is unreachable in practice: the preview button + only renders inside the brief page's `@if (loaded(); as s)` block + (`apps/ssp/src/app/brief/ui/brief.page.ts`), which by construction only shows once + `BriefStore.load()` has already succeeded — including via RB-22's 404-recovery branch. + So a brief always exists by the time a real user can trigger `/brief/preview`; the 404 + path there is a defensive consequence of the type split, not a new user-facing + behaviour anyone will hit. +- **`BriefStore.Get` keeps the `lock (_gate)` wrap**, even though a plain SQLite read + does not strictly need the same mutual exclusion a write does. Every other method in + this file, including the pre-existing `ApplicationStore.Get`-style query in the + sibling store, locks unconditionally — matching that convention was judged more + valuable than a lock-free read this ticket did not need to justify removing. +- **Existing test changes create the brief via `POST /brief/reset`, not a new + `BriefStore.Get`/`ResetAndCreate` direct call from the test.** Going through the HTTP + endpoint (as the old `Get()` helper always did) keeps the tests exercising the real + request pipeline (identity resolution, `ToView` mapping) rather than reaching around + it — the same reasoning that already justified an `IClassFixture` + HTTP-level test suite in the first place. + +## e2e and seeding paths + +- **`e2e/brief-v2.spec.ts`** is the only e2e spec that reaches `/brief`. It already opens + `/brief?role=drafter` and immediately clicks "Opnieuw beginnen (demo)" (`POST +/brief/reset`) before asserting anything — a deliberate fixture reset, not a + workaround. With this ticket live, the page's first `GET /brief` on the fresh + per-run database (WP-74) now 404s; RB-22's `BriefStore.load()` recovers from that by + calling `reset()` once, so the page still renders correctly, and the spec's own + explicit reset click still runs on top of that (harmless — resetting an + already-fresh brief). No behavioural change to the spec was needed; one comment was + updated to say this explicitly rather than leave it to be re-derived. +- **Storybook**: no `brief.page.stories.ts` exists, and none of the eleven `brief/ui/**` + component stories call `HttpClient`/`fetch`/`ApiClient` — every story supplies data + through component `input()`s, per the house convention (design-system/component + stories are not live-network integration tests). Nothing in Storybook depended on + `GET /brief`'s old seeding behaviour. + +## The double round-trip — verdict + +CQ-007 named this its least certain point: a first-ever visit to `/brief` now costs a 404 +followed by a `reset()` call, instead of one request that both creates and returns the +brief. **Shipped as-is; the cost is acceptable.** Three reasons: + +1. **It happens once per browser tab, ever, for one demo entity.** `BriefStore`'s + `hasRecoveredFromMissingBrief` flag (RB-22) makes the 404 unreachable again for the + life of the store instance; a real deployment has one brief per zorgverlener, created + the first time that person ever opens the page. This is not a cost paid on every + page load, or even every session — a page reload still 404s once if the flag reset + with the page, but the underlying row is already there by then, so the _second_ call + in the pair — `reset()` — is now hitting an existing row rather than truly + first-creating one, and returns just as fast as `Get` would have. +2. **An extra round-trip is not an extra spinner.** `BriefStore.load()`'s failure + handling for `notFound` calls `reset()` and applies the result through the same + `applyLoadedView` the success path uses — there is no intermediate "not found" UI + state rendered to the user between the two calls; the page shows its loading state + once, for the combined duration of both requests. +3. **The alternative was rejected, not merely deprioritized.** CQ-007's own + documentation-only alternative — leave `GetOrCreate` in place, just write down that + the GET seeds on first call — was rejected outright by agent 07 in `99-backlog.md`: + "a non-idempotent GET must be visible in the code, not only in a ticket." Given that, + the only way to remove the mixing is some version of this two-call shape; a + single-call alternative would mean either GET creates (the defect) or `POST +/brief/reset` runs unconditionally on load (destructive — it deletes an existing + brief, unacceptable for anyone with real content already saved). + +## The once-only guard's lifetime — re-verified + +RB-22 flagged this as worth re-checking once a real 404 could occur in production +traffic, not only in a test's fake adapter. Having now made the 404 real: `hasRecoveredFromMissingBrief` +is a private field on `BriefStore`, which is `providedIn: 'root'` — one instance per +browser tab (per CLAUDE.md's "shared cross-page state = one root singleton" convention), +reset only by a full page reload. That lifetime is still correct for what the flag +guards: it exists to stop a _second, separate_ `load()` call in the same tab session from +re-triggering `reset()` (e.g. a caller retrying navigation after the first recovery +already ran) — not to remember "this owner has a brief" across reloads or across owners, +which is the server's job (`BriefStore.Get` returning non-null). A page reload correctly +starts the guard over: the first `load()` after a reload will find the now-existing row +via a plain `GET` (no 404, no `reset()` call at all), so the flag never actually gets +exercised a second time in the reload case either. No FE change was needed or made. + +## Verification + +- **Verified red without the fix.** Temporarily (via `Edit`, never `git checkout`) + restored `BriefStore.GetOrCreate` alongside the new `Get`, and pointed `GET /brief` in + `Program.cs` back at `GetOrCreate`. Ran the new test alone: + ``` + BigRegister.Tests.BriefEndpointTests.Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner [FAIL] + Assert.Equal() Failure: Values differ + Expected: NotFound + Actual: OK + ``` + Restored the real fix with a second `Edit` (removed the temporary `GetOrCreate`, + pointed `GET /brief` back at `Get` + 404) and reran: green. +- Full backend suite after the fix: **262/262 passing**, plus the one known, + pre-existing, container-dependent failure + (`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + "Connection refused (localhost:8000)") — not this ticket's bug, does not run under + `npm run ci`, reproduces on a clean tree with no OpenZaak container running. +- `npm run gen:api`: the client changed (`libs/shared/src/infrastructure/api-client.ts`, + `briefGET()` gains a `status === 404` branch — 4 lines). Regenerated and committed; + see "The generated client" above for why the shape differs from RB-22's prediction and + why that difference is harmless. +- `npm run ci` (foreground, no background/Monitor): see result below. + +## What this ticket did not touch + +`apps/ssp/src/app/brief/application/brief.store.ts`, `brief.store.spec.ts`, and +`apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` are unchanged — RB-22's FE logic +was already correct and already tested against exactly this contract, per this ticket's +explicit scope. diff --git a/e2e/brief-v2.spec.ts b/e2e/brief-v2.spec.ts index 25c8742..4e696a7 100644 --- a/e2e/brief-v2.spec.ts +++ b/e2e/brief-v2.spec.ts @@ -7,9 +7,14 @@ import { Actors, loginAs } from './support/actors'; // Preview assertions are content-type/body-level (text/html + watermark marker), not // pixel, per WP-28's decision. // -// This test mutates real state (a letter, keyed per-owner by `BriefStore.GetOrCreate`), -// and WP-74 gives it a fresh throwaway backend DB every `npm run e2e` run, so a -// leftover/in-progress letter from a PREVIOUS RUN is never an issue any more. It +// This test mutates real state (a letter, keyed per-owner by `BriefStore.Get`/ +// `ResetAndCreate` — RB-23 split the old `GetOrCreate`), and WP-74 gives it a fresh +// throwaway backend DB every `npm run e2e` run, so a leftover/in-progress letter from +// a PREVIOUS RUN is never an issue any more. `GET /brief` 404s on that fresh DB until +// the "Opnieuw beginnen (demo)" click below creates the first row — RB-22's +// `BriefStore.load()` already tolerates that 404 by calling `reset()` once, so the +// page renders correctly either way; the explicit click is this test's own fixture +// setup, not a workaround for the 404. It // deliberately still logs in as the shared `Actors.zorgverlener` rather than its own // BSN, though: giving it a distinct BSN (as `smoke.spec.ts` does) hit a real, // reproducible bug in this repo's own e2e run — `GET /brief/preview`'s sent-letter diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index a463750..581ab7d 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 467 frontend behaviours across -9 contexts; 237 backend behaviours across 41 test +9 contexts; 238 backend behaviours across 41 test classes. ## Frontend (by context) @@ -999,7 +999,8 @@ classes. ### BriefEndpointTests -- Get creates a draft with expected sections locked and empty +- Get returns 404 and writes no row when no brief exists for the owner +- SeedBrief creates a draft with expected sections locked and empty - Get offers only global and arts scoped besluit tagged passages - Get joins the case context with the BIG nummer masked - Reveal returns the unmasked BIG nummer for the drafter with step up diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 04e7639..8a30596 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -1350,6 +1350,10 @@ export class ApiClient { result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto; return result200; }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); } else if (status !== 200 && status !== 204) { return response.text().then((_responseText) => { return throwException("An unexpected server error occurred.", status, _responseText, _headers); From edd20c06dfeb25b325935f30992e52b70a4a62c2 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 19:02:09 +0200 Subject: [PATCH 45/61] docs(backlog): mark RB-23 done after merge Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 3ed6ac5..1825aa4 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **implemented** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- From e270b8612f19ae48eddc09fe3b75eb5108cb908b Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 19:15:32 +0200 Subject: [PATCH 46/61] refactor(backend): reorder Program.cs sections into reads-then-writes (RB-19) CQ-006 found that Program.cs states a reads-then-writes principle at the top of the file, then abandons it for five feature sections that mix GET and mutating endpoints in mapping order. This is a pure reorder: within Document upload, Applications, Admin cases, Brief, and Organization templates, every GET now precedes every POST/PUT/DELETE, each split by a `--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already established for Beoordeling/Besluit. DELETE /admin/cases/{id} and GET /admin/audit move up beside GET /admin/cases, closing the 129-line gap CQ-006 measured. GET /admin/org-template/{subOrgId}/preview moves from the Brief section to the Organization-templates section it actually belongs to. No route, signature, DTO, or handler body changed. Every block was cut by exact line-range slicing, never retyped. The sorted list of mapped HTTP-method-plus-path strings is byte-identical before and after; every .Gate(...) count is unchanged; the three routes that moved with a gate were checked by eye against the wrapper their handler actually calls, per RB-12's stated limitation that the route-table test only proves a marker is present, not that it still matches the handler. npm run gen:api regenerated backend/swagger.json and libs/shared/src/infrastructure/api-client.ts; both diffs are ordering only (sorted-file diff is empty), committed alongside per the ticket's own guidance. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 181 +++++++------- backend/swagger.json | 134 +++++------ .../refactor-backlog/99-backlog.md | 70 +++--- .../refactor-backlog/implementation/rb-19.md | 220 ++++++++++++++++++ libs/shared/src/infrastructure/api-client.ts | 176 +++++++------- 5 files changed, 511 insertions(+), 270 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 7fbea17..f0f6ae8 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -242,36 +242,12 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => // --- Document upload --- +// --- reads --- + // Server-owned category config per wizard. The FE renders these; it never hardcodes. api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) => new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList())); -// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded -// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type -// and size authoritatively; stores metadata only (no file bytes / PII held). -api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) => -{ - if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); - var form = await request.ReadFormAsync(); - var file = form.Files.GetFile("file"); - string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); - if (file is null || categoryId == "" || localId == "" || wizardId == "") - return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); - - var category = DocumentRules.Find(wizardId, categoryId); - var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); - if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); - - using var ms = new MemoryStream(); - await file.CopyToAsync(ms); - // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add - // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers - // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. - var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener()); - return Results.Created($"/api/v1/uploads/{response.DocumentId}", response); -}) -.ExcludeFromDescription(); - // Serve stored bytes so a re-opened wizard can preview/download an upload. Inline // for pdf/image (browser renders it), attachment otherwise (download). // Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a @@ -305,6 +281,34 @@ api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) => return new UploadStatusDto(results); }); +// --- writes --- + +// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded +// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type +// and size authoritatively; stores metadata only (no file bytes / PII held). +api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) => +{ + if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); + var form = await request.ReadFormAsync(); + var file = form.Files.GetFile("file"); + string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); + if (file is null || categoryId == "" || localId == "" || wizardId == "") + return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); + + var category = DocumentRules.Find(wizardId, categoryId); + var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); + if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add + // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers + // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. + var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener()); + return Results.Created($"/api/v1/uploads/{response.DocumentId}", response); +}) +.ExcludeFromDescription(); + // User delete: owner-scoped; 409 once linked to a finalised submission. api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch @@ -332,6 +336,8 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx // --- Applications (aanvragen): the system of record the dashboard reads. --- +// --- reads --- + // WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling // ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap @@ -346,6 +352,8 @@ api.MapGet("/applications/{id}", (string id, HttpContext ctx) => .Produces() .Produces(StatusCodes.Status404NotFound); +// --- writes --- + api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => { // Feature flag (WP-47): self-service registration can be closed by an admin. @@ -480,12 +488,40 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re .Produces(StatusCodes.Status404NotFound); // --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- + +// --- reads --- + api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) .Gate("CasesAdmin") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); +// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated +// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement. +api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => + Results.Ok(AuthzAuditStore.List() + .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId)) + .ToList()))) +.Gate("CasesAdmin") +.Produces>() +.ProducesProblem(StatusCodes.Status403Forbidden); + +// --- writes --- + +// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing +// DELETE /applications/{id}. A missing id is a 404. +api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => +{ + if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); + app.Logger.LogInformation("admin case delete id={Id}", id); + return Results.NoContent(); +})) +.Gate("CasesAdmin") +.Produces(StatusCodes.Status204NoContent) +.Produces(StatusCodes.Status404NotFound) +.ProducesProblem(StatusCodes.Status403Forbidden); + // --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. --- // Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`, // WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags — @@ -618,29 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) => // /uploads and /brief/reveal-bignummer. .ExcludeFromDescription(); -// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing -// DELETE /applications/{id}. A missing id is a 404. -api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => -{ - if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); - app.Logger.LogInformation("admin case delete id={Id}", id); - return Results.NoContent(); -})) -.Gate("CasesAdmin") -.Produces(StatusCodes.Status204NoContent) -.Produces(StatusCodes.Status404NotFound) -.ProducesProblem(StatusCodes.Status403Forbidden); - -// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated -// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement. -api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => - Results.Ok(AuthzAuditStore.List() - .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId)) - .ToList()))) -.Gate("CasesAdmin") -.Produces>() -.ProducesProblem(StatusCodes.Status403Forbidden); - // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // tied to a specific brief's live status — see BriefDecisionsDto for that). // WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like @@ -673,6 +686,8 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon // dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real // identities in this POC. --- +// --- reads --- + api.MapGet("/brief", (HttpContext ctx) => { // RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first @@ -685,6 +700,27 @@ api.MapGet("/brief", (HttpContext ctx) => .Produces() .Produces(StatusCodes.Status404NotFound); +// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the +// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → +// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent +// letters serve their frozen archive; anything else renders live with a watermark. +api.MapGet("/brief/preview", (HttpContext ctx) => +{ + // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET + // must not create a brief as a side effect either, so it 404s under the same + // precondition as GET /brief — in the running app the FE only reaches this endpoint + // from the brief page, which has already loaded (and, if needed, reset) a brief. + var e = BriefStore.Get(ctx.Zorgverlener().Bsn); + if (e is null) return Results.NotFound(); + if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) + return Results.Content(archived, "text/html"); + var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); + return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html"); +}) +.ExcludeFromDescription(); + +// --- writes --- + api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; @@ -765,37 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => // OpenAPI doc, same seam as /brief/preview and uploads. .ExcludeFromDescription(); -// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the -// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → -// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent -// letters serve their frozen archive; anything else renders live with a watermark. -api.MapGet("/brief/preview", (HttpContext ctx) => -{ - // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET - // must not create a brief as a side effect either, so it 404s under the same - // precondition as GET /brief — in the running app the FE only reaches this endpoint - // from the brief page, which has already loaded (and, if needed, reset) a brief. - var e = BriefStore.Get(ctx.Zorgverlener().Bsn); - if (e is null) return Results.NotFound(); - if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) - return Results.Content(archived, "text/html"); - var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); - return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html"); -}) -.ExcludeFromDescription(); - -// Proefbrief: the admin's unpublished draft template rendered over a fixture -// brief, so the appearance can be checked before publishing touches real letters. -api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => -{ - var view = OrgTemplateStore.AdminView(subOrgId); - if (view is null) return Results.NotFound(); - var fixture = BriefSeed.NewBrief("proefbrief"); - return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html"); -})) -.Gate("OrgAdmin") -.ExcludeFromDescription(); - api.MapPost("/brief/reset", (HttpContext ctx) => { // Demo "start over": recreate a fresh draft. No guards — showcase affordance only. @@ -810,6 +815,8 @@ api.MapPost("/brief/reset", (HttpContext ctx) => // as drafter/approver); the same Authz check gates every endpoint and feeds the // `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. --- +// --- reads --- + api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () => Results.Ok(OrgTemplateStore.List()))) .Gate("OrgAdmin") @@ -825,6 +832,20 @@ api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); +// Proefbrief: the admin's unpublished draft template rendered over a fixture +// brief, so the appearance can be checked before publishing touches real letters. +api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => +{ + var view = OrgTemplateStore.AdminView(subOrgId); + if (view is null) return Results.NotFound(); + var fixture = BriefSeed.NewBrief("proefbrief"); + return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html"); +})) +.Gate("OrgAdmin") +.ExcludeFromDescription(); + +// --- writes --- + api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () => { var reject = OrgTemplateRules.RejectDraft(req.Draft); diff --git a/backend/swagger.json b/backend/swagger.json index 9985fce..e0ccac2 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -686,6 +686,73 @@ } } }, + "/api/v1/admin/audit": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthzAuditDto" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/admin/cases/{id}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/werkvoorraad": { "get": { "tags": [ @@ -832,73 +899,6 @@ } } }, - "/api/v1/admin/cases/{id}": { - "delete": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "No Content" - }, - "404": { - "description": "Not Found" - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/admin/audit": { - "get": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AuthzAuditDto" - } - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, "/api/v1/me": { "get": { "tags": [ diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 1825aa4..b41a430 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **implemented** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md new file mode 100644 index 0000000..65afb53 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md @@ -0,0 +1,220 @@ +# RB-19 — reorder `Program.cs`: reads before writes per section, regroup admin-cases + org-template preview + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-006 · +`99-backlog.md` RB-19 · Depends on `implementation/rb-12.md` (the route-table test this +ticket leans on as its regression net) + +This is a **pure reorder**. No route, signature, DTO, or handler-body text changed. The +sorted list of `HTTP METHOD + path` mapping calls is byte-identical before and after (see +"Verification" below) — that identity is the strongest evidence this ticket did what it +says and nothing else. + +## What was wrong + +CQ-006, verbatim: `Program.cs` opens by declaring direction as its organising principle +(a "GET: screen-shaped reads" banner, then a "POST: submits" banner), then from the +Document-upload section onward switches to feature grouping without saying so, and every +subsequent section interleaves reads and writes. One feature (Beoordeling/Besluit, WP-65) +already got the fix — a `:441`/`:464`-style banner pair splitting its query endpoint from +its command endpoint — and CQ-006 asks for the same treatment on the five sections that +predate that pattern: Document upload, Applications, Admin cases, Brief, and Organization +templates. Separately, `DELETE /admin/cases/{id}` sat 129 lines away from `GET +/admin/cases`, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in +between; and `GET /admin/org-template/{subOrgId}/preview` was filed under the Brief +section's banner instead of the Org-templates section it actually belongs to. + +## What changed + +| Section (banner) | Before | After | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Document upload | categories, **POST /uploads**, content, status, DELETE, admin-DELETE | categories, content, status, `--- reads ---`/`--- writes ---` sub-banners, **POST /uploads** moved after the reads, DELETE, admin-DELETE | +| Applications | already reads-first (2 GETs, then POST/PUT/DELETE/POST-submit) | unchanged order; sub-banners inserted only | +| Admin cases | GET /admin/cases, _(werkvoorraad/beoordeling/besluit/zgw-notificaties in between)_, **DELETE /admin/cases/{id}**, **GET /admin/audit** | GET /admin/cases, **GET /admin/audit** (moved up), `--- writes ---`, **DELETE /admin/cases/{id}** (moved up) — all three now contiguous; werkvoorraad/beoordeling/besluit/zgw-notificaties follow, unmoved and unchanged | +| Brief | GET /brief, PUT, submit, approve, reject, send, reveal-bignummer, **GET /brief/preview**, _(org-template preview)_, POST /reset | GET /brief, **GET /brief/preview** (moved up beside GET /brief), `--- writes ---`, PUT, submit, approve, reject, send, reveal-bignummer, POST /reset — org-template preview removed from this section | +| Organization templates | list, detail, PUT, publish, rollback | list, detail, **GET /admin/org-template/{subOrgId}/preview** (moved in from Brief), `--- writes ---`, PUT, publish, rollback | + +Every section above got a `// --- reads ---` / `// --- writes ---` sub-banner pair +(matching the short, bare form already used at the file's top-level `:170`/`:236` +banners) inserted at the reads→writes boundary. Werkvoorraad, Beoordeling and Besluit — +not named by CQ-006 as mixed, and already correctly split (Beoordeling is the read, +Besluit is the write, each with its own WP-65 banner) — were left exactly as they were, +including their absolute position relative to each other; only the block ahead of them +(admin-cases) grew, pushing their line numbers down without touching their content. + +`backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` were +regenerated (`npm run gen:api`) and are part of this commit — see "The regenerated pair" +below. + +## Design: line-range slicing, not manual retyping + +Every moved block was cut with a Python script operating on exact 1-indexed line ranges +against the file as it stood after merging in `refactor/adr-c-006-shared-route-guards` +(this branch's actual base — see "Base commit" below), then reassembled in the new order. +No handler body was retyped by hand. This is the same guarantee the ticket's "cut/paste, +not retype" instruction asks for, made structural rather than a promise to be careful: +a line-range slice cannot silently change a character inside a block it does not touch. +The script is not part of this commit (a one-shot tool, not project code); the diff it +produced is what is being reviewed. + +## Judgement calls + +- **Sub-banner wording is bare `// --- reads ---` / `// --- writes ---`, not prose + matching WP-65's descriptive style.** The ticket asks for ":441/:464-style" banners; + WP-65's actual banners are long, feature-specific paragraphs ("read side only + (recording a decision is WP-65's second half)…"). Inventing five more paragraphs like + that would mean writing new explanatory prose about code this ticket is not meant to + re-explain — CQ-006 is explicit that this is "a structure finding, not a correctness + one," and the ticket itself forbids "no fixed comments beyond the banners this ticket + adds." The file's own top-level banners (`:170` "GET: screen-shaped reads", `:236` + "POST: submits") already establish a bare, label-only banner as a legitimate style in + this exact file — the sub-banners here are that same style, nested one level deeper. +- **Werkvoorraad/Beoordeling/Besluit end up sandwiched between Admin-cases and + zgw/notificaties, in that order, unmoved.** Moving `GET /admin/audit` and `DELETE +/admin/cases/{id}` up next to `GET /admin/cases` (as instructed) necessarily pushes + everything that used to sit between them — werkvoorraad, beoordeling, besluit, + zgw/notificaties — down, but does not reorder those four relative to each other. They + were not named as mixed by CQ-006 and were not touched beyond their line numbers + changing. +- **`GET /brief/preview` and `POST /uploads` are both `.ExcludeFromDescription()`-marked + (hand-written FE `fetch`/XHR calls, never through the generated client) — moving them + produced zero diff in `swagger.json`.** This is not a coincidence being reported as + one: an excluded endpoint has no OpenAPI operation to reorder in the first place, so + the regenerated pair's diff below is smaller than "every moved route" might suggest — + it only shows the two endpoints that are both documented and reordered relative to + each other (`GET /admin/audit`, `DELETE /admin/cases/{id}`). +- **No handler types, no `Features/` folder, no mediator** — out of mandate per CQ-006's + own text (filed separately as OOM-A) and the ticket's explicit "out of scope" section. + Nothing beyond comments and mapping order changed. + +## Base commit + +Step zero's warning matched this worktree's actual starting state: `git log --oneline -8` +showed `ae7781e` at HEAD, not `edd20c0`, and `edd20c0 docs(backlog): mark RB-23 done after +merge` was absent from the log entirely — the bad-base lineage named in the ticket. `git +merge refactor/adr-c-006-shared-route-guards` was run, after which `edd20c0` appeared as +`HEAD~0`'s direct ancestor and every RB-01..RB-23 commit was present. All work in this +ticket happened after that merge. + +## Verification + +**The sorted-route-list diff (the key evidence).** Extracted every `.Map(Get|Post|Put| +Delete)("...")` call from `Program.cs` before and after, sorted each list, and diffed +them: + +``` +$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs.before-reorder | sort > before.txt +$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs | sort > after.txt +$ diff before.txt after.txt +$ echo "exit=$?" +exit=0 +$ wc -l before.txt after.txt + 47 before.txt + 47 after.txt +``` + +Empty diff, same count (47 `api.Map*` calls — the two `app.MapGet` health probes are +outside the `/api/v1` group and were never in scope for this reorder; they were untouched +either way). The set of routes is provably unchanged. + +**`.Gate(...)` count, before/after, by wrapper name:** + +``` + 3 .Gate("Beoordelen") + 4 .Gate("CasesAdmin") + 1 .Gate("FlagsAdmin") + 6 .Gate("OrgAdmin") + 2 .Gate("StamdataAdmin") +``` + +Identical in both directions — no gate call was added, removed, or renamed. + +**Per-route eyeball check of every route that changed position, per RB-12's stated +limitation** (the route-table test only proves a `.Gate(...)` marker is present, not that +it still names the wrapper the handler body actually calls): + +| Route | Moved | `.Gate(...)` after | Wrapper actually called inside the handler | Match | +| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------- | ------------------------------------------ | ----- | +| `GET /admin/audit` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => ...)` | yes | +| `DELETE /admin/cases/{id}` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => { ... })` | yes | +| `GET /admin/org-template/{subOrgId}/preview` | Brief section → Org-templates section | `OrgAdmin` | `OrgAdmin(ctx, () => { ... })` | yes | +| `POST /uploads` | within Document-upload, past the three reads | _(none — allow-listed, ownership-scoped)_ | — | n/a | +| `GET /brief/preview` | within Brief, up beside `GET /brief` | _(none — allow-listed, ownership-scoped)_ | — | n/a | +| `GET /uploads/{documentId}/content` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a | +| `GET /uploads/status` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a | + +Five routes were deliberately relocated by this ticket; two more shifted position only as +a byproduct of `POST /uploads` moving past them (their own order relative to each other +is unchanged). All three gated routes among these were checked by eye against the +handler body they wrap, not just against `RouteInventoryTests`' marker check — all three +match. + +**`RouteInventoryTests`:** + +``` +Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 770 ms +``` + +Both `Every_mapped_route_is_authz_gated_or_on_the_named_allow_list` and +`Every_gate_marker_names_a_known_admin_wrapper` pass. + +**Full backend suite:** `dotnet test --filter "Category!=Integration"` — **262/262 +passing**, plus the one known, pre-existing, container-dependent failure +(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`), +which does not run under `npm run ci` and reproduces on a clean tree with no OpenZaak +container running — not this ticket's bug. + +**`dotnet build`** (both projects): 0 warnings, 0 errors. **`dotnet format +BigRegister.slnx --verify-no-changes`**: clean. + +**No new test was added.** Per the ticket's Definition of Done: this is a zero-semantic- +change commit, and §3c's pre-existing 97.4% line / 84.8% branch coverage of `Program.cs` +is the regression net CQ-006 itself names. Nothing about this diff needs a new test to be +trustworthy — a passing pre-existing suite plus an empty sorted-route diff is stronger +evidence for "nothing changed" than a new test asserting the same thing would be. + +## The regenerated pair + +`npm run gen:api` was run after the reorder. It produced a diff in both +`backend/swagger.json` (2 hunks) and `libs/shared/src/infrastructure/api-client.ts` (5 +hunks) — both **pure reordering, zero content change**. Confirmed by sorting every line of +each file (before vs. after) and diffing the sorted output: empty in both cases. The only +two OpenAPI paths that moved position in the document are `/api/v1/admin/audit` and +`/api/v1/admin/cases/{id}` — the two documented (non-`ExcludeFromDescription`) endpoints +this ticket actually reordered relative to their OpenAPI-document neighbours; the +generated client's `audit()`/`cases()` methods and their `process*` helpers moved by the +same amount, unchanged in every other respect (parameters, return types, status-code +branches, JSDoc). Both regenerated files are committed alongside `Program.cs`, per the +ticket's explicit instruction: "if the only change is ordering inside swagger.json, say +so explicitly and commit the regenerated pair rather than leaving CI's drift job to +fail." + +**`npm run ci`**: every job through "backend dependency audit" passed before this +ticket's files were committed; the one job that legitimately failed pre-commit was "api- +client drift" (`git diff --exit-code` against the not-yet-committed regenerated files — +expected, since that step compares the working tree to `HEAD`, and `HEAD` still had the +pre-reorder client at that point). After committing, `npm run ci` was re-run to confirm a +clean, fully green result against the committed tree — see the final PASS/exit-code +reported in this ticket's closing message. + +## What a reviewer should check + +This diff is too large to read top-to-bottom without guidance. The fastest way to review +it with confidence: + +1. **Trust the sorted-route diff, not a manual read of every hunk.** The "Verification" + section above shows the set of `HTTP METHOD + path` strings is byte-identical before + and after. If you want to reproduce it yourself: check out this commit's parent, + extract the same `grep -oE` pattern from both revisions of `Program.cs`, sort, diff. +2. **Spot-check the five per-route table entries above**, not the whole file — those are + the only routes whose position (and, for three of them, gate-vs-handler match) + actually matters for this ticket's correctness claim. +3. **Diff `git show -- backend/src/BigRegister.Api/Program.cs` with + whitespace-insensitive word diff** (`git diff -w --color-words`) if you want to + confirm no character inside a moved handler body changed — the line-range-slicing + approach in "Design" above makes this a formality rather than a real risk, but it is + cheap to re-check. +4. **Do not expect Werkvoorraad/Beoordeling/Besluit/zgw-notificaties to have moved + position relative to each other** — only their absolute line numbers shifted, as a + side effect of the admin-cases block growing above them. +5. **The regenerated `swagger.json`/`api-client.ts` diff is expected and pre-verified as + ordering-only** (sorted-file diff is empty) — it does not need a second manual read. diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 8a30596..835788c 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -956,6 +956,94 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + audit(): Promise { + let url_ = this.baseUrl + "/api/v1/admin/audit"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processAudit(_response); + }); + } + + protected processAudit(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[]; + return result200; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + cases(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/admin/cases/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCases(_response); + }); + } + + protected processCases(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + /** * @return OK */ @@ -1112,94 +1200,6 @@ export class ApiClient { return Promise.resolve(null as any); } - /** - * @return No Content - */ - cases(id: string): Promise { - let url_ = this.baseUrl + "/api/v1/admin/cases/{id}"; - if (id === undefined || id === null) - throw new globalThis.Error("The parameter 'id' must be defined."); - url_ = url_.replace("{id}", encodeURIComponent("" + id)); - url_ = url_.replace(/[?&]$/, ""); - - let options_: RequestInit = { - method: "DELETE", - headers: { - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processCases(_response); - }); - } - - protected processCases(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 204) { - return response.text().then((_responseText) => { - return; - }); - } else if (status === 403) { - return response.text().then((_responseText) => { - let result403: any = null; - result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Forbidden", status, _responseText, _headers, result403); - }); - } else if (status === 404) { - return response.text().then((_responseText) => { - return throwException("Not Found", status, _responseText, _headers); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - - /** - * @return OK - */ - audit(): Promise { - let url_ = this.baseUrl + "/api/v1/admin/audit"; - url_ = url_.replace(/[?&]$/, ""); - - let options_: RequestInit = { - method: "GET", - headers: { - "Accept": "application/json" - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processAudit(_response); - }); - } - - protected processAudit(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 200) { - return response.text().then((_responseText) => { - let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[]; - return result200; - }); - } else if (status === 403) { - return response.text().then((_responseText) => { - let result403: any = null; - result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Forbidden", status, _responseText, _headers, result403); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - /** * @return OK */ From 424ceb604b514233ce72682c70edfa898ab7b355 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 19:21:01 +0200 Subject: [PATCH 47/61] docs(backlog): CD batch 4 complete All six tickets RB-18 to RB-23 merged, one commit per ticket. Records the two incomplete tickets that the agents reported, RB-22's deliberate departure from the runResult idiom, and how RB-19 was verified as a pure reorder. Adds five dispatch lessons. The stale worktree base is now the rule at 11 of 13 agent-runs. A spend limit killed four agents mid-flight and a message resumed each one from its own transcript, so no work was redone. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++++++++---------- .../refactor-backlog/_status.md | 43 +++++++++--- 2 files changed, 69 insertions(+), 44 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index b41a430..fe54364 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **implemented** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index e7cb9f0..679cc2e 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,15 +14,15 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ------------------------------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | -| 4 | RB-18..RB-23 | in progress | Split into three waves to keep the merge order honest, because three of the six tickets touch `Program.cs`. **Wave A (dispatched, parallel):** RB-18, RB-20, RB-21, RB-22 — no file overlap between them. **Wave B:** RB-23, which must merge after RB-22 (expand/contract pair: the FE must tolerate the 404 before the BE returns it). **Wave C:** RB-19 alone and last — it is the only **High**-risk ticket, it reorders all 48 endpoints in `Program.cs`, and landing it last means it reorders the final content instead of conflicting with RB-18's and RB-23's edits to the same file. RB-19 also needs RB-12's route-table test as its safety net; read `rb-12.md` first, including its stated limitation that detection is a declaration, not a derivation. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | -| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | +| CD batch | Tickets | Status | Notes | +| -------- | ------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | +| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. | +| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | +| 6 | RB-31, RB-32, RB-33 | not started | | +| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, @@ -76,6 +76,31 @@ least one of these. Put all of it in the prompt. 6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`. +7. **The stale base is now the rule, not the exception.** Batch 4 ran six agents; **five were + handed a bad base**, three of them the same unrelated `ae7781e` lineage. Across batches 2-4 that + is **11 of 13 agent-runs**. Every one self-corrected at step zero. Keep the named-expected-commit + check as the first instruction in every prompt — it is the highest-value line in there. Give the + _current_ HEAD, not the batch's starting commit, when tickets merge sequentially. +8. **A spend limit can kill every agent mid-flight; resume, do not restart.** All four wave-A agents + died on an org monthly-spend 429, three of them at the final CI re-run. Their worktrees kept the + uncommitted work intact. Sending each agent a message resumed it from its own transcript and it + finished from exactly where it stopped — nothing was redone. Check `git -C status` + before assuming work is lost. +9. **The `99-backlog.md` conflict is mechanical and has a fixed recipe.** It fired on three of five + merges. Cause: the central prettier pass reflows the table's column widths, so the whole table + conflicts even though the two sides differ in only one or two status cells. Recipe: take HEAD's + table, flip the incoming ticket's cell, re-run prettier. Verify by parsing both sides cell-by-cell + and printing only the differing cells before discarding either side — do not eyeball a 33-row + table. A ticket's cell reads `**done**` once merged (matching RB-01..RB-17), not `implemented`. +10. **For a zero-semantic-change commit, ask for evidence CI cannot give.** RB-19's diff is 181 lines + in `Program.cs` and no test can prove it changed nothing. The sorted-route-list diff, plus the + route/gate/handler-triple comparison, is what actually made it reviewable. Ask for it in the + prompt and re-run it centrally before merging. +11. **`gen:api` is not always a no-op on a pure reorder.** OpenAPI operation order follows mapping + order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to + prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to + commit the regenerated pair, or CI's drift job fails on a correct change. + **Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review From 9520d6c24e1194d2dd72133655544fe9edd724af Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 20:40:41 +0200 Subject: [PATCH 48/61] refactor(shared): move upload/ into infrastructure/domain/application (RB-24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libs/shared/src/upload/ held a network adapter, an Elm machine, and two application-layer coordinators outside the folder-per-layer convention every other context follows. The dependency-cruiser rule carved an exception around the misplaced adapter instead of the violation being fixed. Move all five files to the layer each belongs to (git mv), update every import across 24 consumer files, then delete the carve-out clause from .dependency-cruiser.base.js. No export renamed, no file split, no spec content changed. Deleting the carve-out exposed a second, pre-existing rule violation: ui-not-infrastructure had never fired against upload.adapter.ts because its old path did not match /infrastructure/. Three UI components injected UploadAdapter directly for its one-line contentUrl() wrapper. Route each through the existing pure uploadContentUrl() function via the application layer (upload-controller's new previewUrlFor, OrgTemplateStore's new previewUrlFor) instead — the same idiom brief.store.ts already used. npm run ci passes; dep:check is clean for both apps with the carve-out gone. Co-Authored-By: Claude Opus 5 --- .dependency-cruiser.base.js | 4 +- .../src/app/brief/application/brief.store.ts | 2 +- .../brief/application/org-template.store.ts | 9 +- .../brief/domain/org-template.machine.spec.ts | 2 +- .../app/brief/domain/org-template.machine.ts | 2 +- .../org-template-editor.component.ts | 2 +- .../org-template-editor.stories.ts | 2 +- .../ssp/src/app/brief/ui/org-template.page.ts | 4 +- .../domain/herregistratie.machine.ts | 2 +- .../herregistratie-wizard.component.ts | 13 +- .../herregistratie-wizard.stories.ts | 2 +- .../domain/registratie-wizard.machine.spec.ts | 2 +- .../domain/registratie-wizard.machine.ts | 2 +- .../registratie-wizard.component.ts | 13 +- .../registratie-wizard.stories.ts | 2 +- .../refactor-backlog/99-backlog.md | 2 +- .../refactor-backlog/implementation/rb-24.md | 178 ++++++++++++++++++ docs/reference/architecture/dependencies.md | 2 +- .../upload-controller.ts | 19 +- .../upload-shell.service.ts | 9 +- .../{upload => domain}/upload.machine.spec.ts | 0 .../src/{upload => domain}/upload.machine.ts | 0 .../upload.adapter.ts | 2 +- .../delivery-channel-toggle.component.ts | 2 +- .../document-category.component.ts | 2 +- .../document-category.stories.ts | 2 +- .../document-chip/document-chip.component.ts | 2 +- .../document-chip/document-chip.stories.ts | 2 +- .../document-upload.component.ts | 2 +- .../document-upload.stories.ts | 2 +- .../single-upload/single-upload.component.ts | 2 +- .../single-upload/single-upload.stories.ts | 2 +- .../upload-status-icon.component.ts | 2 +- 33 files changed, 246 insertions(+), 49 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md rename libs/shared/src/{upload => application}/upload-controller.ts (88%) rename libs/shared/src/{upload => application}/upload-shell.service.ts (94%) rename libs/shared/src/{upload => domain}/upload.machine.spec.ts (100%) rename libs/shared/src/{upload => domain}/upload.machine.ts (100%) rename libs/shared/src/{upload => infrastructure}/upload.adapter.ts (99%) diff --git a/.dependency-cruiser.base.js b/.dependency-cruiser.base.js index ff945cd..b6f1e0b 100644 --- a/.dependency-cruiser.base.js +++ b/.dependency-cruiser.base.js @@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) { name: 'apiclient-infrastructure-only', comment: - 'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.', + 'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.', severity: 'error', - from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }, + from: { pathNot: '/infrastructure/' }, to: { path: '^libs/shared/src/infrastructure/api-client\\.ts$', dependencyTypesNot: ['type-only'], diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 6bb8073..623535a 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -19,7 +19,7 @@ import { OrgTemplate } from '@brief/domain/org-template'; import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; -import { uploadContentUrl } from '@shared/upload/upload.adapter'; +import { uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; /** diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 6135944..c78213f 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -3,9 +3,9 @@ import { createStore } from '@shared/application/store'; import { ActionState, SaveState } from '@shared/application/action-state'; import { createDebouncedSave } from '@shared/application/debounced-save'; import { machineRemoteData } from '@shared/application/machine-remote-data'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadShellService } from '@shared/upload/upload-shell.service'; -import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine'; +import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; +import { UploadShellService } from '@shared/application/upload-shell.service'; +import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine'; import { MARGIN_MAX_MM, MARGIN_MIN_MM, @@ -72,6 +72,9 @@ export class OrgTemplateStore implements PendingSave { return id ? this.uploadAdapter.contentUrl(id) : null; }); + /** Preview/download link for any completed upload in the editor's document list. */ + readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId); + /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback; the server re-validates and stays the authority — publish is gated on this. */ readonly draftValid = computed(() => { diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index fb223fa..1b8f24b 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { expectTag } from '@shared/testing/expect-tag'; import { OrgTemplate, OrgTemplateAdminView } from './org-template'; import { OrgTemplateState, reduce } from './org-template.machine'; -import { DocumentCategory } from '@shared/upload/upload.machine'; +import { DocumentCategory } from '@shared/domain/upload.machine'; const template: OrgTemplate = { subOrgId: 'cibg-registers', diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index de90a36..7932941 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -1,6 +1,6 @@ import { assertNever } from '@shared/kernel/fp'; import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; -import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine'; +import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; /** * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index b9b8c98..2690e0f 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; -import { UploadState } from '@shared/upload/upload.machine'; +import { UploadState } from '@shared/domain/upload.machine'; import { Brief } from '@brief/domain/brief'; import { MARGIN_MAX_MM, diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts index 2b4d987..11fca36 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { OrgTemplateEditorComponent } from './org-template-editor.component'; import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template'; -import { UploadState, initialUpload } from '@shared/upload/upload.machine'; +import { UploadState, initialUpload } from '@shared/domain/upload.machine'; const draft: OrgTemplate = { subOrgId: 'cibg-registers', diff --git a/apps/ssp/src/app/brief/ui/org-template.page.ts b/apps/ssp/src/app/brief/ui/org-template.page.ts index f28b7b4..697f19d 100644 --- a/apps/ssp/src/app/brief/ui/org-template.page.ts +++ b/apps/ssp/src/app/brief/ui/org-template.page.ts @@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; @@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te export class OrgTemplatePage { protected store = inject(OrgTemplateStore); protected access = inject(AccessStore); - private uploadAdapter = inject(UploadAdapter); protected canEdit = computed(() => this.access.can('orgtemplate:edit')); - protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId); + protected previewUrlFor = this.store.previewUrlFor; protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`; protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`; diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts index 2c457a7..ffed921 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts @@ -7,7 +7,7 @@ import { reduceUpload, requiredCategoriesSatisfied, deliveryRefs, -} from '@shared/upload/upload.machine'; +} from '@shared/domain/upload.machine'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index f2cc4ad..986dae0 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -23,9 +23,8 @@ import { } from '@herregistratie/domain/herregistratie.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; -import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; +import { createUploadController } from '@shared/application/upload-controller'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; /** Organism: multi-step herregistratie wizard. ALL state lives in one signal driven by the pure `reduce` function (see herregistratie.machine.ts) via an @@ -149,13 +148,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload. }) export class HerregistratieWizardComponent { private profile = inject(BigProfileStore); - private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); - /** Preview/download link for a completed upload; dev-simulation `demo-*` ids have - no stored bytes, so they get no link. */ + /** Preview/download link for a completed upload; delegates to the upload + controller (application layer), which knows the dev-simulation `demo-*` ids + have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => - documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); + this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index f876c5e..4a632a3 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { WizardState } from '@herregistratie/domain/herregistratie.machine'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { Uren } from '@registratie/domain/value-objects/uren'; const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] }; diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 695b314..30aac1a 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { expectTag } from '@shared/testing/expect-tag'; import { Draft, diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts index 73b96d4..069178a 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts @@ -9,7 +9,7 @@ import { reduceUpload, requiredCategoriesSatisfied, deliveryRefs, -} from '@shared/upload/upload.machine'; +} from '@shared/domain/upload.machine'; /** * A FIXED 3-step registration wizard. The steps never change in number (always diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index aa2b60a..25e3b19 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -37,9 +37,8 @@ import { } from '@registratie/domain/registratie-wizard.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; -import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; +import { createUploadController } from '@shared/application/upload-controller'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; const KANALEN = [ { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, @@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; }) export class RegistratieWizardComponent { private lookup = inject(RegistratieLookupStore); - private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); - /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids - have no stored bytes, so they get no link. */ + /** Preview/download link for a completed upload; delegates to the upload + controller (application layer), which knows the dev-simulation `demo-*` ids + have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => - documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); + this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / tests can mount any state directly. */ seed = input(initial); diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts index 5fd9ef4..29f31db 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -8,7 +8,7 @@ import { RegistratieState, ValidRegistratie, } from '@registratie/domain/registratie-wizard.machine'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { Postcode } from '@registratie/domain/value-objects/postcode'; const adres: Partial = { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fe54364..d55b046 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -125,7 +125,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | | **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | | **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | | **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | | **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | | **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md new file mode 100644 index 0000000..599034b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md @@ -0,0 +1,178 @@ +# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 · +`99-backlog.md` RB-24, "Merges" table row for RB-25/26/27 + +## What was wrong + +`libs/shared/src/upload/` held five files outside the folder-per-layer convention every +other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw +`XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`. +`upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/` +folder. The exception was hard-coded into the enforcement itself: +`.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read +`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the +violation instead of the violation being fixed, which is why the baseline scan reported 0 +violations despite this. + +## What changed + +| From `libs/shared/src/upload/` | To | +| -------------------------------- | ----------------------------------------------------- | +| `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` | +| `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) | +| `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` | +| `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` | + +All five moves used `git mv`. `libs/shared/src/upload/` no longer exists. + +**Import updates.** 24 consumer files import from `@shared/upload/*` (found with +`grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated +`@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths +were rewritten to the new locations (30 import statements total, some files import more +than one symbol). No export was renamed, no file was split, no logic changed in any of +these 24 files beyond the import path string. + +**Within the five moved files**, three had relative imports (`./upload.adapter`, +`./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases: +`upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine` → +`@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and +`upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`; +`upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it +and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine` +import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed +pure rename. + +**The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule: +`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` → `from: { pathNot: '/infrastructure/' }`, +comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further +consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules" +paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only +inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since +leaving it would document a rule that no longer exists. + +## A second, real violation the move exposed — fixed, not just reported + +Deleting the carve-out did not by itself make `dep:check` pass. A **separate, +pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import +`infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because +before this move the file's path did not contain `/infrastructure/` at all. Three UI +components were injecting `UploadAdapter` directly: +`apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`, +`apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`, +and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically +moved into `infrastructure/`, `dep:check` correctly flagged all three: + +``` +error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts +error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts +error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts +``` + +This is judged in-scope to fix, not a second unrelated finding to merely report, for three +reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the +deletion, the move is incomplete, so fix the move rather than restoring the clause." +Second, all three call sites used `UploadAdapter` for exactly one thing — +`.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported, +injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no +injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into +its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts` +already used that pure function directly; the three UI files had independently reinvented +`inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays +inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27 +target: + +- `libs/shared/src/application/upload-controller.ts` — the object `createUploadController` + returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure + `uploadContentUrl`. Both wizard components already hold a `createUploadController` + instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now + delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself. +- `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects + `UploadAdapter` legitimately — it's application layer) gained one more computed-style + field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now + reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`. + +No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the +identical string (the method is a one-line pass-through to the function), and the +`demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method +verbatim. + +## Verification + +- **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as + a 0-insertion/0-deletion pure rename — no content changed, including its own imports + (both files moved into `domain/` together, so its `./upload.machine` relative import + needed no edit). No spec content changed anywhere in this ticket. +- `npm run dep:check`: **passes for both apps** with the carve-out clause removed — + `✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp), + `✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal). +- `npm run lint`: clean. +- `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing** + (ssp / behandelportal / shared / beheer). +- `npm run build`: both apps build (pre-existing, unrelated warnings about + `/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time — + present before this ticket, vendored assets resolved at serve/deploy time, not a + regression from this move). +- **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`): + the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100% + functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002 + predicted landing in a folder the baseline reported at "0% spec reach across 3 files" + (`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which + remain unspecced — that gap is pre-existing and out of this ticket's scope). + +## Non-TypeScript references to the old path — findings + +Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config +file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js` +files, and `libs/shared/docs/*.mdx`. + +- **Storybook config, angular.json, dependency-cruiser app configs**: no reference to + `shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change. +- **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself, + deleted (see above). +- **`docs/reference/architecture/dependencies.md`**: one prose reference to the same + carve-out, corrected in this diff (see above) since it directly describes the rule this + ticket edits. +- **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or + `@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and + `shared/ui/upload/...` by filename/short-path only, never the full old directory path — + both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling + folder). +- **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**: + each carries a handful of `src/app/shared/upload/upload.machine.ts` + /`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor, + informational only (they tell a translator where a string originated; they are not + read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is + `npm run extract-i18n`'s job for the source-locale file and does not touch the + hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the + move plus import updates, not a translation-tooling refresh. They will self-correct + the next time `extract-i18n` runs for an unrelated reason. +- **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**: + several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`, + `06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`) + reference the old path — expected, since most of them describe or cite the violation + this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell + (see below). + +## What RB-25/26/27 now find where + +- **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`): + `KeepaliveTransport` and `UploadShellService` are both now in + `libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The + token belongs in `application/` alongside them — nothing about the token's shape or + location changes because of this move. +- **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now + `libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected` + callback — the accept/reject decision RB-26 targets — is in + `libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise + unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket — + see above). RB-26 should extend `upload.machine.ts` in its new location; no import path + in that file needs touching beyond what this ticket already did. +- **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure + is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same + file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and + `genericError` are all still exactly where they were, just under `infrastructure/`. + +## `npm run ci` + +Result and step count reported in the final answer. diff --git a/docs/reference/architecture/dependencies.md b/docs/reference/architecture/dependencies.md index 8b97476..cb878e2 100644 --- a/docs/reference/architecture/dependencies.md +++ b/docs/reference/architecture/dependencies.md @@ -35,7 +35,7 @@ reverse. An app may not import the other app's source directly. **Atomic-layer rules:** `domain/` is framework-free (no Angular); `contracts/` import nothing (pure wire DTOs, ADR-0001); `ui/` + `layout/` never import `infrastructure/` directly (reach data through an application store/command — type-only DTO imports are fine); the generated `ApiClient` -is a value only inside `infrastructure/` (+ `libs/shared/src/upload`). Plus **no circular** +is a value only inside `infrastructure/`. Plus **no circular** dependencies. These apply uniformly across an app's tree and both libraries — no debug-state exception anymore (WP-67 moved the dev panel component out of `libs/shared` into `apps/ssp` since it's genuinely SSP-specific, coupled to `BigProfileStore`; the shared `ShellComponent` hosts diff --git a/libs/shared/src/upload/upload-controller.ts b/libs/shared/src/application/upload-controller.ts similarity index 88% rename from libs/shared/src/upload/upload-controller.ts rename to libs/shared/src/application/upload-controller.ts index cf6cb56..7fa1522 100644 --- a/libs/shared/src/upload/upload-controller.ts +++ b/libs/shared/src/application/upload-controller.ts @@ -1,9 +1,19 @@ import { DestroyRef, effect, inject } from '@angular/core'; -import { CategoryParams, UploadAdapter } from './upload.adapter'; +import { + CategoryParams, + UploadAdapter, + uploadContentUrl, +} from '@shared/infrastructure/upload.adapter'; import { UploadShellService } from './upload-shell.service'; import { problemDetail } from '@shared/infrastructure/api-error'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine'; +import { + DeliveryChannel, + UploadMsg, + UploadState, + inFlight, + rejectReason, +} from '@shared/domain/upload.machine'; export interface UploadControllerDeps { wizardId: string; @@ -59,6 +69,11 @@ export function createUploadController(deps: UploadControllerDeps) { } return { + /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids + have no stored bytes, so they get no link. */ + previewUrlFor(documentId: string): string | undefined { + return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId); + }, onFileSelected(categoryId: string, selected: File[]) { const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId); if (!cat) return; diff --git a/libs/shared/src/upload/upload-shell.service.ts b/libs/shared/src/application/upload-shell.service.ts similarity index 94% rename from libs/shared/src/upload/upload-shell.service.ts rename to libs/shared/src/application/upload-shell.service.ts index 4b88329..bab1e67 100644 --- a/libs/shared/src/upload/upload-shell.service.ts +++ b/libs/shared/src/application/upload-shell.service.ts @@ -1,7 +1,12 @@ import { Injectable, inject } from '@angular/core'; -import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED } from './upload.adapter'; +import { + UploadAdapter, + XhrUploadRequest, + XhrUploadHandle, + UPLOAD_ABORTED, +} from '@shared/infrastructure/upload.adapter'; import { problemDetail } from '@shared/infrastructure/api-error'; -import { UploadMsg, Upload } from './upload.machine'; +import { UploadMsg, Upload } from '@shared/domain/upload.machine'; /** * Transport seam (PRD §6): how upload bytes leave the browser. The shipped impl is diff --git a/libs/shared/src/upload/upload.machine.spec.ts b/libs/shared/src/domain/upload.machine.spec.ts similarity index 100% rename from libs/shared/src/upload/upload.machine.spec.ts rename to libs/shared/src/domain/upload.machine.spec.ts diff --git a/libs/shared/src/upload/upload.machine.ts b/libs/shared/src/domain/upload.machine.ts similarity index 100% rename from libs/shared/src/upload/upload.machine.ts rename to libs/shared/src/domain/upload.machine.ts diff --git a/libs/shared/src/upload/upload.adapter.ts b/libs/shared/src/infrastructure/upload.adapter.ts similarity index 99% rename from libs/shared/src/upload/upload.adapter.ts rename to libs/shared/src/infrastructure/upload.adapter.ts index a0d1620..0cb0e7e 100644 --- a/libs/shared/src/upload/upload.adapter.ts +++ b/libs/shared/src/infrastructure/upload.adapter.ts @@ -8,7 +8,7 @@ import { problemDetail } from '@shared/infrastructure/api-error'; import { currentScenario } from '@shared/infrastructure/scenario'; import { currentSubject } from '@shared/infrastructure/subject'; import { environment } from '@shared/environments/environment'; -import { DocumentCategory } from './upload.machine'; +import { DocumentCategory } from '@shared/domain/upload.machine'; /** Answer-derived query params that affect which categories the server presents. */ export interface CategoryParams { diff --git a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts index 41fab35..59d2bc4 100644 --- a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts +++ b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { DeliveryChannel } from '@shared/upload/upload.machine'; +import type { DeliveryChannel } from '@shared/domain/upload.machine'; /** Atom: choose how a document is delivered — uploaded digitally or sent by post. Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */ diff --git a/libs/shared/src/ui/upload/document-category/document-category.component.ts b/libs/shared/src/ui/upload/document-category/document-category.component.ts index 33193bb..6540530 100644 --- a/libs/shared/src/ui/upload/document-category/document-category.component.ts +++ b/libs/shared/src/ui/upload/document-category/document-category.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine'; +import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/domain/upload.machine'; import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component'; import { FileInputComponent } from '../file-input/file-input.component'; import { SingleUploadComponent } from '../single-upload/single-upload.component'; diff --git a/libs/shared/src/ui/upload/document-category/document-category.stories.ts b/libs/shared/src/ui/upload/document-category/document-category.stories.ts index a82275f..1bc2aed 100644 --- a/libs/shared/src/ui/upload/document-category/document-category.stories.ts +++ b/libs/shared/src/ui/upload/document-category/document-category.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { DocumentCategory, Upload } from '@shared/upload/upload.machine'; +import type { DocumentCategory, Upload } from '@shared/domain/upload.machine'; import { DocumentCategoryComponent } from './document-category.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts index e3a10b8..b5053e3 100644 --- a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts +++ b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from '@angular/core'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component'; const STATUS_LABELS: Record = { diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts index ac1ff05..4747651 100644 --- a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts +++ b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; import { DocumentChipComponent } from './document-chip.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts index 9aee010..eb024f1 100644 --- a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts +++ b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine'; +import type { DeliveryChannel, UploadState } from '@shared/domain/upload.machine'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { DocumentCategoryComponent } from '../document-category/document-category.component'; diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts index 578ca75..af6fff8 100644 --- a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts +++ b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { UploadState } from '@shared/upload/upload.machine'; +import type { UploadState } from '@shared/domain/upload.machine'; import { DocumentUploadComponent } from './document-upload.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts index 25454bd..6fdda98 100644 --- a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts +++ b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import type { Upload } from '@shared/upload/upload.machine'; +import type { Upload } from '@shared/domain/upload.machine'; import { DocumentChipComponent } from '../document-chip/document-chip.component'; import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.component'; diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts index 5d4ee64..f612abe 100644 --- a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts +++ b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { Upload } from '@shared/upload/upload.machine'; +import type { Upload } from '@shared/domain/upload.machine'; import { SingleUploadComponent } from './single-upload.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts index b5ac079..a2cdf4a 100644 --- a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts +++ b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from '@angular/core'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; interface Glyph { char: string; From 07bb6277c0fbd47682e7ffe25317421688050493 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 20:42:00 +0200 Subject: [PATCH 49/61] refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BriefStore's five guard decisions (Save, Submit, Send, and the shared Approve/Reject review path) were pure functions of status tag, actor role, and entity completeness, but each sat inside a lock-held, DB-opening method. A spec could not exercise the decision without a booted host and a real SQLite file. Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore keeps its lock, its Db.Create(), its static shape, and every method signature — only the if cascades move. Add BriefRuleTests.cs (29 assertions, ~120 ms, no host boot) covering every branch, including the rejected-to-draft reopen on save, the required-filled gate on submit, and the non-drafter and self-review denials. The existing host-booting brief endpoint tests are unchanged and still pass, proving the extraction preserved behaviour. Co-Authored-By: Claude Opus 5 --- .../src/BigRegister.Api/Data/BriefStore.cs | 21 +- .../Domain/Letters/BriefRules.cs | 63 ++++++ .../Domain/BriefRuleTests.cs | 147 ++++++++++++ .../refactor-backlog/99-backlog.md | 2 +- .../refactor-backlog/implementation/rb-30.md | 210 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 26 ++- 6 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs index f880d98..170b3fd 100644 --- a/backend/src/BigRegister.Api/Data/BriefStore.cs +++ b/backend/src/BigRegister.Api/Data/BriefStore.cs @@ -68,10 +68,10 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (!isDrafter) return (Outcome.Forbidden, null); - if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null); + var outcome = BriefRules.CanSave(e.Status, isDrafter); + if (outcome != Outcome.Ok) return (outcome, null); e.Sections = sections.ToList(); - if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft"); + e.Status = BriefRules.StatusAfterSave(e.Status); db.SaveChanges(); return (Outcome.Ok, e); } @@ -84,8 +84,8 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (!isDrafter) return (Outcome.Forbidden, null); - if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null); + var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections)); + if (outcome != Outcome.Ok) return (outcome, null); e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at); db.SaveChanges(); return (Outcome.Ok, e); @@ -107,7 +107,8 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (e.Status.Tag != "approved") return (Outcome.Conflict, null); + var outcome = BriefRules.CanSend(e.Status); + if (outcome != Outcome.Ok) return (outcome, null); e.Status = new BriefStatusDto("sent", SentAt: at); // Pin the org-template version the letter was sent with (WP-23): from here on // its appearance is frozen — republishing the template touches unsent briefs only. @@ -150,7 +151,7 @@ public static class BriefStore // from the drafter (a drafter cannot approve their own letter). The SoD check is // Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked // BEFORE the status guard so Forbidden vs Conflict ordering matches the old - // inline check exactly. + // inline check exactly (BriefRules.CanDecide preserves that order). private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func next) { lock (_gate) @@ -158,15 +159,13 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null); - if (e.Status.Tag != "submitted") return (Outcome.Conflict, null); + var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId); + if (outcome != Outcome.Ok) return (outcome, null); e.Status = next(); db.SaveChanges(); return (Outcome.Ok, e); } } - - private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0); } /// Seeded template (sections + placeholder fields) and passage library. diff --git a/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs b/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs new file mode 100644 index 0000000..a5bea7c --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs @@ -0,0 +1,63 @@ +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Authorization; + +namespace BigRegister.Domain.Letters; + +/// +/// SERVER-OWNED brief state-transition and authorization rules (RB-30, TE-008). Each +/// method is a pure decision over (status tag, actor role, entity completeness) — +/// extracted out of 's lock-held, DB-opening methods so the +/// decision can be unit-tested without a booted host or a real SQLite file. Callers +/// pass the values the rule needs, never the entity, so this stays pure. +/// +/// Returns — that type already exists as the domain +/// concept the whole brief flow reports through (`BriefResult` in Program.cs switches +/// on it directly), so this reuses it rather than inventing a second result shape. +/// +public static class BriefRules +{ + /// Save is drafter-only, and only while the letter is editable (draft/rejected). + /// Order matches the store's original inline check: role before status, so a + /// non-drafter always sees Forbidden even against a non-editable status. + public static BriefStore.Outcome CanSave(BriefStatusDto status, bool isDrafter) + { + if (!isDrafter) return BriefStore.Outcome.Forbidden; + if (status.Tag is not ("draft" or "rejected")) return BriefStore.Outcome.Conflict; + return BriefStore.Outcome.Ok; + } + + /// A save on a rejected letter reopens it to draft (mirrors the FE reducer); a save + /// on a draft leaves the status untouched. + public static BriefStatusDto StatusAfterSave(BriefStatusDto status) => + status.Tag == "rejected" ? new BriefStatusDto("draft") : status; + + /// Every required section needs at least one block before a letter is submittable. + public static bool RequiredFilled(IReadOnlyList sections) => + sections.All(s => !s.Required || s.Blocks.Count > 0); + + /// Submit is drafter-only, only from draft, and only once every required section + /// is filled. + public static BriefStore.Outcome CanSubmit(BriefStatusDto status, bool isDrafter, bool requiredFilled) + { + if (!isDrafter) return BriefStore.Outcome.Forbidden; + if (status.Tag != "draft" || !requiredFilled) return BriefStore.Outcome.Conflict; + return BriefStore.Outcome.Ok; + } + + /// Send only from approved — sending is a mechanical dispatch step, not role-gated + /// (Authz.CanActOn already returns true unconditionally for BriefAction.Send). + public static BriefStore.Outcome CanSend(BriefStatusDto status) => + status.Tag == "approved" ? BriefStore.Outcome.Ok : BriefStore.Outcome.Conflict; + + /// Approve/Reject share this guard: the caller must be entitled to act on the letter + /// (four-eyes/SoD, via the existing ), and the letter must + /// be submitted. The entitlement check runs BEFORE the status check — Forbidden takes + /// priority over Conflict, matching the store's original order exactly. + public static BriefStore.Outcome CanDecide(BriefAction action, BriefStatusDto status, Principal principal, string drafterId) + { + if (!Authz.CanActOn(action, principal, drafterId)) return BriefStore.Outcome.Forbidden; + if (status.Tag != "submitted") return BriefStore.Outcome.Conflict; + return BriefStore.Outcome.Ok; + } +} diff --git a/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs new file mode 100644 index 0000000..de01174 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs @@ -0,0 +1,147 @@ +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Authorization; +using BigRegister.Domain.Letters; + +namespace BigRegister.Tests.Domain; + +public class BriefRuleTests +{ + private static BriefStatusDto Status(string tag) => new(tag); + + private static readonly Principal Drafter = new(PrincipalRole.Drafter); + private static readonly Principal Approver = new(PrincipalRole.Approver); + + // --- CanSave ----------------------------------------------------------------- + + [Theory] + [InlineData("draft")] + [InlineData("rejected")] + public void A_drafter_may_save_a_draft_or_rejected_letter(string tag) => + Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSave(Status(tag), isDrafter: true)); + + [Theory] + [InlineData("submitted")] + [InlineData("approved")] + [InlineData("sent")] + public void A_drafter_may_not_save_a_non_editable_letter(string tag) => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSave(Status(tag), isDrafter: true)); + + [Theory] + [InlineData("draft")] + [InlineData("submitted")] + public void A_non_drafter_is_forbidden_to_save_regardless_of_status(string tag) => + // Role is checked before status: Forbidden wins even against an otherwise-open status. + Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSave(Status(tag), isDrafter: false)); + + // --- StatusAfterSave ----------------------------------------------------------- + + [Fact] + public void Saving_a_rejected_letter_reopens_it_to_draft() => + Assert.Equal("draft", BriefRules.StatusAfterSave(Status("rejected")).Tag); + + [Fact] + public void Saving_a_draft_letter_leaves_its_status_unchanged() => + Assert.Equal("draft", BriefRules.StatusAfterSave(Status("draft")).Tag); + + // --- RequiredFilled -------------------------------------------------------------- + + private static LetterSectionDto Section(string key, bool required, int blockCount) => + new(key, key, required, Enumerable.Range(0, blockCount) + .Select(i => new LetterBlockDto("freeText", $"{key}-{i}", new RichTextBlockDto(Array.Empty()))) + .ToList()); + + [Fact] + public void No_required_sections_means_nothing_to_fill() => + Assert.True(BriefRules.RequiredFilled(Array.Empty())); + + [Fact] + public void An_optional_empty_section_does_not_block_submission() => + Assert.True(BriefRules.RequiredFilled(new[] { Section("slot", required: false, blockCount: 0) })); + + [Fact] + public void A_required_section_with_a_block_is_filled() => + Assert.True(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 1) })); + + [Fact] + public void A_required_section_with_no_blocks_is_not_filled() => + Assert.False(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 0) })); + + [Fact] + public void One_unfilled_required_section_blocks_submission_even_if_others_are_filled() => + Assert.False(BriefRules.RequiredFilled(new[] + { + Section("kern", required: true, blockCount: 1), + Section("bijlage", required: true, blockCount: 0), + })); + + // --- CanSubmit ----------------------------------------------------------------- + + [Fact] + public void A_drafter_may_submit_a_filled_draft() => + Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: true)); + + [Fact] + public void A_drafter_may_not_submit_an_unfilled_draft() => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: false)); + + [Fact] + public void A_drafter_may_not_submit_a_letter_that_is_not_a_draft() => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("submitted"), isDrafter: true, requiredFilled: true)); + + [Fact] + public void A_non_drafter_is_forbidden_to_submit_even_a_filled_draft() => + // Role is checked before status/completeness: Forbidden wins over Conflict. + Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSubmit(Status("draft"), isDrafter: false, requiredFilled: true)); + + // --- CanSend --------------------------------------------------------------------- + + [Fact] + public void An_approved_letter_may_be_sent() => + Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSend(Status("approved"))); + + [Theory] + [InlineData("draft")] + [InlineData("submitted")] + [InlineData("rejected")] + [InlineData("sent")] + public void Only_an_approved_letter_may_be_sent(string tag) => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSend(Status(tag))); + + // --- CanDecide (Approve/Reject shared guard) -------------------------------------- + + [Theory] + [InlineData(BriefAction.Approve)] + [InlineData(BriefAction.Reject)] + public void An_approver_may_decide_a_submitted_letter_drafted_by_someone_else(BriefAction action) => + Assert.Equal( + BriefStore.Outcome.Ok, + BriefRules.CanDecide(action, Status("submitted"), Approver, drafterId: BriefStore.DrafterId)); + + [Fact] + public void A_drafter_may_not_approve_or_reject() => + Assert.Equal( + BriefStore.Outcome.Forbidden, + BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Drafter, drafterId: BriefStore.DrafterId)); + + [Fact] + public void An_approver_may_not_decide_a_letter_they_drafted_themselves() => + // Four-eyes / SoD: the acting approver id happens to equal the letter's drafterId. + Assert.Equal( + BriefStore.Outcome.Forbidden, + BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Approver, drafterId: BriefStore.ApproverId)); + + [Fact] + public void An_approver_may_not_decide_a_letter_that_is_not_submitted() => + Assert.Equal( + BriefStore.Outcome.Conflict, + BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.DrafterId)); + + [Fact] + public void Entitlement_is_checked_before_status_forbidden_wins_over_conflict() => + // Same actor as drafter AND a non-submitted status: still Forbidden, not Conflict — + // matches the store's original check order (Authz.CanActOn before the status guard). + Assert.Equal( + BriefStore.Outcome.Forbidden, + BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.ApproverId)); +} diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fe54364..a95d9fa 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -131,7 +131,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | | **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | | **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | | **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | | **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | | **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md new file mode 100644 index 0000000..207d4f3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md @@ -0,0 +1,210 @@ +# RB-30 — extract `BriefStore`'s guards into `Domain/Letters/BriefRules.cs` + +Status: **implemented** · 2026-08-27 · Source finding: `02-testability.md` TE-008 · +`99-backlog.md` RB-30 + +RB-30 moves the brief workflow's five guard decisions out of `BriefStore` (a +lock-held, DB-opening static store) into a pure `Domain/Letters/BriefRules.cs`, and +adds a free-running unit test file for them. This is a pure extraction: the store +keeps its lock, its `Db.Create()`, its static shape, and every method's signature. + +## What was wrong + +Five guard clusters in `Data/BriefStore.cs` are pure decisions over `(status tag, +actor role, entity completeness)` — Save, Submit, Send, and the shared Approve/Reject +review path each start with an `if` cascade that is a function of two enums and a +bool. But every one of those `if`s sat inside a method that had already done `lock +(_gate) { using var db = Db.Create(); ... }`, so a spec could not exercise the +decision without a booted host and a real SQLite file. `Domain/Letters/` held only +`LetterHtml.cs` and `OrgTemplateRules.cs`; there was no `BriefRules` class, even +though `Authz.CanActOn` — a pure `Domain/Authorization/` call one line away from +three of the guards — already proved the pattern worked for this exact file. + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs` | New. Five pure statics: `CanSave`, `StatusAfterSave`, `RequiredFilled` + `CanSubmit`, `CanSend`, `CanDecide`. All take `BriefStatusDto`/`bool`/`Principal`/`string`, never `BriefEntity` — no persistence type reaches this file. | +| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `Save`, `Submit`, `Send`, and the private `Review` (the Approve/Reject shared path) each replace their inline `if` cascade with one call into `BriefRules`, then branch only on the returned `Outcome`. The private `RequiredFilled(BriefEntity e)` helper is deleted — `BriefRules.RequiredFilled(IReadOnlyList)` replaces it. Lock, `Db.Create()`, method signatures, and the public `Outcome` enum are all unchanged. | +| `backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs` | New. 29 `[Fact]`/`[Theory]` assertions covering every branch of all five rules — see "Tests added" below. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-30's status cell: `open` → `done`. | + +## The surface, as built — and where it differs from TE-008's proposal + +TE-008 proposed: + +``` +CanSave(BriefStatusDto status, bool isDrafter) → Outcome +StatusAfterSave(BriefStatusDto) → BriefStatusDto +CanSubmit(status, isDrafter, bool requiredFilled) → Outcome +CanSend(status) +CanDecide(status, Principal, drafterId) +``` + +The ticket explicitly names this a proposal, not a specification. What was built +matches it almost exactly, with two adjustments forced by the real code: + +- **`Outcome` is `BriefStore.Outcome`, not a new type.** `BriefStore` already + exposes a public `enum Outcome { Ok, Forbidden, Conflict }`, and `Program.cs`'s + `BriefResult` switches on it directly across every brief endpoint. TE-008 itself + says: "if `Outcome` does not already exist as a domain concept, use whatever the + sibling rule classes already return" — it does exist, so `BriefRules` returns it + rather than inventing a second result shape. This does mean `Domain/Letters/` + references a type nested in `Api.Data`; the same cross-reference already exists in + this file's neighbor, `LetterHtml.cs` (`using BigRegister.Api.Data;`, for + `BriefEntity`), and in `Authz.cs` (for `BriefStore`'s role-id constants) — both in + the same single-assembly project, so this is a namespace convention, not an + assembly boundary. `Outcome` itself is a plain three-value enum with no EF/ASP.NET + attached, so this does not pull a persistence type into `Domain/`. +- **`CanDecide` takes an explicit `BriefAction action` parameter**, not just + `(status, Principal, drafterId)`. The real guard — `BriefStore.Review` — is one + private method shared by both `Approve` and `Reject`, and it calls + `Authz.CanActOn(action, principal, drafterId)`, which needs to know which action is + being attempted. `BriefRules.CanDecide` composes that existing pure + `Authz.CanActOn` call with the status check, rather than re-implementing the SoD + logic a second time — so the four-eyes rule still has exactly one source of truth. + +The `RequiredFilled` predicate is a sixth pure static, not one of the five guards +proper — TE-008 names it separately ("plus the `RequiredFilled(e)` predicate") and it +is built the same way: `RequiredFilled(IReadOnlyList sections) → +bool`, taking the section list rather than the entity. + +## Order and behaviour preserved + +Every rule keeps the original check order, which matters because `Outcome.Forbidden` +must outrank `Outcome.Conflict` (a non-drafter or non-entitled caller sees Forbidden +even against an otherwise-invalid status): + +- `CanSave`: `!isDrafter` (Forbidden) before the status-tag check (Conflict). +- `CanSubmit`: `!isDrafter` (Forbidden) before `status.Tag != "draft" || +!requiredFilled` (Conflict). +- `CanDecide`: `!Authz.CanActOn(...)` (Forbidden) before `status.Tag != "submitted"` + (Conflict) — the exact order the old inline check in `Review` used, per its own + comment ("checked BEFORE the status guard"). +- `CanSave`'s entity-not-found branch (`e is null → Conflict`) stays inline in + `BriefStore` — it is a persistence fact ("no row for this owner"), not one of the + three business axes TE-008 names (status tag, actor role, entity completeness), so + it was left where it was rather than forced into a rule that would then need to + accept a nullable entity. + +## Tests added + +`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, 29 assertions, alongside +the seven Domain test files that already existed: + +- **`CanSave`** — drafter saves draft/rejected (Ok, `[Theory]`); drafter saves + submitted/approved/sent (Conflict, `[Theory]`); non-drafter saves draft or submitted + (Forbidden both times — proves role beats status). +- **`StatusAfterSave`** — rejected → draft; draft stays draft. +- **`RequiredFilled`** — no sections; an unfilled optional section; a filled required + section; an unfilled required section; one filled + one unfilled required section + (proves one bad section blocks the whole letter). +- **`CanSubmit`** — filled draft (Ok); unfilled draft (Conflict — **the required-filled + gate the ticket explicitly asked for**); non-draft status (Conflict); non-drafter + on a filled draft (Forbidden — role beats completeness). +- **`CanSend`** — approved (Ok); draft/submitted/rejected/sent (Conflict, `[Theory]`). +- **`CanDecide`** — approver decides a submitted letter drafted by someone else, for + both Approve and Reject (Ok, `[Theory]`); a drafter attempting to decide (Forbidden + — **the non-drafter denial the ticket asked for**); an approver whose acting id + equals the drafter id, i.e. self-review (Forbidden — the four-eyes/SoD case); an + approver deciding a non-submitted letter (Conflict); an approver who is also the + drafter AND the status is non-submitted (Forbidden, not Conflict — proves the + priority order survived the extraction). + +## Verified red without the fix + +Inverted `CanSubmit`'s completeness check (`!requiredFilled` → `requiredFilled`) with +an `Edit`, ran `BriefRuleTests` alone: + +``` +[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_not_submit_an_unfilled_draft [FAIL] + Assert.Equal() Failure: Values differ +Expected: Conflict +Actual: Ok +[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_submit_a_filled_draft [FAIL] + Assert.Equal() Failure: Values differ +Expected: Ok +Actual: Conflict + +Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29 +``` + +Reverted with a second `Edit` (never `git checkout` — that would have discarded the +whole file). Reran: 29/29 green. + +## Existing tests — unchanged + +`BriefEndpointTests.cs`, `PreviewEndpointTests.cs`, and `OrgTemplateEndpointTests.cs` +(the three host-booting suites that exercise the brief endpoints) needed **no +changes**. Ran together: 32/32 passing, proving the extraction preserved every HTTP +outcome (`Save_is_drafter_only`, `Submit_blocks_on_empty_required_section`, +`Submit_succeeds_when_required_sections_filled`, +`Drafter_cannot_approve_own_letter_but_a_different_reviewer_can`, +`Reject_returns_comments`, `Editing_a_rejected_letter_reopens_it_to_draft`, +`Send_only_from_approved`, and the rest, all unmodified). + +## The metric TE-008 cares about: host-booting brief-rule assertions + +Before this ticket, the five guard decisions had **zero** free-running unit +assertions. Every branch of every guard was reachable only through the seven +host-booting endpoint test methods above (six of them containing an explicit +`Assert.Equal(HttpStatusCode.Forbidden/Conflict, ...)`, each paying a full +`TestWebApplicationFactory` host boot plus a real SQLite round-trip, run serially +process-wide because of `[assembly: DisableTestParallelization]`). + +After this ticket: + +- **0 → 29** free-running unit assertions covering these branches + (`BriefRuleTests.cs`, `dotnet test --filter FullyQualifiedName~BriefRuleTests` + completes in **~120 ms**, no host, no SQLite file). +- **7 → 7** host-booting endpoint tests, unchanged. They stay — they are now the + proof that `BriefStore` wires `BriefRules`'s answer to the right HTTP status, not + the only place the business decision itself is checked. That split (wiring proven + at the integration layer, decision logic proven at the unit layer) is the seam + TE-008 argued for. +- New branches this ticket made assertable that the endpoint suite never covered + directly: the SoD self-review case (`An_approver_may_not_decide_a_letter_they_drafted_themselves`) + and the Forbidden-beats-Conflict priority ordering for both `CanSave`/`CanSubmit` + (role checked first) and `CanDecide` (entitlement checked first) — these existed as + implicit behaviour in the original `if` cascades but had no assertion pinning them + before RB-30. + +## What was not extracted + +Nothing — all five guards named in TE-008, plus the `RequiredFilled` predicate, moved +cleanly. None needed the `DbContext`: each was already a function of values already +resident on the in-memory `BriefEntity` (its `Status`, `Sections`, `DrafterId`), never +of a query against the database itself. + +## Scope respected + +- `Domain/Letters/LetterHtml.cs` was not touched (a concurrent agent owns it). +- `BriefEntity.ToDto()` was not touched — its CC 16 is a separate, out-of-scope + finding per the ticket. +- `Data/Db.cs`'s static-store decision and `TestWebApplicationFactory`'s serialized-test + position were not challenged; the store's lock, `Db.Create()`, and public shape are + byte-for-byte the same as before this ticket, other than the `if` cascades moving + out. + +## Verification + +- `dotnet build`: 0 warnings, 0 errors. +- `dotnet test --filter FullyQualifiedName~BriefRuleTests`: 29/29, ~120 ms. +- `dotnet test --filter FullyQualifiedName~BriefEndpointTests|...PreviewEndpointTests|...OrgTemplateEndpointTests`: + 32/32, unchanged. +- Full backend suite: **291/292 passing**, plus the one known, pre-existing, + container-dependent failure + (`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + "Connection refused (localhost:8000)") — not this ticket's bug, does not run under + `npm run ci`, reproduces on a clean tree with no OpenZaak container running. +- `npm run ci` (foreground, no background/Monitor): see the commit message / session + report for the exit code and step count. + +## What this ticket did not touch + +No frontend file was touched — the brief workflow's status machine is server- +authoritative, and the FE's own pure reducer (mirroring these same transitions for +UX) was already out of this ticket's scope. No file outside `backend/Data/BriefStore.cs`, +`backend/Domain/Letters/BriefRules.cs`, +`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, and `99-backlog.md` was +changed. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 581ab7d..ff45045 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 467 frontend behaviours across -9 contexts; 238 backend behaviours across 41 test +9 contexts; 259 backend behaviours across 42 test classes. ## Frontend (by context) @@ -1017,6 +1017,30 @@ classes. - Me returns no capabilities for drafter and the brief set for approver - Reset recreates a fresh draft with locked prefilled sections +### BriefRuleTests + +- A drafter may save a draft or rejected letter +- A drafter may not save a non editable letter +- A non drafter is forbidden to save regardless of status +- Saving a rejected letter reopens it to draft +- Saving a draft letter leaves its status unchanged +- No required sections means nothing to fill +- An optional empty section does not block submission +- A required section with a block is filled +- A required section with no blocks is not filled +- One unfilled required section blocks submission even if others are filled +- A drafter may submit a filled draft +- A drafter may not submit an unfilled draft +- A drafter may not submit a letter that is not a draft +- A non drafter is forbidden to submit even a filled draft +- An approved letter may be sent +- Only an approved letter may be sent +- An approver may decide a submitted letter drafted by someone else +- A drafter may not approve or reject +- An approver may not decide a letter they drafted themselves +- An approver may not decide a letter that is not submitted +- Entitlement is checked before status forbidden wins over conflict + ### DiplomaRuleTests - Profession is derived from program From ddd02f65bced1fe2dc340bb03d6fac3461fc1089 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 20:42:58 +0200 Subject: [PATCH 50/61] fix(backend): resolve the body datum placeholder from at, not UtcNow (RB-29) LetterHtml.Render already receives the letter's instant and uses it for the letterhead date. The body's "datum" placeholder resolved through ResolveAuto, which ignored that instant and read the wall clock instead. This is not a shipped bug today, because every current caller passes Now() at render time. It becomes one the moment Render runs with a historical instant (an archive re-render, a back-dated letter): the letterhead and the body would then disagree within one document. Thread the existing "at" parameter down through RenderParagraphs and RenderNode into ResolveAuto's "datum" case. Render's own signature, and every call site, stays unchanged. Add two tests with a fixed historical "at": one pins the body's rendered date to the expected Dutch string, the other asserts the letterhead date and the body date agree. Both fail red against the old code, showing today's date instead of the pinned one. Co-Authored-By: Claude Opus 5 --- .../Domain/Letters/LetterHtml.cs | 15 ++- .../BigRegister.Tests/LetterHtmlTests.cs | 61 +++++++++ .../refactor-backlog/99-backlog.md | 70 +++++----- .../refactor-backlog/implementation/rb-29.md | 125 ++++++++++++++++++ 4 files changed, 229 insertions(+), 42 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md diff --git a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs index fffb309..8ddc879 100644 --- a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs +++ b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs @@ -56,7 +56,7 @@ public static class LetterHtml { sb.Append("

").Append(Enc(section.Title)).Append("

"); foreach (var block in section.Blocks) - RenderParagraphs(sb, block.Content.Paragraphs, defs); + RenderParagraphs(sb, block.Content.Paragraphs, defs, at); sb.Append("
"); } sb.Append("
"); @@ -89,7 +89,8 @@ public static class LetterHtml private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)"; private static void RenderParagraphs( - StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs) + StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs, + string at) { string? openList = null; foreach (var para in paragraphs) @@ -101,14 +102,14 @@ public static class LetterHtml openList = para.List; } sb.Append(openList is null ? "

" : "

  • "); - foreach (var node in para.Nodes) RenderNode(sb, node, defs); + foreach (var node in para.Nodes) RenderNode(sb, node, defs, at); sb.Append(openList is null ? "

    " : "
  • "); } if (openList is not null) sb.Append(openList == "bullet" ? "" : ""); } private static void RenderNode( - StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs) + StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) { switch (node.Type) { @@ -122,7 +123,7 @@ public static class LetterHtml var key = node.Key ?? ""; var def = defs.GetValueOrDefault(key); var label = def?.Label ?? key; - sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]")); + sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label, at)) : Enc($"[NOG IN TE VULLEN: {label}]")); break; } } @@ -131,11 +132,11 @@ public static class LetterHtml // single demo applicant (SeedData.Registration — no per-brief resolved value is // ever stored, see the class doc above). Falls back to the label itself for any // other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback. - private static string ResolveAuto(string key, string label) => key switch + private static string ResolveAuto(string key, string label, string at) => key switch { "naam_zorgverlener" => SeedData.Registration.Naam, "big_nummer" => SeedData.Registration.BigNummer, - "datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")), + "datum" => FormatDatumNl(at), _ => label, }; diff --git a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs index a6415c4..3d59f71 100644 --- a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs +++ b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs @@ -80,6 +80,44 @@ public class LetterHtmlTests private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html"); + // A minimal brief whose body renders the "datum" placeholder — the golden-file + // fixture above never uses it in the body, only in the letterhead, so it cannot + // exercise ResolveAuto's "datum" case (TE-007). + private static BriefEntity FixtureBriefWithDatumInBody() => new() + { + BriefId = "datum-brief-1", + Owner = "golden", + Beroep = "arts", + TemplateId = "besluit-arts", + DrafterId = BriefStore.DrafterId, + Placeholders = new[] + { + new PlaceholderDefDto("datum", "Datum", true), + }, + Sections = new() + { + new("kern", "Kern van het besluit", true, new List + { + new("freeText", "kern-1", new RichTextBlockDto(new[] + { + new ParagraphDto(new[] { new RichTextNodeDto("placeholder", Key: "datum") }), + })), + }), + }, + Status = new BriefStatusDto("draft"), + }; + + private static string ExtractLetterheadDate(string html) => + Regex.Match(html, "
    Datum
    ([^<]+)
    ").Groups[1].Value; + + private static string ExtractBodyDatumParagraph(string html) + { + var bodyStart = html.IndexOf("
    ", StringComparison.Ordinal); + var bodyEnd = html.IndexOf("
    ", StringComparison.Ordinal); + var body = html[bodyStart..bodyEnd]; + return Regex.Match(body, "

    ([^<]+)

    ").Groups[1].Value; + } + [Fact] public void Render_matches_the_golden_file() { @@ -88,6 +126,29 @@ public class LetterHtmlTests Assert.Equal(golden, html); } + [Fact] + public void Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock() + { + const string historicalAt = "2019-03-14T08:00:00.0000000+00:00"; + + var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false); + + Assert.Equal("14 maart 2019", ExtractBodyDatumParagraph(html)); + } + + [Fact] + public void Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at() + { + // A historical `at` (an archive re-render, a back-dated letter) is the case + // where the letterhead and the body datum placeholder could disagree within + // one document, if the body still read the wall clock (TE-007). + const string historicalAt = "2019-03-14T08:00:00.0000000+00:00"; + + var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false); + + Assert.Equal(ExtractLetterheadDate(html), ExtractBodyDatumParagraph(html)); + } + [Fact] public void Every_letter_prefixed_class_exists_in_letter_css() { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fe54364..6d55727 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | implemented | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md new file mode 100644 index 0000000..e5181c4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md @@ -0,0 +1,125 @@ +# RB-29 — Thread `at` through `LetterHtml.ResolveAuto`'s `datum` case + +Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-007 · +`99-backlog.md` RB-29 + +## What was wrong + +`LetterHtml.Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)` +(`backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs`) already took the letter's +instant and used it correctly for the letterhead date +(`sb.Append(Enc(FormatDatumNl(at)))`). The body's `datum` placeholder resolved through +the private `ResolveAuto(string key, string label)`, which ignored `at` and called +`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` instead — a pure `Domain/` rule +class reading the wall clock. `ResolveAuto` is reached only through the private chain +`RenderNode` ← `RenderParagraphs` ← `Render`, so no caller outside this file could pin +the value a test would see. + +The ticket read as filed against the current code: `Render`'s signature, the letterhead's +correct use of `at`, and `ResolveAuto`'s `UtcNow` read were all exactly as TE-007 +described (line numbers had moved — CC around the file has grown since the finding was +written — but the code shape had not). One thing TE-007 named as the visible symptom +also checked out: `LetterHtmlTests.cs` already declares a +`new PlaceholderDefDto("datum", "Datum", true)` in its golden-file fixture, but no +`RichTextNodeDto` in that fixture's `Sections` actually references the `datum` key in +the body — it is declared but never rendered there, so the existing golden-file test +could not have caught this even if it asserted on dates (which it does not either). + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` | `ResolveAuto(string key, string label)` → `ResolveAuto(string key, string label, string at)`; `"datum" => FormatDatumNl(at)`. `at` threaded down through the two private call sites in the chain: `RenderParagraphs` and `RenderNode` both gained an `at` parameter, passed from `Render`'s own `at`. | +| `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` | New fixture `FixtureBriefWithDatumInBody()` — a minimal brief whose body actually references the `datum` placeholder (the golden fixture never does). Two new `[Fact]`s (see below) plus two small extraction helpers. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-29 status cell: `open` → `implemented`. | + +`Render`'s own signature is unchanged — TE-007's "zero public API change, zero +call-site change" held exactly. `Program.cs:697`, `:708`, and `BriefStore.cs:120` (the +three callers) needed no edit. + +## What the fix looks like + +```csharp +private static void RenderParagraphs( + StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs, + string at) +{ + // ... unchanged body, forwards `at` to RenderNode ... +} + +private static void RenderNode( + StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) +{ + // ... unchanged body, forwards `at` to ResolveAuto ... +} + +private static string ResolveAuto(string key, string label, string at) => key switch +{ + "naam_zorgverlener" => SeedData.Registration.Naam, + "big_nummer" => SeedData.Registration.BigNummer, + "datum" => FormatDatumNl(at), + _ => label, +}; +``` + +Both call sites already had `at` in scope (`Render`'s own parameter), so this is a pure +threading change — no new state, no new dependency. + +## Tests added + +TE-007 named the exact gap: the golden-file fixture declares the `datum` placeholder but +never renders it in the body, so no existing assertion could catch a body/letterhead +mismatch. A new fixture and two focused tests close it: + +1. **`Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock`** — + renders `FixtureBriefWithDatumInBody()` with a fixed historical `at` + (`2019-03-14T08:00:00.0000000+00:00`) and asserts the body's rendered paragraph is the + exact string `"14 maart 2019"`. A test using today's date would have passed before + and after the fix and proven nothing — this one pins a date nowhere near "now", so it + fails whenever the resolver reads the wall clock instead of `at`. +2. **`Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at`** + — same fixture and historical `at`, asserts the letterhead `
    ` date and the body's + rendered `datum` paragraph are equal. This is TE-007's stated payoff: not a shipped + bug today (every current caller passes `Now()` at render time, so the two dates always + coincided even with the bug present), but a latent one — the moment `Render` is ever + called with a historical `at` (re-rendering an archive, back-dating a letter), the + letterhead and body would disagree within a single document. This test is the one + that would have caught that. + +Both tests use the repo's one date formatter (`FormatDatumNl`, already used by both call +sites under test) only indirectly, through the literal expected string `"14 maart +2019"` — no second hand-rolled `ToString` format was introduced in the test file either. + +## Verification + +- **Verified red without the fix.** Reverted only the `ResolveAuto` expression (via + `Edit`, not `git checkout`) back to + `"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))`, leaving the new + tests and the threaded signatures in place. Ran the two new tests: + ``` + Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock [FAIL] + Assert.Equal() Failure: Strings differ + Expected: "14 maart 2019" + Actual: "27 augustus 2026" + + Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at [FAIL] + Assert.Equal() Failure: Strings differ + Expected: "14 maart 2019" + Actual: "27 augustus 2026" + ``` + Both failures show the body rendering the run's actual wall-clock date (today, + 2026-08-27) instead of the pinned historical `at` — the precise defect TE-007 + describes. Restored the fix with a second `Edit` and re-ran: all 4 tests in + `LetterHtmlTests` green (2 pre-existing + 2 new). +- `grep -n "UtcNow\|DateTime.Now\|DateTime.Today\|DateTimeOffset.Now" +backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` — no matches. No ambient + clock read remains anywhere in the file. +- `npm run ci` (foreground, no background/Monitor): see result reported alongside this + ticket. + +## What this ticket did not touch + +`LetterHtml.cs`'s overall structure and CC (TE-007 records it at 21, the third-highest +in the backend) are unchanged — reducing that is out of scope for this ticket, per its +own text. `Data/BriefStore.cs` and any `BriefRules.cs` file were not touched — a +concurrent ticket owns that file. From 95bb77395eaa1d5ab59844c2f9665ee625c507c0 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 08:28:30 +0200 Subject: [PATCH 51/61] refactor(shared): move the accept/reject decision into planFileSelection (RB-26) createUploadController required inject(), an effect(), and a window listener before a test could reach it. The file-selection policy trapped behind that cost now lives in a pure function, planFileSelection, in upload.machine.ts. planFileSelection takes plain { name, type, size } objects, not File, and decides per file whether to reject it or accept it, with no I/O. The controller executes the plan: it dispatches a rejection as-is, and starts the upload for an accepted file (the one step that needs crypto.randomUUID()). A new spec covers the three outcomes: the 'multiple' batch rejection, a rejectReason-based rejection, and the accept case, plus order in a mixed batch. Verified red-then-green with a temporary stub, undone by a second edit. No change to the controller's public surface or to the calling organism. previewUrlFor (added by RB-24) is untouched. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++++------ .../refactor-backlog/implementation/rb-26.md | 109 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 12 +- .../src/application/upload-controller.ts | 23 ++-- libs/shared/src/domain/upload.machine.spec.ts | 81 +++++++++++++ libs/shared/src/domain/upload.machine.ts | 37 ++++++ 6 files changed, 284 insertions(+), 48 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index d55b046..065c69f 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **implemented** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md new file mode 100644 index 0000000..a1653dc --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md @@ -0,0 +1,109 @@ +# RB-26 — move the accept/reject decision into `planFileSelection` (`upload.machine.ts`) + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-004 · +`99-backlog.md` RB-26 · Depends on `implementation/rb-24.md` (moved the upload files into +`infrastructure`/`domain`/`application`) + +## What was wrong + +TE-004: `createUploadController` does three `inject()` calls, registers an `effect()`, and +adds a `window` focus listener, all before it returns. A spec must run inside a `TestBed` +injection context with `UploadAdapter`, `UploadShellService`, and `DestroyRef` all +satisfied to reach anything inside it. What sits behind that cost is real policy: +`onFileSelected` decides, per file, whether to reject it with reason `'multiple'`, reject +it with a `rejectReason` result, or start its upload — a decision over +`(categories, categoryId, files)` with no I/O in it. `rejectReason`, the predicate that +decision calls, was already exported and spec'd; the decision that calls it was not. + +## What changed + +`libs/shared/src/domain/upload.machine.ts` gains one pure export: + +```ts +export function planFileSelection( + state: UploadState, + categoryId: string, + files: { name: string; type: string; size: number }[], +): UploadMsg[]; +``` + +It takes plain `{ name, type, size }` objects, not `File` — a spec needs no DOM. The body +is the old `onFileSelected` decision, moved: an unknown category plans nothing; too many +files for a single-file category plans one `FileRejected` with reason `'multiple'` and +skips the per-file checks; otherwise each file is judged by `rejectReason` and plans +either a `FileRejected` or a `FileSelected` entry, one entry per input file, in order. + +`libs/shared/src/application/upload-controller.ts`'s `onFileSelected` now maps `selected: +File[]` to plain candidates, calls `planFileSelection`, and executes the result: a +`FileRejected` entry dispatches as-is; anything else starts the upload for the file at +that same array index (`crypto.randomUUID()`, `files.set()`, `shell.upload()` — the three +things that must stay impure and stay in the controller). No other method changed. +`previewUrlFor` (added by RB-24) is untouched. + +## The `localId` placeholder — a deliberate, contained choice + +An accepted file's planned `FileSelected` entry carries `localId: ''`. A real id needs +`crypto.randomUUID()`, and the ticket is explicit that call stays in the controller, not +the domain. The controller reads only each entry's `.type` to route it — it dispatches a +`FileRejected` entry verbatim, but for a `FileSelected` entry it discards the entry and +calls `start(categoryId, selected[i])`, which builds its own message with a real id. +The placeholder is therefore never dispatched. This was the only way found to keep the +return type exactly `UploadMsg[]` (as the ticket's own code sketch specifies) while still +letting the plan carry a per-file, order-preserving "start this one" signal — the +`FileRejected` variant carries no file identity (state keys rejections by category only), +so position in the returned array is what the controller uses to find the matching +original `File`. A discriminated `{ kind: 'reject' | 'start'; msg? }` return would avoid +the placeholder but was not built, since the ticket's signature is explicit and the +placeholder design meets it without changing behaviour. + +## Behaviour + +Same messages, same order, for the same inputs. Tracing all three original branches: + +- Unknown category: original returns without dispatching; new code calls `planFileSelection` + (returns `[]`), then `forEach` over an empty array — no dispatch, no start. +- Too many files for a single-file category: original dispatches one `FileRejected` + ('multiple') and returns; new code gets a one-entry plan and dispatches that one entry — + `forEach` never reaches indices past the plan's length, so no file starts. +- Per-file loop: original dispatches `FileRejected` or calls `start` for each file, in + order; new code's plan has one entry per file, in the same order, and the controller + dispatches or starts at each index identically. + +## Testing + +`libs/shared/src/domain/upload.machine.spec.ts` gained a `planFileSelection` describe +block: unknown category (plans nothing), the `'multiple'` batch rejection, a passing +single file against a single-file category, `rejectReason`'s two reject cases (`'type'`, +`'size'`) reached through the plan, the accept case's exact `FileSelected` shape +(including the `localId: ''` placeholder), and a mixed multiple-file case asserting +order (`['FileSelected', 'FileRejected', 'FileSelected']`). + +**Proved red before green**, per the ticket's instruction not to use `git checkout`: +temporarily replaced the function body with a stub returning `[]` unconditionally (an +edit, not a revert), ran `ng test shared`, and got: + +``` +Test Files 1 failed | 23 passed (24) + Tests 6 failed | 139 passed (145) +``` + +The 6 failures were the `'multiple'` rejection, both `rejectReason` cases, the accepted- +file shape, and the mixed-order case — every outcome that depends on the real branching, +each failing with `expected [] to deeply equal [...]`. The unknown-category case passed +even against the stub, since both the stub and the real implementation return `[]` there +— expected, not a gap, since that branch has no policy to exercise. A second edit restored +the real body; the same run returned to `24 passed / 145 passed`. + +## Scope held + +No change to `createUploadController`'s construction, the `effect()`, or the `window` +listener — those are RB-25/RB-27's targets (RB-25 is `UploadShellService`, running +concurrently in the same commit window; RB-27 is `upload.adapter.ts`'s XHR closure). +Neither file was touched. The controller's public surface (`previewUrlFor`, +`onFileSelected`, `onRemove`, `onRetry`, `onDelete`, `onChannelChange`) is unchanged in +name and signature, and the organism that calls it (``) needed no +change. + +## `npm run ci` + +Result and step count reported in the closing message. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 581ab7d..2d75c5d 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 467 frontend behaviours across +**is** the suite, reshaped for a business reader. 474 frontend behaviours across 9 contexts; 238 backend behaviours across 41 test classes. @@ -798,6 +798,16 @@ classes. - maskTail keeps the requested tail length - masks the whole value when it is not longer than the kept tail +#### planFileSelection + +- plans nothing for an unknown category +- rejects the whole batch with reason "multiple" for a single-file category +- does not reject a single file against a single-file category +- rejects one file via rejectReason (wrong type) +- rejects one file via rejectReason (too large) +- plans a FileSelected entry for a file that passes format validation +- judges each file independently and preserves order for a mixed multiple-file category + #### problemDetail - extracts the detail from an RFC-7807 ProblemDetails diff --git a/libs/shared/src/application/upload-controller.ts b/libs/shared/src/application/upload-controller.ts index 7fa1522..4e04579 100644 --- a/libs/shared/src/application/upload-controller.ts +++ b/libs/shared/src/application/upload-controller.ts @@ -12,7 +12,7 @@ import { UploadMsg, UploadState, inFlight, - rejectReason, + planFileSelection, } from '@shared/domain/upload.machine'; export interface UploadControllerDeps { @@ -75,17 +75,16 @@ export function createUploadController(deps: UploadControllerDeps) { return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId); }, onFileSelected(categoryId: string, selected: File[]) { - const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId); - if (!cat) return; - if (!cat.multiple && selected.length > 1) { - deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' }); - return; - } - for (const file of selected) { - const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); - if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason }); - else start(categoryId, file); - } + // The accept/reject decision lives in upload.machine.ts (planFileSelection), + // so it's testable without a DOM File. Each plan entry lines up by index with + // `selected`: a rejection dispatches as-is; anything else means "start", so the + // controller runs the one impure step the plan can't (crypto.randomUUID()). + const candidates = selected.map((f) => ({ name: f.name, type: f.type, size: f.size })); + const plan = planFileSelection(deps.getUpload(), categoryId, candidates); + plan.forEach((msg, i) => { + if (msg.type === 'FileRejected') deps.dispatch(msg); + else start(categoryId, selected[i]); + }); }, onRemove(localId: string) { shell.cancel([localId]); diff --git a/libs/shared/src/domain/upload.machine.spec.ts b/libs/shared/src/domain/upload.machine.spec.ts index 25542ba..c81f811 100644 --- a/libs/shared/src/domain/upload.machine.spec.ts +++ b/libs/shared/src/domain/upload.machine.spec.ts @@ -10,6 +10,7 @@ import { deliveryRefs, inFlight, rejectReason, + planFileSelection, } from './upload.machine'; const cat = (over: Partial = {}): DocumentCategory => ({ @@ -309,6 +310,86 @@ describe('rejectReason', () => { }); }); +describe('planFileSelection', () => { + const file = (over: Partial<{ name: string; type: string; size: number }> = {}) => ({ + name: 'diploma.pdf', + type: 'application/pdf', + size: 1_000_000, + ...over, + }); + + it('plans nothing for an unknown category', () => { + const s = stateWith([cat({ categoryId: 'diploma' })]); + expect(planFileSelection(s, 'unknown', [file()])).toEqual([]); + }); + + it('rejects the whole batch with reason "multiple" for a single-file category', () => { + const s = stateWith([cat({ categoryId: 'diploma', multiple: false })]); + const plan = planFileSelection(s, 'diploma', [file(), file({ name: 'second.pdf' })]); + expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'multiple' }]); + }); + + it('does not reject a single file against a single-file category', () => { + const s = stateWith([cat({ categoryId: 'diploma', multiple: false })]); + const plan = planFileSelection(s, 'diploma', [file()]); + expect(plan).toHaveLength(1); + expect(plan[0].type).toBe('FileSelected'); + }); + + it('rejects one file via rejectReason (wrong type)', () => { + const s = stateWith([ + cat({ categoryId: 'diploma', multiple: true, acceptedTypes: ['application/pdf'] }), + ]); + const plan = planFileSelection(s, 'diploma', [file({ type: 'image/png' })]); + expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'type' }]); + }); + + it('rejects one file via rejectReason (too large)', () => { + const s = stateWith([cat({ categoryId: 'diploma', multiple: true, maxSizeMb: 1 })]); + const plan = planFileSelection(s, 'diploma', [file({ size: 2_000_000 })]); + expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'size' }]); + }); + + it('plans a FileSelected entry for a file that passes format validation', () => { + const s = stateWith([ + cat({ + categoryId: 'diploma', + multiple: true, + acceptedTypes: ['application/pdf'], + maxSizeMb: 10, + }), + ]); + const plan = planFileSelection(s, 'diploma', [file()]); + expect(plan).toEqual([ + { + type: 'FileSelected', + categoryId: 'diploma', + localId: '', + fileName: 'diploma.pdf', + fileSizeMb: 1, + }, + ]); + }); + + it('judges each file independently and preserves order for a mixed multiple-file category', () => { + const s = stateWith([ + cat({ + categoryId: 'diploma', + multiple: true, + acceptedTypes: ['application/pdf'], + maxSizeMb: 10, + }), + ]); + const plan = planFileSelection(s, 'diploma', [ + file({ name: 'a.pdf' }), + file({ name: 'b.png', type: 'image/png' }), + file({ name: 'c.pdf' }), + ]); + expect(plan.map((m) => m.type)).toEqual(['FileSelected', 'FileRejected', 'FileSelected']); + expect((plan[1] as { reason: string }).reason).toBe('type'); + }); +}); + describe('inFlight', () => { it('returns only queued/uploading uploads', () => { let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1'); diff --git a/libs/shared/src/domain/upload.machine.ts b/libs/shared/src/domain/upload.machine.ts index 1efe0f4..90d2898 100644 --- a/libs/shared/src/domain/upload.machine.ts +++ b/libs/shared/src/domain/upload.machine.ts @@ -119,6 +119,43 @@ export function rejectReason( return null; } +/** + * The file-selection policy behind ``: for each candidate file, + * decide whether to reject it or accept it, with no I/O. An unknown category plans + * nothing (mirrors the controller's own "unknown category" guard). Choosing more than + * one file for a single-file category rejects the whole batch with reason `'multiple'` + * and skips the per-file checks; otherwise each file is judged by `rejectReason`. + * + * An accepted file plans a `'FileSelected'` entry with a placeholder `localId: ''` — + * a real id needs `crypto.randomUUID()`, an impure call that stays in + * `createUploadController`. The controller reads only each entry's `type` (never its + * fields) to decide, at that same array index, whether to dispatch the rejection as-is + * or start the upload for the original file — so the placeholder is never dispatched. + */ +export function planFileSelection( + state: UploadState, + categoryId: string, + files: { name: string; type: string; size: number }[], +): UploadMsg[] { + const cat = state.categories.find((c) => c.categoryId === categoryId); + if (!cat) return []; + if (!cat.multiple && files.length > 1) { + return [{ type: 'FileRejected', categoryId, reason: 'multiple' }]; + } + return files.map((file): UploadMsg => { + const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); + return reason + ? { type: 'FileRejected', categoryId, reason } + : { + type: 'FileSelected', + categoryId, + localId: '', + fileName: file.name, + fileSizeMb: file.size / 1e6, + }; + }); +} + /** Map one upload's status, leaving the rest of the list untouched. */ function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState { return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) }; From adad4513d0cc9caa9d29b01d7c7ab491ebae543d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 08:31:34 +0200 Subject: [PATCH 52/61] refactor(shared): add UPLOAD_TRANSPORT injection token (RB-25) UploadShellService injected the concrete KeepaliveTransport class instead of a token. The class was not exported, so a spec could not fake it, and could not provide against the UploadTransport interface either, since an interface is not a DI token. The port existed only on paper. Add UPLOAD_TRANSPORT, an InjectionToken with a default factory that resolves the same KeepaliveTransport singleton, following the SessionPort/SESSION_PORT shape. UploadShellService now injects the token. Runtime behaviour is unchanged. Add upload-shell.service.spec.ts: a recording fake transport plus a fake UploadAdapter exercise upload(), delete(), cancel() and pollReturning(), the four methods the missing seam left unreachable. Coverage for upload-shell.service.ts goes from 0% to 88.6% line / 85% branch. Mark RB-25 done in 99-backlog.md and add its implementation note. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 2 +- .../refactor-backlog/implementation/rb-25.md | 130 ++++++++++ libs/shared/docs/behaviour-spec.mdx | 27 +- .../application/upload-shell.service.spec.ts | 230 ++++++++++++++++++ .../src/application/upload-shell.service.ts | 16 +- 5 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md create mode 100644 libs/shared/src/application/upload-shell.service.spec.ts diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index d55b046..3b23611 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -126,7 +126,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | | **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | | **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | | **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | | **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | | **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md new file mode 100644 index 0000000..bc4949e --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md @@ -0,0 +1,130 @@ +# RB-25 — `UPLOAD_TRANSPORT` injection token replaces `inject(KeepaliveTransport)` + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-003 · +`99-backlog.md` RB-25, "Merges" table row for RB-25/26/27 · Depends on +`implementation/rb-24.md` (the move that put this file at its current path) + +## What was wrong + +`libs/shared/src/application/upload-shell.service.ts` defines `export interface +UploadTransport` and documents it as the swap seam for upload transport. It then binds +`private transport: UploadTransport = inject(KeepaliveTransport)` — the concrete class, +which is `@Injectable` but not exported. A spec cannot reference the class to override its +provider, and cannot provide against the interface either, because an interface is not a +DI token. The port existed on paper only. + +The consequence TE-003 measures: `upload()`, `delete()`, `cancel()`, and `pollReturning()` +— the methods that translate transport and adapter outcomes into `UploadMsg`s — had no +spec at all. `libs/shared/upload` sat at 52.0% line / 50.0% branch, and +`upload-shell.service.ts` was one of the two unreached non-`ui/` files. + +## What changed + +One file plus one new spec, exactly as scoped: + +- `libs/shared/src/application/upload-shell.service.ts`: added + + ```ts + export const UPLOAD_TRANSPORT = new InjectionToken('UPLOAD_TRANSPORT', { + providedIn: 'root', + factory: () => inject(KeepaliveTransport), + }); + ``` + + copied verbatim from TE-003's own fix, placed directly under the `KeepaliveTransport` + class it wraps. `UploadShellService.transport` now reads + `inject(UPLOAD_TRANSPORT)` instead of `inject(KeepaliveTransport)`. This is the same + interface-plus-token shape as `SessionPort`/`SESSION_PORT` + (`libs/shared/src/application/session.port.ts`), the repo's one other explicit port. + `KeepaliveTransport` itself is untouched: still a private, unexported `@Injectable`, and + still the default factory's target — a real app gets the exact same singleton instance + it always did. + +- `libs/shared/src/application/upload-shell.service.spec.ts` (new): a recording fake + `UploadTransport` (records every `send()` call, exposes `resolveDone`/`rejectDone` per + call so a test drives the returned `Promise` by hand) provided against `UPLOAD_TRANSPORT`, + plus a fake `UploadAdapter` (a plain object with `vi.fn()` for `status`/`deleteDocument`) + provided against the already-exported `UploadAdapter` class. 16 specs across all four + target methods: + - `upload()` — `UploadQueued` carries the transport's `backgroundSyncAvailable`; + `onProgress` → `UploadProgress`; a resolved transport → `UploadComplete`; a rejected + transport → `UploadFailed` with the rejection reason; a rejection with the + `UPLOAD_ABORTED` sentinel dispatches nothing. + - `cancel()` — calls the stored cancel function for an in-flight upload and forgets it + (a second `cancel()` on the same id is a no-op); an unknown id is a no-op. + - `delete()` — `UploadDeleting` then `UploadDeleteComplete` on success; + `UploadDeleteFailed` with the server's `detail` on a ProblemDetails rejection; falls + back to an empty reason when the rejection carries no `detail`. + - `pollReturning()` — skips the adapter call entirely for an empty upload list; + dispatches `BackgroundUploadsReturned` filtered to only the items the server reports + `complete` with a `documentId`; dispatches nothing when nothing has arrived. + +No other file changed. `UploadAdapter`, `upload.machine.ts`, and `upload-controller.ts` +are untouched, per the ticket's file-scope fence (RB-26 and RB-28 are concurrently in +adjacent files). + +## Verification + +- **Coverage, `upload-shell.service.ts`** (`npm run test:coverage` narrowed to `shared`, + read from `coverage/shared/lcov.info`): + + | Metric | Before | After | + | --------- | ------ | -------------- | + | Lines | 0% | 88.57% (31/35) | + | Branches | 0% | 85.00% (17/20) | + | Functions | 0% | 87.50% (14/16) | + + "Before" is 0% across the board: no spec file for this service existed prior to this + ticket (confirmed by `grep -rln UploadShellService --include=*.spec.ts`, which returns + only the new spec), matching TE-003's "unreached" classification. The remaining + uncovered lines are the `KeepaliveTransport` class body (`send()`, its `inject`) and the + `UPLOAD_TRANSPORT` factory closure itself — both require a real `XMLHttpRequest`/real DI + resolution to exercise and are intentionally out of this ticket's scope: TE-003's fix is + the seam, not a rewrite of the transport it wraps. + +- **Red-proof.** Edited `upload()`'s success branch from + `dispatch({ type: 'UploadComplete', localId: req.localId, documentId })` to + `dispatch({ type: 'UploadFailed', localId: req.localId, reason: 'BROKEN-FOR-RED-PROOF' })`, + ran `ng test shared`. Result: 1 failed / 150 passed, with + + ``` + AssertionError: expected "vi.fn()" to be called with arguments: [ { type: 'UploadComplete', …(2) } ] + Received: + 1st vi.fn() call: [{ "backgroundSync": false, "localId": "l1", "type": "UploadQueued" }] + 2nd vi.fn() call: [{ "localId": "l1", "reason": "BROKEN-FOR-RED-PROOF", "type": "UploadFailed" }] + ``` + + at `upload-shell.service.spec.ts:79` (the `UploadComplete` assertion). Re-applied the + original line with a second edit (not `git checkout`); `git diff` against HEAD shows + only the intended token change — the red edit left no trace. Re-ran: 151/151 green. + +- `npm run ci`: result and step count in the final answer. + +## Judgement call + +- **The fake `UploadAdapter` is a plain object, not a class extending `UploadAdapter`.** + `UploadAdapter` is exported and already usable as a DI token (it always was — TE-003's + gap was specific to `KeepaliveTransport`, not `UploadAdapter`), so `delete()` and + `pollReturning()` (which never touch `this.transport`) were technically fakeable before + this ticket by providing a fake `UploadAdapter`. Nobody had written that spec, though, + and `upload()`/`cancel()` still needed `UPLOAD_TRANSPORT` regardless (they populate and + drain the `inflight` map via `transport.send()`). The spec fakes both seams together so + all four methods are exercised as one coherent suite, per the ticket's own framing + ("provide a recording fake transport and assert the message translation in `upload()`, + `delete()`, `cancel()` and `pollReturning()`"). + +## Handoff to RB-27 + +RB-27 extracts `uploadOutcome(status, responseText)` out of the XHR closure in +`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` — a different file, +untouched by this ticket. The token makes RB-27's optional half (moving the +`currentScenario()` branch into `KeepaliveTransport.send()`) no easier and no harder than +before: `KeepaliveTransport` is still unexported and its `send()` body is unchanged, one +line (`inject(UploadAdapter)`, `return this.adapter.xhrUpload(req, onProgress)`). If RB-27 +takes that optional move, it can inject `UPLOAD_TRANSPORT` in its own spec to assert the +scenario branch without touching this file — the seam is there and provided-in-root, but +RB-27 does not need to change anything here to use it. + +## `npm run ci` + +Result and step count reported in the final answer. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 581ab7d..43186f1 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 467 frontend behaviours across +**is** the suite, reshaped for a business reader. 480 frontend behaviours across 9 contexts; 238 backend behaviours across 41 test classes. @@ -675,6 +675,31 @@ classes. - map only touches Success - map2 precedence: Failure > Loading > Success +#### UploadShellService.cancel + +- calls the transport cancel function for an in-flight upload and forgets it +- is a no-op for a localId with nothing in flight + +#### UploadShellService.delete + +- dispatches UploadDeleting, then UploadDeleteComplete on success +- dispatches UploadDeleteFailed with the server detail on failure +- falls back to an empty reason when the server sends no detail + +#### UploadShellService.pollReturning + +- does nothing when there are no uploads to poll +- dispatches BackgroundUploadsReturned for uploads the server reports complete +- does not dispatch when nothing has arrived yet + +#### UploadShellService.upload + +- dispatches UploadQueued with the transport backgroundSync flag, then sends via the transport +- translates a progress callback into UploadProgress +- translates a resolved transport into UploadComplete +- translates a rejected transport into UploadFailed with the reason +- does not dispatch UploadFailed on a user-initiated abort + #### authGuard - allows an authenticated user diff --git a/libs/shared/src/application/upload-shell.service.spec.ts b/libs/shared/src/application/upload-shell.service.spec.ts new file mode 100644 index 0000000..4a56652 --- /dev/null +++ b/libs/shared/src/application/upload-shell.service.spec.ts @@ -0,0 +1,230 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, vi } from 'vitest'; +import { + UploadAdapter, + UPLOAD_ABORTED, + XhrUploadHandle, +} from '@shared/infrastructure/upload.adapter'; +import { UploadMsg } from '@shared/domain/upload.machine'; +import { UploadShellService, UploadTransport, UPLOAD_TRANSPORT } from './upload-shell.service'; + +/** Records every `send()` call and lets a test resolve/reject/report progress by hand. */ +function fakeTransport(backgroundSyncAvailable = false) { + const handles: Array<{ + req: Parameters[0]; + onProgress: (pct: number) => void; + resolveDone: (v: { documentId: string }) => void; + rejectDone: (e: unknown) => void; + cancel: ReturnType; + }> = []; + const transport: UploadTransport = { + backgroundSyncAvailable, + send: vi.fn((req, onProgress) => { + let resolveDone!: (v: { documentId: string }) => void; + let rejectDone!: (e: unknown) => void; + const done = new Promise<{ documentId: string }>((res, rej) => { + resolveDone = res; + rejectDone = rej; + }); + const cancel = vi.fn(); + handles.push({ req, onProgress, resolveDone, rejectDone, cancel }); + const handle: XhrUploadHandle = { done, cancel }; + return handle; + }), + }; + return { transport, handles }; +} + +function setup(opts: { backgroundSync?: boolean; adapter?: Partial } = {}) { + const { transport, handles } = fakeTransport(opts.backgroundSync ?? false); + const adapter: Partial = { + status: vi.fn().mockResolvedValue([]), + deleteDocument: vi.fn().mockResolvedValue(undefined), + ...opts.adapter, + }; + TestBed.configureTestingModule({ + providers: [ + { provide: UPLOAD_TRANSPORT, useValue: transport }, + { provide: UploadAdapter, useValue: adapter }, + ], + }); + const service = TestBed.inject(UploadShellService); + const dispatch = vi.fn<(m: UploadMsg) => void>(); + return { service, dispatch, transport, handles, adapter }; +} + +const req = { localId: 'l1', categoryId: 'c1', wizardId: 'w1', file: new File(['x'], 'x.pdf') }; + +describe('UploadShellService.upload', () => { + it('dispatches UploadQueued with the transport backgroundSync flag, then sends via the transport', () => { + const { service, dispatch, transport } = setup({ backgroundSync: true }); + service.upload(req, dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadQueued', + localId: 'l1', + backgroundSync: true, + }); + expect(transport.send).toHaveBeenCalledOnce(); + }); + + it('translates a progress callback into UploadProgress', () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].onProgress(42); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadProgress', + localId: 'l1', + progressPct: 42, + }); + }); + + it('translates a resolved transport into UploadComplete', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].resolveDone({ documentId: 'doc-1' }); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadComplete', + localId: 'l1', + documentId: 'doc-1', + }); + }); + + it('translates a rejected transport into UploadFailed with the reason', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].rejectDone('network down'); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadFailed', + localId: 'l1', + reason: 'network down', + }); + }); + + it('does not dispatch UploadFailed on a user-initiated abort', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].rejectDone(UPLOAD_ABORTED); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'UploadFailed' })); + }); +}); + +describe('UploadShellService.cancel', () => { + it('calls the transport cancel function for an in-flight upload and forgets it', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + service.cancel(['l1']); + expect(handles[0].cancel).toHaveBeenCalledOnce(); + // Cancelling twice is a no-op the second time — the entry is already forgotten. + service.cancel(['l1']); + expect(handles[0].cancel).toHaveBeenCalledOnce(); + }); + + it('is a no-op for a localId with nothing in flight', () => { + const { service } = setup(); + expect(() => service.cancel(['unknown'])).not.toThrow(); + }); +}); + +describe('UploadShellService.delete', () => { + it('dispatches UploadDeleting, then UploadDeleteComplete on success', async () => { + const { service, dispatch } = setup(); + service.delete('l1', 'doc-1', dispatch); + expect(dispatch).toHaveBeenCalledWith({ type: 'UploadDeleting', localId: 'l1' }); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ type: 'UploadDeleteComplete', localId: 'l1' }); + }); + + it('dispatches UploadDeleteFailed with the server detail on failure', async () => { + const { service, dispatch } = setup({ + adapter: { deleteDocument: vi.fn().mockRejectedValue({ detail: 'Document is gekoppeld.' }) }, + }); + service.delete('l1', 'doc-1', dispatch); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadDeleteFailed', + localId: 'l1', + reason: 'Document is gekoppeld.', + }); + }); + + it('falls back to an empty reason when the server sends no detail', async () => { + const { service, dispatch } = setup({ + adapter: { deleteDocument: vi.fn().mockRejectedValue(new Error('boom')) }, + }); + service.delete('l1', 'doc-1', dispatch); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadDeleteFailed', + localId: 'l1', + reason: '', + }); + }); +}); + +describe('UploadShellService.pollReturning', () => { + it('does nothing when there are no uploads to poll', async () => { + const status = vi.fn().mockResolvedValue([]); + const { service, dispatch } = setup({ adapter: { status } }); + await service.pollReturning([], dispatch); + expect(status).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches BackgroundUploadsReturned for uploads the server reports complete', async () => { + const status = vi.fn().mockResolvedValue([ + { localId: 'l1', status: 'complete', documentId: 'doc-1' }, + { localId: 'l2', status: 'unknown' }, + ]); + const { service, dispatch } = setup({ adapter: { status } }); + const uploads = [ + { + localId: 'l1', + categoryId: 'c1', + fileName: 'a.pdf', + fileSizeMb: 1, + status: { type: 'queued' as const }, + backgroundSync: true, + }, + { + localId: 'l2', + categoryId: 'c1', + fileName: 'b.pdf', + fileSizeMb: 1, + status: { type: 'queued' as const }, + backgroundSync: true, + }, + ]; + await service.pollReturning(uploads, dispatch); + expect(status).toHaveBeenCalledWith(['l1', 'l2']); + expect(dispatch).toHaveBeenCalledWith({ + type: 'BackgroundUploadsReturned', + results: [{ localId: 'l1', success: true, documentId: 'doc-1' }], + }); + }); + + it('does not dispatch when nothing has arrived yet', async () => { + const status = vi.fn().mockResolvedValue([{ localId: 'l1', status: 'unknown' }]); + const { service, dispatch } = setup({ adapter: { status } }); + const uploads = [ + { + localId: 'l1', + categoryId: 'c1', + fileName: 'a.pdf', + fileSizeMb: 1, + status: { type: 'queued' as const }, + backgroundSync: true, + }, + ]; + await service.pollReturning(uploads, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/src/application/upload-shell.service.ts b/libs/shared/src/application/upload-shell.service.ts index bab1e67..6014cbc 100644 --- a/libs/shared/src/application/upload-shell.service.ts +++ b/libs/shared/src/application/upload-shell.service.ts @@ -1,4 +1,4 @@ -import { Injectable, inject } from '@angular/core'; +import { Injectable, InjectionToken, inject } from '@angular/core'; import { UploadAdapter, XhrUploadRequest, @@ -28,6 +28,18 @@ class KeepaliveTransport implements UploadTransport { } } +/** + * The swap seam (see UploadTransport above), made real: a spec provides a fake + * transport against this token instead of the concrete class. This copies the + * `SessionPort` / `SESSION_PORT` shape (session.port.ts), the repo's one other + * explicit port. The default factory returns the same KeepaliveTransport + * instance the class-injection used to, so runtime behaviour is unchanged. + */ +export const UPLOAD_TRANSPORT = new InjectionToken('UPLOAD_TRANSPORT', { + providedIn: 'root', + factory: () => inject(KeepaliveTransport), +}); + type Dispatch = (m: UploadMsg) => void; /** @@ -37,7 +49,7 @@ type Dispatch = (m: UploadMsg) => void; */ @Injectable({ providedIn: 'root' }) export class UploadShellService { - private transport: UploadTransport = inject(KeepaliveTransport); + private transport: UploadTransport = inject(UPLOAD_TRANSPORT); private adapter = inject(UploadAdapter); private inflight = new Map void>(); // localId → cancel From ce952941bb171920eb6fa40d0dc1958777aff549 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 08:38:14 +0200 Subject: [PATCH 53/61] refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28) Three application-layer commands ended in raw DOM calls (URL.createObjectURL, window.open, document.createElement('a').click(), URL.revokeObjectURL) as their last statement. jsdom cannot assert a call that is also the end of the function, so each command's success path stayed unassertable, and StamdataStore.download()'s two-clause guard stayed permanently dark on its true branch (TE-006). Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an InjectionToken mirroring SESSION_PORT's shape: an interface with open()/ download(), a real implementation preserving the existing open()-never- revokes vs download()-always-revokes asymmetry, provided in root. Route StamdataStore.download(), BriefStore.previewLetter(), and OrgTemplateStore.proefbrief() through it. Add specs with a recording fake presenter: StamdataStore.download()'s guard (both clauses) and its success path, asserting toJson(...)'s exact output reaches the file; BriefStore.previewLetter()'s existing success test now goes through the seam instead of spying on window/URL directly; a new org-template.store.spec.ts (none existed before) covers proefbrief()'s success and failure paths. Verified red without the fix by editing the download() filename to the wrong extension, watching the success-path spec fail, then restoring it. Co-Authored-By: Claude Opus 5 --- .../app/brief/application/brief.store.spec.ts | 58 ++++-- .../src/app/brief/application/brief.store.ts | 8 +- .../application/org-template.store.spec.ts | 120 ++++++++++++ .../brief/application/org-template.store.ts | 4 +- .../refactor-backlog/99-backlog.md | 70 +++---- .../refactor-backlog/implementation/rb-28.md | 180 ++++++++++++++++++ .../src/application/stamdata.store.spec.ts | 78 +++++++- libs/beheer/src/application/stamdata.store.ts | 9 +- libs/shared/docs/behaviour-spec.mdx | 15 +- libs/shared/src/application/blob-presenter.ts | 37 ++++ 10 files changed, 511 insertions(+), 68 deletions(-) create mode 100644 apps/ssp/src/app/brief/application/org-template.store.spec.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md create mode 100644 libs/shared/src/application/blob-presenter.ts diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts index b96941a..2a493f8 100644 --- a/apps/ssp/src/app/brief/application/brief.store.spec.ts +++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { Result } from '@shared/kernel/fp'; +import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief'; import { OrgTemplate } from '@brief/domain/org-template'; import { @@ -53,8 +54,26 @@ const caseContext: CaseContext = { const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; -function setup(adapter: Partial): BriefStore { - TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); +/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of + touching the DOM, so a spec can assert a command's success path directly. */ +function fakeBlobPresenter() { + const opened: Blob[] = []; + const presenter: BlobPresenter = { + open: (blob) => opened.push(blob), + download: () => { + throw new Error('not used by BriefStore'); + }, + }; + return { presenter, opened }; +} + +function setup(adapter: Partial, blobPresenter?: BlobPresenter): BriefStore { + TestBed.configureTestingModule({ + providers: [ + { provide: BriefAdapter, useValue: adapter }, + ...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []), + ], + }); return TestBed.inject(BriefStore); } @@ -287,43 +306,46 @@ describe('BriefStore rejection diff', () => { }); describe('BriefStore.previewLetter', () => { - // vi.spyOn reuses an existing spy (and its call history) if one is already on - // the property — window.open/URL.createObjectURL must be restored between tests. afterEach(() => vi.restoreAllMocks()); - it('opens the composed letter in a new tab on success', async () => { - const store = setup({ - load: (): Promise> => - Promise.resolve({ ok: true, value: view }), - }); + it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { + const { presenter, opened } = fakeBlobPresenter(); + const store = setup( + { + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), + }, + presenter, + ); await store.load(); const blob = new Blob([''], { type: 'text/html' }); - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); - const open = vi.spyOn(window, 'open').mockImplementation(() => null); vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ ok: true, value: blob, }); await store.previewLetter(); - expect(open).toHaveBeenCalledWith('blob:mock', '_blank'); + expect(opened).toEqual([blob]); expect(store.lastError()).toBeNull(); }); it('surfaces the error without opening a tab on failure', async () => { - const store = setup({ - load: (): Promise> => - Promise.resolve({ ok: true, value: view }), - }); + const { presenter, opened } = fakeBlobPresenter(); + const store = setup( + { + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), + }, + presenter, + ); await store.load(); - const open = vi.spyOn(window, 'open').mockImplementation(() => null); vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ ok: false, error: PREVIEW_FAILED, }); await store.previewLetter(); - expect(open).not.toHaveBeenCalled(); + expect(opened).toHaveLength(0); expect(store.lastError()).toBe(PREVIEW_FAILED); }); }); diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 623535a..80ab564 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -21,6 +21,7 @@ import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapt import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; +import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; /** * Root singleton for the letter: the Elm store (Model + dispatch), the derived @@ -35,6 +36,7 @@ export class BriefStore implements PendingSave { private adapter = inject(BriefAdapter); private previewAdapter = inject(LetterPreviewAdapter); private revealAdapter = inject(RevealBigNummerAdapter); + private blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -244,8 +246,8 @@ export class BriefStore implements PendingSave { send = () => this.transition(() => this.adapter.send()); /** Explicit action, never a live re-render (PRD §8): opens the server-composed - letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and - the tab outlives this call; not worth a teardown hook for a POC. */ + letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the + object URL is never revoked. */ async previewLetter() { this.actionState.set({ tag: 'Busy' }); const r = await this.previewAdapter.preview(); @@ -254,7 +256,7 @@ export class BriefStore implements PendingSave { return; } this.actionState.set({ tag: 'Idle' }); - window.open(URL.createObjectURL(r.value), '_blank'); + this.blobPresenter.open(r.value); } /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability diff --git a/apps/ssp/src/app/brief/application/org-template.store.spec.ts b/apps/ssp/src/app/brief/application/org-template.store.spec.ts new file mode 100644 index 0000000..f5decef --- /dev/null +++ b/apps/ssp/src/app/brief/application/org-template.store.spec.ts @@ -0,0 +1,120 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect } from 'vitest'; +import { Result, ok } from '@shared/kernel/fp'; +import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; +import { UploadAdapter } from '@shared/infrastructure/upload.adapter'; +import { UploadShellService } from '@shared/application/upload-shell.service'; +import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template'; +import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; +import { OrgTemplateStore } from './org-template.store'; + +const template: OrgTemplate = { + subOrgId: 'cibg-registers', + orgName: 'CIBG — Registers', + returnAddress: 'Postbus 00000\n2500 AA Den Haag', + footerContact: 'info@voorbeeld.example', + footerLegal: 'KvK 00000000', + signatureName: 'A. de Vries', + signatureRole: 'Hoofd Registratie', + signatureClosing: 'Met vriendelijke groet,', + margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 }, + version: 1, +}; + +const view: OrgTemplateAdminView = { + draft: template, + publishedVersion: 1, + history: [], + unsentBriefs: 0, +}; + +const subOrgs: SubOrgSummary[] = [ + { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, +]; + +/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of + touching the DOM, so a spec can assert a command's success path directly. */ +function fakeBlobPresenter() { + const opened: Blob[] = []; + const presenter: BlobPresenter = { + open: (blob) => opened.push(blob), + download: () => { + throw new Error('not used by OrgTemplateStore'); + }, + }; + return { presenter, opened }; +} + +/** A no-op categories resource: the logo-upload sub-state is untouched by these + tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */ +function fakeCategoriesResource(): ReturnType { + const fake = { status: () => 'idle' as const, value: () => undefined }; + return fake as unknown as ReturnType; +} + +function setup( + adapter: Partial, + blobPresenter: BlobPresenter, +): OrgTemplateStore { + const uploadAdapter: Partial = { + categoriesResource: () => fakeCategoriesResource(), + }; + TestBed.configureTestingModule({ + providers: [ + { provide: OrgTemplateAdapter, useValue: adapter }, + { provide: UploadAdapter, useValue: uploadAdapter }, + { provide: UploadShellService, useValue: {} }, + { provide: BLOB_PRESENTER, useValue: blobPresenter }, + ], + }); + return TestBed.inject(OrgTemplateStore); +} + +// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw +// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- + +describe('OrgTemplateStore.proefbrief (RB-28)', () => { + it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { + // Given a loaded sub-org template. + const { presenter, opened } = fakeBlobPresenter(); + const blob = new Blob([''], { type: 'text/html' }); + const store = setup( + { + list: (): Promise> => Promise.resolve(ok(subOrgs)), + load: (): Promise> => Promise.resolve(ok(view)), + proefbrief: (): Promise> => Promise.resolve(ok(blob)), + }, + presenter, + ); + await store.load(); + + // When proefbrief() is called... + await store.proefbrief(); + + // Then the presenter receives exactly the rendered blob, and no error surfaces. + expect(opened).toEqual([blob]); + expect(store.lastError()).toBeNull(); + }); + + it('surfaces the error without opening a tab on failure', async () => { + // Given a loaded sub-org template whose proefbrief call fails server-side. + const { presenter, opened } = fakeBlobPresenter(); + const store = setup( + { + list: (): Promise> => Promise.resolve(ok(subOrgs)), + load: (): Promise> => Promise.resolve(ok(view)), + proefbrief: (): Promise> => + Promise.resolve({ ok: false, error: 'mislukt' }), + }, + presenter, + ); + await store.load(); + + // When proefbrief() is called... + await store.proefbrief(); + + // Then the presenter is never reached and the error is surfaced. + expect(opened).toHaveLength(0); + expect(store.lastError()).toBe('mislukt'); + }); +}); diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index c78213f..67bb1fe 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -20,6 +20,7 @@ import { } from '@brief/domain/org-template.machine'; import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; +import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; type LoadedState = Extract; @@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave { private adapter = inject(OrgTemplateAdapter); private uploadAdapter = inject(UploadAdapter); private shell = inject(UploadShellService); + private blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -217,7 +219,7 @@ export class OrgTemplateStore implements PendingSave { return; } this.actionState.set({ tag: 'Idle' }); - window.open(URL.createObjectURL(r.value), '_blank'); + this.blobPresenter.open(r.value); } // --- logo upload (reuses the shared upload transport; single `org-logo` file) --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 42b2021..68606c5 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | implemented | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md new file mode 100644 index 0000000..a915435 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md @@ -0,0 +1,180 @@ +# RB-28 — `BLOB_PRESENTER` token unlocks the three blob-to-browser success paths + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-006 · +`99-backlog.md` RB-28 + +## What was wrong + +Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot +meaningfully execute: `StamdataStore.download()` +(`libs/beheer/src/application/stamdata.store.ts`) did `URL.createObjectURL` → +`document.createElement('a')` → `a.click()` → `URL.revokeObjectURL`; +`BriefStore.previewLetter()` (`apps/ssp/src/app/brief/application/brief.store.ts`) and +`OrgTemplateStore.proefbrief()` (`apps/ssp/src/app/brief/application/org-template.store.ts`) +both did `window.open(URL.createObjectURL(blob), '_blank')`. Because the call was the +last statement of each command, TE-006 recorded the whole success path as effectively +unassertable, and `download()`'s two-clause guard (`if (!s || !this.canDownload()) +return;`) as permanently dark on its true branch. + +## What changed + +One new file, `libs/shared/src/application/blob-presenter.ts`, mirroring the +`SESSION_PORT` token already in that folder — an interface, a production +implementation, and an `InjectionToken`: + +```ts +export interface BlobPresenter { + open(blob: Blob): void; + download(blob: Blob, filename: string): void; +} + +const realBlobPresenter: BlobPresenter = { + open(blob) { + window.open(URL.createObjectURL(blob), '_blank'); + }, + download(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, +}; + +export const BLOB_PRESENTER = new InjectionToken('BLOB_PRESENTER', { + providedIn: 'root', + factory: () => realBlobPresenter, +}); +``` + +`open()` never revokes the object URL (the tab it opens outlives the call — +`BriefStore.previewLetter`'s original comment already said so and is preserved, +moved onto the token's own doc comment); `download()` does revoke, once the click has +fired. This asymmetry is preserved deliberately, not unified — the two call sites +behaved differently before this ticket and still do. + +Each of the three commands now injects `BLOB_PRESENTER` and calls it instead of the DOM +directly: + +| File | Before (last statement) | After | +| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `stamdata.store.ts` | `createObjectURL` → `createElement('a')` → `click()` → `revokeObjectURL` (6 lines) | `this.blobPresenter.download(blob, \`${s.table.id}.json\`);` | +| `brief.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` | +| `org-template.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` | + +`OrgTemplateStore.previewUrlFor` (added by RB-24, a different seam — a document +content URL for an ``, not a blob handoff) is untouched. + +## Tests added + +**`libs/beheer/src/application/stamdata.store.spec.ts`** — a new +`StamdataStore.download (RB-28)` describe block with a recording fake `BlobPresenter`: + +1. Does not call the presenter while `canDownload()` is false because nothing is dirty + yet — the guard's previously-dark true branch, first clause. +2. Does not call the presenter while previewing a date, even with a real edit present — + the guard's true branch, second clause. +3. **The success path**, asserting `toJson(...)`'s exact output reaches the file: reads + the recorded blob's text and compares it byte-for-byte against a direct call to + `toJson(store.table()!, store.rows())`, and asserts the filename is + `professions.json`. + +**`apps/ssp/src/app/brief/application/brief.store.spec.ts`** — the existing +`BriefStore.previewLetter` describe block's success test previously spied directly on +`window.open`/`URL.createObjectURL` (both already jsdom-spyable, since the properties +exist even though calling them for real throws "not implemented"). It now provides the +recording fake via `BLOB_PRESENTER` and asserts `opened` holds exactly the resolved +blob — the same outcome, reached through the new seam instead of monkey-patching two +global browser objects. + +**`apps/ssp/src/app/brief/application/org-template.store.spec.ts`** (new file — +`OrgTemplateStore` had no spec at all before this ticket) — a +`OrgTemplateStore.proefbrief (RB-28)` describe block: the success path (presenter +receives the resolved blob, no error) and the failure path (presenter never reached, +error surfaced). A `Partial` stub with a no-op `categoriesResource` +(status `'idle'`) satisfies the store's constructor effect without touching the +logo-upload sub-state, which these tests do not exercise. + +## Verified red without the fix + +Broke `StamdataStore.download()` with an `Edit` (not `git checkout`): changed the +filename from `` `${s.table.id}.json` `` to `` `${s.table.id}.csv` ``. Ran the new +success-path spec: + +``` +AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality + +Expected: "professions.json" +Received: "professions.csv" + ❯ libs/beheer/src/application/stamdata.store.spec.ts:134:36 +``` + +Re-applied the correct filename with a second `Edit`; the full `stamdata.store.spec.ts` +file (6 tests) went green again. + +## Verification + +- **`grep` for remaining DOM blob calls** in all three stores — + `grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)"` — + zero matches. The only occurrences of those calls anywhere in `apps`/`libs` are inside + `blob-presenter.ts` itself (checked with a second, unscoped grep — no fourth inlined + handoff exists). +- `npm run lint`: clean. +- `npm run dep:check`: unaffected (no new import direction — `libs/shared` still does not + depend on `libs/beheer`; both `libs/beheer` and `apps/ssp/brief` import the new token + from `libs/shared`, never the reverse). +- `npm test` (all four projects): all pass — ssp 276, behandelportal 37, shared 138, + beheer 26 (up from 23; +3 for the new `download()` describe block). +- Coverage, `npm run test:coverage` narrowed per project: + - `libs/beheer/src/application/stamdata.store.ts` — **before** BRH 15 / BRF 37 + (40.5% branch, confirmed against the current tree, matching TE-006's citation + exactly); **after** BRH 25 / BRF 37 (**67.6% branch**). `libs/beheer/src/application` + has exactly this one file, so the module figure moves the same way. + - `apps/ssp/src/app/brief/application/brief.store.ts` — **before** BRH 39 / BRF 72 + (54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23 + already added branches (the 404-tolerance path) since the finding was written — see + "What TE-006 got wrong" below. **After**: BRH 39 / BRF 72, unchanged — swapping the + global-spy assertions for the injected fake changes how the success branch is + reached in the spec, not whether it is reached; it was already covered before this + ticket (see below). + - `apps/ssp/src/app/brief/application/org-template.store.ts` — no spec existed before + this ticket, so there is no meaningful "before" branch figure for it specifically. + **After**: BRH 17 / BRF 77, including both `proefbrief()` branches newly covered. +- `npm run ci` (foreground, `timeout: 600000`, no background/Monitor): result reported + in the implementing agent's final answer. + +## What TE-006 got wrong + +TE-006 states: "`brief.store.spec.ts` demonstrates this exactly: it tests +`previewLetter`'s failure case ... and cannot test the success case." This is not +accurate for the code as it stood at the start of this ticket. The spec already had an +`'opens the composed letter in a new tab on success'` test that used +`vi.spyOn(URL, 'createObjectURL')` and `vi.spyOn(window, 'open')` to assert the success +path — jsdom defines both properties (as functions that throw "not implemented" if +actually invoked), so `vi.spyOn` can already replace them, and the pre-existing test +did. That test passed both before and after this ticket's change; this ticket did not +newly unlock `previewLetter`'s success path, it moved an already-passing assertion off +two hand-spied global browser objects and onto the new injectable seam. `git log +--follow -p` on the spec file shows this test dates to the WP-67 monorepo merge, not to +any of RB-22/23/24. + +The seam is still worth having: `StamdataStore.download()`'s success path (five +DOM/API calls in a row: `createObjectURL`, `createElement`, `.href`, `.download`, +`.click()`, `revokeObjectURL`) is a materially harder thing to spy on faithfully than a +single `window.open` call, and was in fact still dark before this ticket (no +`download()` test of any kind existed). `OrgTemplateStore.proefbrief()` also had no +spec at all. TE-006's diagnosis (three commands share the same class of problem, one +token fixes all three) is sound; only the specific "cannot test" claim about +`previewLetter` overstates what was true for that one call site. Scope was not reduced +because of this — all three call sites are migrated per the ticket's own instruction to +ship them together rather than half-adopt the seam. + +## What this ticket did not touch + +`OrgTemplateStore.previewUrlFor` (RB-24) — confirmed present and unchanged at +`org-template.store.ts:78`. `libs/shared/src/application/upload-shell.service.ts` +(RB-25) and `upload-controller.ts` (RB-26) — not read beyond what RB-24's own note +already described, not edited. `libs/shared/docs/behaviour-spec.mdx` — regenerated by +`npm run gen:behaviour-spec` (part of `npm run ci`) to reflect the new/renamed test +names; never hand-edited. diff --git a/libs/beheer/src/application/stamdata.store.spec.ts b/libs/beheer/src/application/stamdata.store.spec.ts index d81c350..14958ec 100644 --- a/libs/beheer/src/application/stamdata.store.spec.ts +++ b/libs/beheer/src/application/stamdata.store.spec.ts @@ -1,7 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect } from 'vitest'; import { Result, ok } from '@shared/kernel/fp'; -import { StamRow, StamTable } from '@beheer/domain/stamdata'; +import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; +import { StamRow, StamTable, toJson } from '@beheer/domain/stamdata'; import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter'; import { StamdataStore } from './stamdata.store'; @@ -16,13 +17,30 @@ const table: StamTable = { }; const rows: StamRow[] = [{ program: 'geneeskunde', beroep: 'Arts' }]; -function setup(): StamdataStore { +/** A recording fake of BLOB_PRESENTER — records every call instead of touching the DOM, + which is what TE-006's seam is for: the store's success path becomes assertable. */ +function fakeBlobPresenter() { + const opened: Blob[] = []; + const downloaded: { blob: Blob; filename: string }[] = []; + const presenter: BlobPresenter = { + open: (blob) => opened.push(blob), + download: (blob, filename) => downloaded.push({ blob, filename }), + }; + return { presenter, opened, downloaded }; +} + +function setup(blobPresenter?: BlobPresenter): StamdataStore { const adapter: Partial = { list: (): Promise> => Promise.resolve(ok([table])), load: (): Promise> => Promise.resolve(ok({ table, rows: rows.map((r) => ({ ...r })) })), }; - TestBed.configureTestingModule({ providers: [{ provide: StamdataAdapter, useValue: adapter }] }); + TestBed.configureTestingModule({ + providers: [ + { provide: StamdataAdapter, useValue: adapter }, + ...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []), + ], + }); return TestBed.inject(StamdataStore); } @@ -63,3 +81,57 @@ describe('StamdataStore undo/redo (WP-32)', () => { expect(store.canUndo()).toBe(false); }); }); + +// --- RB-28 (TE-006): download() ends in BLOB_PRESENTER.download, not raw DOM calls, +// so the seam makes both the guard's branches and the success path assertable. --- + +describe('StamdataStore.download (RB-28)', () => { + it('does not call the presenter while the two-clause guard blocks (nothing dirty yet)', async () => { + // Given a freshly loaded table with no edits — canDownload() is false. + const { presenter, downloaded } = fakeBlobPresenter(); + const store = setup(presenter); + await store.load(); + expect(store.canDownload()).toBe(false); + + // When download() is called... + store.download(); + + // Then the guard's true branch fires and the presenter is never reached. + expect(downloaded).toHaveLength(0); + }); + + it('does not call the presenter while previewing a date, even with edits', async () => { + // Given a loaded table with a real edit, but a preview date filter active. + const { presenter, downloaded } = fakeBlobPresenter(); + const store = setup(presenter); + await store.load(); + store.editCell(0, 'beroep', 'Chirurg'); + store.setPreviewDate('2024-01-01'); + expect(store.canDownload()).toBe(false); + + // When download() is called... + store.download(); + + // Then the guard still blocks it. + expect(downloaded).toHaveLength(0); + }); + + it("passes toJson(...)'s exact output and the table id as the filename (success path)", async () => { + // Given a loaded table with a valid, dirty edit — canDownload() is true. + const { presenter, downloaded } = fakeBlobPresenter(); + const store = setup(presenter); + await store.load(); + store.editCell(0, 'beroep', 'Chirurg'); + expect(store.canDownload()).toBe(true); + const expectedJson = toJson(store.table()!, store.rows()); + + // When download() is called... + store.download(); + + // Then the presenter receives exactly one call, with toJson's output reaching the + // file byte-for-byte and the table id as the file name. + expect(downloaded).toHaveLength(1); + expect(downloaded[0].filename).toBe('professions.json'); + await expect(downloaded[0].blob.text()).resolves.toBe(expectedJson); + }); +}); diff --git a/libs/beheer/src/application/stamdata.store.ts b/libs/beheer/src/application/stamdata.store.ts index c3f9154..0574b13 100644 --- a/libs/beheer/src/application/stamdata.store.ts +++ b/libs/beheer/src/application/stamdata.store.ts @@ -18,6 +18,7 @@ import { reduce, } from '@beheer/domain/stamdata-editor.machine'; import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter'; +import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; type LoadedState = Extract; @@ -30,6 +31,7 @@ type LoadedState = Extract; @Injectable({ providedIn: 'root' }) export class StamdataStore { private adapter = inject(StamdataAdapter); + private blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -138,12 +140,7 @@ export class StamdataStore { const s = this.loaded(); if (!s || !this.canDownload()) return; const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${s.table.id}.json`; - a.click(); - URL.revokeObjectURL(url); + this.blobPresenter.download(blob, `${s.table.id}.json`); } } diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 9d57a5b..f705e70 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 467 frontend behaviours across +**is** the suite, reshaped for a business reader. 472 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -114,6 +114,12 @@ classes. - records addRow and undoes it - clears history when switching table +#### StamdataStore.download (RB-28) + +- does not call the presenter while the two-clause guard blocks (nothing dirty yet) +- does not call the presenter while previewing a date, even with edits +- passes toJson(...)'s exact output and the table id as the filename (success path) + #### activeOn (valid-time, half-open [van, tot)) - includes a row whose window covers the date @@ -187,7 +193,7 @@ classes. #### BriefStore.previewLetter -- opens the composed letter in a new tab on success +- opens the composed letter via BLOB_PRESENTER on success (RB-28) - surfaces the error without opening a tab on failure #### BriefStore.revealBigNummer (PRD-0002 §5c) @@ -200,6 +206,11 @@ classes. - sends no X-Role/X-Subject headers outside isDevMode() - sends X-Role (and X-Subject when known) under isDevMode() +#### OrgTemplateStore.proefbrief (RB-28) + +- opens the rendered proefbrief via BLOB_PRESENTER on success +- surfaces the error without opening a tab on failure + #### RevealBigNummerAdapter.reveal (BIO-006a + BIO-012) - sends X-Step-Up only when the caller passes stepUp: true diff --git a/libs/shared/src/application/blob-presenter.ts b/libs/shared/src/application/blob-presenter.ts new file mode 100644 index 0000000..a2b841d --- /dev/null +++ b/libs/shared/src/application/blob-presenter.ts @@ -0,0 +1,37 @@ +import { InjectionToken } from '@angular/core'; + +/** + * A shared seam for handing a generated `Blob` to the browser, WITHOUT the calling + * command inlining `URL.createObjectURL`/`window.open`/`document.createElement('a')` + * as its own last statement (TE-006) — those calls are unassertable in jsdom because + * they are the end of the command, not a value the spec can intercept. A recording + * fake satisfies this shape in specs; `realBlobPresenter` is the production default. + */ +export interface BlobPresenter { + /** Open a blob in a new tab (e.g. a rendered letter preview). Never revokes the + object URL — the tab outlives this call, and the POC treats the leak as cheap + (see `BriefStore.previewLetter`'s original comment). */ + open(blob: Blob): void; + /** Trigger a browser download of a blob under the given file name, then revoke the + object URL once the click has been dispatched. */ + download(blob: Blob, filename: string): void; +} + +const realBlobPresenter: BlobPresenter = { + open(blob: Blob) { + window.open(URL.createObjectURL(blob), '_blank'); + }, + download(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, +}; + +export const BLOB_PRESENTER = new InjectionToken('BLOB_PRESENTER', { + providedIn: 'root', + factory: () => realBlobPresenter, +}); From e63db509efcab546a737b23498fd258a12e110de Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 08:52:17 +0200 Subject: [PATCH 54/61] refactor(shared): extract uploadOutcome from the XHR load closure (RB-27) UploadAdapter.xhrUpload built new XMLHttpRequest() directly and put the actual decisions inside its load listener: 2xx-vs-not, JSON.parse of the body with a fallback, and ProblemDetails mapping via parseError. None of it was reachable without stubbing the XHR global, so it had no spec (TE-005; file LH 5/64, BRH 3/57). Extract uploadOutcome(status, responseText): Result, a pure function next to genericError/parseError. It holds the 2xx check, the JSON.parse-with-fallback, and the ProblemDetails mapping. The load listener is now a two-line dispatch into it. Abort-vs-error disambiguation stays where it is: it decides whether a response exists at all, before uploadOutcome would even run, and the proposed signature has no field for "aborted". It is already a one-line ternary with no DOM-only logic to extract. Add upload.adapter.spec.ts: plain describe/it, no DOM, no XHR stub, covering a 2xx success, a 2xx unparseable body, a non-2xx ProblemDetails body, a non-2xx non-ProblemDetails body, and the 200/300 boundary. Verified red by editing uploadOutcome down to one line (an Edit, not git checkout): 4 of 5 new specs failed. Re-applied with a second Edit. Coverage for upload.adapter.ts: LH 5/64 -> 12/65, BRH 3/57 -> 7/59. Skip TE-005's optional half (moving the currentScenario() branch into KeepaliveTransport.send()): it needs a second file, upload-shell. service.ts, and this ticket's own scope fences it to upload.adapter.ts and its spec. The dev simulator's behaviour is unchanged. Mark RB-27 implemented in 99-backlog.md and add its implementation note, including a batch 5 close-out. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++---- .../refactor-backlog/implementation/rb-27.md | 193 ++++++++++++++++++ libs/shared/docs/behaviour-spec.mdx | 10 +- .../src/infrastructure/upload.adapter.spec.ts | 35 ++++ .../src/infrastructure/upload.adapter.ts | 30 ++- 5 files changed, 293 insertions(+), 45 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md create mode 100644 libs/shared/src/infrastructure/upload.adapter.spec.ts diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 265fa41..854f879 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **implemented** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md new file mode 100644 index 0000000..596a495 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md @@ -0,0 +1,193 @@ +# RB-27 — `uploadOutcome` extracted from the XHR `load` closure + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-005 · +`99-backlog.md` RB-27, "Merges" table row for RB-25/26/27 · Depends on +`implementation/rb-24.md` (the move that put this file at its current path) and +`implementation/rb-25.md` (handoff paragraph read before deciding the optional half) + +## What was wrong + +`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` constructs +`new XMLHttpRequest()` directly and attaches its `load` listener inline. The listener +body held the actual decisions: 2xx-vs-not, `JSON.parse` of the response body with a +fallback to a generic error, and (on a non-2xx status) ProblemDetails mapping via the +un-exported `parseError`. None of it is reachable without stubbing the XHR global, so +the interpretation logic had no spec. + +TE-005's baseline citation: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57 (5.3% branch)**. +The file was counted "reached" in the module total only because another spec imports +it — essentially nothing in it executed. + +## What changed + +One function extracted from the `load` listener, in the same file: + +```ts +export function uploadOutcome( + status: number, + responseText: string, +): Result { + if (status < 200 || status >= 300) return err(parseError(responseText)); + try { + return ok({ documentId: JSON.parse(responseText).documentId }); + } catch { + return err(genericError()); + } +} +``` + +placed next to `genericError`/`parseError` (below the class, above the dev +`simulateUpload`). It contains exactly the 2xx-vs-not check, the `JSON.parse`-with- +fallback, and the ProblemDetails mapping — the three decisions TE-005 names. The `load` +listener is now a two-line dispatch: + +```ts +xhr.addEventListener('load', () => { + const outcome = uploadOutcome(xhr.status, xhr.responseText); + outcome.ok ? resolve(outcome.value) : reject(outcome.error); +}); +``` + +`Result`, `ok`, `err` are imported from `@shared/kernel/fp` (the repo's one `Result` +type, already used the same way by `libs/shared`'s other infrastructure adapters). +`parseError` and `genericError` are untouched — `uploadOutcome` calls them exactly as +the old listener body did, so their own behavior (ProblemDetails detail extraction, +generic fallback) is unchanged. + +## Abort-vs-error: left as a separate, smaller concern + +TE-005 names abort-vs-error disambiguation in the same sentence as the extraction +target, but its proposed signature — `uploadOutcome(status: number, responseText: +string)` — has no way to express "the request was aborted before any response +arrived." That is a real, structural mismatch, not an oversight to route around: + +- `uploadOutcome` runs inside the `load` listener, which fires only when the browser + received a complete HTTP response — it has a `status` and a `responseText` by + construction. +- The `abort` listener fires instead of `load` when `xhr.abort()` was called + client-side. There is no HTTP response at that point — no status, no body — so + folding it into `uploadOutcome`'s signature would mean inventing a fake status (e.g. + `0`) to stand for "not actually a response," which trades one implicit convention for + another and makes the pure function's contract lie about what it receives. + +The existing code already expresses this as the smallest form it can take: + +```ts +xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError()))); +``` + +one ternary, deciding between two sentinels based on which native event fired and +whether `cancel()` was called first — not on response content. It is not a second +`uploadOutcome`-shaped decision hiding in a closure; it is a one-line dispatch already. +Extracting it into its own named function would add a call site and an import for a +single ternary with no reachable-only-via-DOM logic left inside it. Left in place, as +DoD point 2 allows. + +## Spec added, verified red + +`libs/shared/src/infrastructure/upload.adapter.spec.ts` (new file) — plain +`describe`/`it`, no `TestBed`, no DOM, no XHR stub, matching the DoD's explicit +"that is the entire point." Five cases: + +1. 2xx status with a valid JSON body → `{ ok: true, value: { documentId } }`. +2. 2xx status with an unparseable body → falls back to the generic `UPLOAD_FAILED` + text (the `JSON.parse`-with-fallback branch). +3. Non-2xx status with a ProblemDetails body → the `detail` field, via `parseError`. +4. Non-2xx status with a body that is not ProblemDetails-shaped → falls back to the + generic text. +5. The 200/300 boundary: 299 is success, 300 is not. + +`UPLOAD_FAILED`'s text is not exported (unchanged by this ticket), so the spec holds +its own copy of the Dutch string as a local constant with a comment pointing at the +source — the same trade every other spec makes when asserting against `$localize` +constants that never leave their module ($localize`strings are English-first prose +only where the source is`nl`, so this is the source text as written, not a stand-in). + +**Red-proof.** Edited `uploadOutcome`'s body down to a single line — +`return ok({ documentId: JSON.parse(responseText).documentId });`, dropping the +status check and the try/catch — with an `Edit` (not `git checkout`). Ran +`ng test shared`. Result: 4 of the 5 new specs failed: + +``` +SyntaxError: Unexpected token 'o', "not json" is not valid JSON + ❯ uploadOutcome libs/shared/src/infrastructure/upload.adapter.ts:168:32 + +AssertionError: expected { ok: true, value: { …(1) } } to deeply equal { ok: false, …(1) } +- Expected "error": "Document is al aan een aanvraag gekoppeld.", "ok": false, ++ Received "ok": true, "value": { "documentId": undefined }, + +SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON + +AssertionError: expected true to be false // Object.is equality +``` + +(only the plain 2xx-valid-JSON case still passed, as expected of a mutant that always +reports success). Re-applied the real body with a second `Edit`; `git diff` against +HEAD shows only the intended net change — the red edit left no trace. Re-ran: +163/163 green. + +## Coverage, `upload.adapter.ts` + +| Metric | Before (TE-005 baseline) | After | +| -------- | ------------------------ | ---------------------- | +| Lines | LH 5 / LF 64 (7.8%) | LH 12 / LF 65 (18.5%) | +| Branches | BRH 3 / BRF 57 (5.3%) | BRH 7 / BRF 59 (11.9%) | + +(`LF`/`BRF` grew by one line and two branches because `uploadOutcome` is new source; +`npm run test:coverage`'s shared run, `coverage/shared/lcov.info`, narrowed to this +file's `SF:` block.) The jump is real but modest in absolute percentage: `uploadOutcome` +itself is now fully exercised (`FNDA:6,uploadOutcome`, both branches of the status +check hit, both the try and the catch path hit), but the class methods +(`categoriesResource`, `status`, `deleteDocument`, `xhrUpload`'s own body, +`simulateUpload`) remain unreached — they need DI/XHR/timers to test and are +out of this ticket's scope, exactly as TE-005 scopes it ("extract the interpretation, +not the transport"). + +## Optional scenario-branch move: not taken + +TE-005 suggests, as an explicitly optional second half, moving the `currentScenario()` +branch from `xhrUpload` up into `KeepaliveTransport.send()` +(`libs/shared/src/application/upload-shell.service.ts`) so `xhrUpload` becomes +transport-only. RB-25's handoff confirms the seam is available (`KeepaliveTransport` +is still unexported, `send()` is still an unchanged one-liner) but not required. + +This ticket does not take that half, for a reason RB-25's handoff does not settle: +the ticket's own **Scope** section restricts this ticket to `upload.adapter.ts` and its +spec only ("RB-24, RB-25, RB-26, RB-28 have all already merged — nothing else in the +upload module is in flight, so you have the folder to yourself"). Moving the scenario +branch requires editing `upload-shell.service.ts` too — exporting `simulateUpload` (or +moving it) out of `upload.adapter.ts` and importing it into the application-layer +`send()` — which is a second file, outside the stated scope. Doing it anyway would also +widen this single-file ticket's diff for an explicitly optional half the ticket itself +says to skip when it "complicates the diff." The dev simulator's behavior is therefore +byte-for-byte unchanged: `xhrUpload` still checks `currentScenario()` first and still +delegates to the untouched `simulateUpload` for `upload-slow`/`upload-fail`, verified by +inspection (the only edit inside `xhrUpload` is the `load`-listener dispatch) and by the +full `shared` suite staying green, including `upload-shell.service.spec.ts`'s existing +scenario-adjacent assertions. + +## Verification + +- `npm run lint`: clean. +- `npm run dep:check`: unaffected — the only new import is `@shared/kernel/fp`, already + the repo's shared `Result` module, imported the same way by other `libs/shared` + infrastructure adapters (no new import direction). +- `npm test` / `ng test shared`: 163/163, across 26 spec files — 5 of those tests are + the new `upload.adapter.spec.ts`, the other 158 across 25 pre-existing files are + unchanged by this ticket. +- `npm run ci`: result and step count reported in the implementing agent's final answer. + +## Batch 5 close-out + +Batch 5 (RB-25 through RB-30) is now fully implemented. For `libs/shared/upload` +(moved to its layered home by RB-24) specifically: `upload.machine.ts` (domain) has its +own spec and `planFileSelection` extracted by RB-26; `upload-shell.service.ts` +(application) has a full spec covering `upload()`/`cancel()`/`delete()`/ +`pollReturning()` via the `UPLOAD_TRANSPORT` token RB-25 added; `upload-controller.ts` +(application) was already spec'd before this batch; `upload.adapter.ts` +(infrastructure) now has `uploadOutcome` as a pure, spec'd seam, though the class's +HTTP-bound methods (categories/status/delete/the XHR transport itself) remain +untested by design — XHR is the one boundary this batch deliberately does not +abstract, per TE-005's own instruction. End to end, every layer of the upload module +that can hold pure logic now does, and has a spec proving it; what is left uncovered is +exactly the DOM/network edge the module exists to wrap, not logic hiding behind it. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index d80a96a..2d7c71d 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 492 frontend behaviours across +**is** the suite, reshaped for a business reader. 497 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -929,6 +929,14 @@ classes. - failed then retried returns to queued - UploadRemoved drops the upload +#### uploadOutcome + +- resolves a 2xx response with a valid JSON body to the document id +- falls back to the generic error when a 2xx body is not valid JSON +- maps a non-2xx ProblemDetails body to its detail +- falls back to the generic error for a non-2xx body without a ProblemDetails detail +- treats status 200-299 as success and everything else as failure + #### withIdempotencyKey / currentIdempotencyKey - threads the key to every read made inside the wrapped fn diff --git a/libs/shared/src/infrastructure/upload.adapter.spec.ts b/libs/shared/src/infrastructure/upload.adapter.spec.ts new file mode 100644 index 0000000..ddd33ec --- /dev/null +++ b/libs/shared/src/infrastructure/upload.adapter.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { uploadOutcome } from './upload.adapter'; + +/** Matches the un-exported UPLOAD_FAILED fallback text in upload.adapter.ts. */ +const UPLOAD_FAILED = 'Uploaden is niet gelukt. Probeer het opnieuw.'; + +describe('uploadOutcome', () => { + it('resolves a 2xx response with a valid JSON body to the document id', () => { + const outcome = uploadOutcome(200, JSON.stringify({ documentId: 'doc-1' })); + expect(outcome).toEqual({ ok: true, value: { documentId: 'doc-1' } }); + }); + + it('falls back to the generic error when a 2xx body is not valid JSON', () => { + const outcome = uploadOutcome(201, 'not json'); + expect(outcome).toEqual({ ok: false, error: UPLOAD_FAILED }); + }); + + it('maps a non-2xx ProblemDetails body to its detail', () => { + const outcome = uploadOutcome( + 409, + JSON.stringify({ detail: 'Document is al aan een aanvraag gekoppeld.', status: 409 }), + ); + expect(outcome).toEqual({ ok: false, error: 'Document is al aan een aanvraag gekoppeld.' }); + }); + + it('falls back to the generic error for a non-2xx body without a ProblemDetails detail', () => { + const outcome = uploadOutcome(500, 'Internal Server Error'); + expect(outcome).toEqual({ ok: false, error: UPLOAD_FAILED }); + }); + + it('treats status 200-299 as success and everything else as failure', () => { + expect(uploadOutcome(299, JSON.stringify({ documentId: 'd' })).ok).toBe(true); + expect(uploadOutcome(300, JSON.stringify({ detail: 'x' })).ok).toBe(false); + }); +}); diff --git a/libs/shared/src/infrastructure/upload.adapter.ts b/libs/shared/src/infrastructure/upload.adapter.ts index 0cb0e7e..38bc22a 100644 --- a/libs/shared/src/infrastructure/upload.adapter.ts +++ b/libs/shared/src/infrastructure/upload.adapter.ts @@ -9,6 +9,7 @@ import { currentScenario } from '@shared/infrastructure/scenario'; import { currentSubject } from '@shared/infrastructure/subject'; import { environment } from '@shared/environments/environment'; import { DocumentCategory } from '@shared/domain/upload.machine'; +import { Result, err, ok } from '@shared/kernel/fp'; /** Answer-derived query params that affect which categories the server presents. */ export interface CategoryParams { @@ -128,15 +129,8 @@ export class UploadAdapter { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }); xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - resolve({ documentId: JSON.parse(xhr.responseText).documentId }); - } catch { - reject(genericError()); - } - } else { - reject(parseError(xhr.responseText)); - } + const outcome = uploadOutcome(xhr.status, xhr.responseText); + outcome.ok ? resolve(outcome.value) : reject(outcome.error); }); xhr.addEventListener('error', () => reject(genericError())); xhr.addEventListener('abort', () => @@ -160,6 +154,24 @@ export class UploadAdapter { const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`; const genericError = (): string => UPLOAD_FAILED; +/** + * Pure interpretation of one finished XHR `load` event: 2xx-vs-not, `JSON.parse` + * of the body with a fallback to a generic error, and (on a non-2xx status) + * ProblemDetails mapping via `parseError`. No DOM and no XHR — the listener that + * calls this only reads `xhr.status`/`xhr.responseText` and dispatches the result. + */ +export function uploadOutcome( + status: number, + responseText: string, +): Result { + if (status < 200 || status >= 300) return err(parseError(responseText)); + try { + return ok({ documentId: JSON.parse(responseText).documentId }); + } catch { + return err(genericError()); + } +} + /** * Demo-only (dev): the real XHR POST finishes instantly for metadata, so progress * and failure can't otherwise be shown. Drives the progress bar over ~2.5s, then From c4a5d20202b70360c6045d16a5786b134d6062da Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 08:53:00 +0200 Subject: [PATCH 55/61] docs(backlog): mark RB-27 done after merge Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 854f879..13b661c 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **implemented** | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- From 03c6e093060ceda54e811aa19f17ba970a48d104 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 08:55:39 +0200 Subject: [PATCH 56/61] docs(backlog): CD batch 5 complete All seven tickets RB-24 to RB-30 merged, one commit per ticket. Records the actual wave split, since the backlog's own depends-on column missed that RB-24 rewrites imports in two of RB-28's target files. RB-24 expanded its own scope to fix a second, real boundary violation that deleting its acceptance criterion exposed, reviewed and accepted. Two more findings were shown stale or overstated, on top of the nine from earlier batches. RB-26 and RB-27 both correctly declined part of their own ticket's proposed shape. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/_status.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 679cc2e..41a3be7 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,15 +14,15 @@ ## Phase 3 — implementation -| CD batch | Tickets | Status | Notes | -| -------- | ------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | -| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | -| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | -| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. | -| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. | -| 6 | RB-31, RB-32, RB-33 | not started | | -| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | +| CD batch | Tickets | Status | Notes | +| -------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | +| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. | +| 5 | RB-24..RB-30 | **complete** | All seven merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Ran as three waves, not the two the backlog implied: RB-24 rewrites imports in `brief.store.ts` and `org-template.store.ts`, which are two of RB-28's three targets — a dependency the backlog's "25/26/27 depend on 24" note never mentioned. **A** = RB-24 alone (the move), then **A2** = RB-29 + RB-30 in parallel (backend, no file overlap with the move or each other), **B** = RB-25 + RB-26 + RB-28 in parallel once RB-24 landed, **C** = RB-27 alone last, since it depends on RB-25's transport token. **RB-24 expanded its own scope, correctly.** Deleting the dependency-cruiser carve-out — the ticket's own acceptance criterion — exposed a second, real `ui-not-infrastructure` violation the old path had hidden: three UI components injected `UploadAdapter` for nothing but a one-line wrapper over its own exported pure function. The dispatch prompt said to report a second violation, not fix it; the agent judged this one was on the critical path (`dep:check` cannot pass with the carve-out gone otherwise) and fixed it minimally, reusing the existing pure function. Reviewed before merging — sound. **Two more findings were shown to be stale or overstated, on top of the two ADR-fixes found wrong and RB-18/RB-23's incompleteness from batch 4 — nine total now.** RB-25 found TE-003 overstated its own blocker: of the four methods named, only `upload()` and `cancel()` were actually unfakeable through the missing token — `delete()`/`pollReturning()` already went through the exported `UploadAdapter`. RB-28 found TE-006 already false at the time it was written: `brief.store.spec.ts` already had a `previewLetter` success test via jsdom's spyable `URL`/`window` stubs, contradicting the finding's "cannot test the success case" claim — the overall three-site diagnosis still held and was shipped as instructed. **RB-26 made one real design call**, reviewed before merging: `planFileSelection` must return `UploadMsg[]` per its literal signature, but an accepted file's real `localId` needs `crypto.randomUUID()`, which the ticket itself keeps in the controller. It ships a placeholder `localId: ''` discriminated by `.type` alone and never dispatched — verified the index alignment holds for both the multiple-rejection short-circuit and the per-file path. **RB-27 left one thing unextracted, correctly**: TE-005 lumped abort-vs-error disambiguation into the same extraction as `uploadOutcome`, but abort fires on a different event with no `status`/`responseText` at all — it structurally cannot fit the proposed signature. Left in place as a one-line ternary. The optional `currentScenario()` move into `KeepaliveTransport` was also correctly declined — it would have crossed into `upload-shell.service.ts`, outside this ticket's stated single-file scope. **End state of `libs/shared/upload`** (now split across proper layers): every layer that can hold pure logic has one and is spec'd — `upload.machine.ts` (domain, `planFileSelection`), `upload-shell.service.ts` (application, the `UPLOAD_TRANSPORT` seam), `upload-controller.ts` (application), `upload.adapter.ts` (infrastructure, `uploadOutcome`). Only the XHR/DOM boundary itself stays untested by design — TE-005 was explicit that abstracting `XMLHttpRequest` away is not wanted, since the file documents why XHR (not `fetch`) is required. | +| 6 | RB-31, RB-32, RB-33 | not started | | +| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | **Standing caveat for every batch:** `dotnet test` reports one failure, `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, @@ -100,6 +100,7 @@ least one of these. Put all of it in the prompt. order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to commit the regenerated pair, or CI's drift job fails on a correct change. +12. **The backlog's own "depends on" column is not exhaustive — check actual imports before parallelizing a wave.** Batch 5's table said only "25/26/27 depend on 24"; it never mentioned that RB-24 rewrites imports in two of RB-28's three target files. `grep -rln` for the moved module's import path against every other open ticket's target files, before deciding what runs in parallel — not after a conflict. **Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and From 531817259ef92c889a1bb57fae033c7bf960c2a8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 13:14:21 +0200 Subject: [PATCH 57/61] refactor(shared): delete unwrapOk, the unadopted test value-object helper (RB-33) unwrapOk had zero consumers in apps/ or libs/ since ADR-0006 shipped it. The one call site the finding named already satisfies the ADR's real rule (call the real parser, never a cast) with an inline guard, so adding a manufactured first caller was not the better fix. This commit deletes the helper and its file, and updates the one doc sentence that named it. The finding's call site is unchanged. See rb-33.md for the full adopt-or-delete reasoning. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 ++++++------ .../refactor-backlog/implementation/rb-33.md | 100 ++++++++++++++++++ libs/shared/docs/testing.mdx | 10 +- libs/shared/src/testing/value-object.ts | 14 --- 4 files changed, 140 insertions(+), 54 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md delete mode 100644 libs/shared/src/testing/value-object.ts diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 13b661c..108f9b9 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | implemented | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md new file mode 100644 index 0000000..c4eed16 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md @@ -0,0 +1,100 @@ +# RB-33 — `unwrapOk`: adopt or delete + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-011 · +`99-backlog.md` RB-33 + +## Decision: delete + +The ticket names this "adopt or delete", not "adopt", and asks for the judgment call, not +the default. I deleted `unwrapOk`. + +## Why delete, not adopt + +`unwrapOk` (`libs/shared/src/testing/value-object.ts`) has had zero consumers across the +whole codebase since ADR-0006 shipped it, except its own definition and one sentence in +`libs/shared/docs/testing.mdx`. I verified this before changing anything: + +``` +grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx +libs/shared/docs/testing.mdx:92: ...unwrapOk(parseX(raw))... +libs/shared/src/testing/value-object.ts:9:export function unwrapOk(...) +libs/shared/src/testing/value-object.ts:11: throw new Error(`unwrapOk: ...`); +``` + +The one call site the finding names, +`apps/ssp/src/app/registratie/application/submit-change-request.spec.ts`, still has the +exact hand-rolled guard the finding quotes: + +```ts +const telefoon = parseTelefoonnummer('0612345678'); +if (!telefoon.ok) throw new Error('fixture phone should parse'); +``` + +I also checked whether any other spec has the same shape, in case the finding's "one call +site" undercounted the real duplication: + +``` +grep -rln "if (!.*\.ok)\s*throw" apps libs --include=*.spec.ts +apps/ssp/src/app/registratie/application/submit-change-request.spec.ts +``` + +Only this one file, anywhere. There is no cast (`'x' as Telefoonnummer`) to close off +either — the spec already calls the real `parseTelefoonnummer` and checks `.ok` before +touching `.value`. ADR-0006 §3's actual requirement ("never a cast") is already met by the +inline code, with or without the helper. + +Weighing it honestly: + +- **For adopt:** it is a one-line change, and the ADR's own worked example literally shows + this exact call. Doing it would make the finding's "zero adopters" claim technically + false. +- **For delete:** a helper that gains its _only_ real-codebase consumer by an agent adding + that one call site as an act of ticket compliance is not organic adoption — it is + manufacturing a usage to justify keeping the file. `unwrapOk` has sat available, exported, + and documented since ADR-0006 (well before this session) without a single spec reaching + for it on its own. One caller, forever, is not "removing duplication" (the stated point + of a shared test helper) — there is no duplication with only one occurrence. The inline + guard is also arguably clearer here: its error message (`'fixture phone should parse'`) + names the actual fixture, where `unwrapOk`'s generic message + (`unwrapOk: expected ok, got error: ...`) does not. + +Delete wins: it removes dead, unadopted code and its stale doc reference, changes no +runtime behaviour anywhere, and costs nothing to reverse if a second real need for this +idiom shows up later (three lines, trivial to re-add against actual duplication instead of +a single hypothetical site). + +## What changed + +| File | Change | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/testing/value-object.ts` | Deleted. Its only export, `unwrapOk`, is what this ticket removes; the file had nothing else in it. | +| `libs/shared/docs/testing.mdx` | Rewrote the sentence that named `unwrapOk` and the deleted file's path. It now states the same rule in plain terms — call the real `parse*` and check `.ok`, never a cast — and keeps the `RemoteData` half of the sentence pointing at `remote-data.ts` (unchanged, still in use). | +| `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` | **Not touched.** Its inline guard already satisfies ADR-0006 §3; this is the "delete" branch, so the fixture-construction behaviour stays exactly as it was. | +| `99-backlog.md` | RB-33's status cell: `open` → `implemented`. | + +## What this ticket did not touch + +`docs/reference/architecture/0006-test-data-builders.md` (the ADR itself) still shows +`unwrapOk` in its worked example and decision table. That is deliberate: RB-33 is a code +ticket, not one of the five ADR-fix tickets that need architect sign-off +(`06-adr-conformance.md`'s "ADR-fix tickets" section). The ADR's illustrated pattern +("call the real parser, unwrap through a checked path, never a cast") is still the correct +principle — this ticket only removes one now-unused concrete implementation of it, which +the inline guard in `submit-change-request.spec.ts` already satisfies without the named +helper. Amending the ADR's own text is out of this ticket's scope and is left for a future +ADR-fix ticket if one is ever raised. The finding document (`06-adr-conformance.md`) and the +historical WP-70/WP-71 backlog notes that mention `unwrapOk` are left as-is — they are +records of what was true when written, not living code. + +No other file in `libs/shared/src/testing/` was touched (`expect-tag.ts`, `machine.ts`, +`remote-data.ts` are all unrelated and still have real consumers). + +## Verification + +- `grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx` — zero occurrences. +- `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` — unchanged file, + still passes (see `npm run ci` result below). +- No new test added. The ticket is a deletion of unused code plus a doc-sentence rewrite; + the surviving inline guard in the spec is exercised the same way it always was, by the + spec's three existing `it` blocks. +- `npm run ci` (foreground): see the session report for the exit code and step count. diff --git a/libs/shared/docs/testing.mdx b/libs/shared/docs/testing.mdx index 6249f3b..aad8f86 100644 --- a/libs/shared/docs/testing.mdx +++ b/libs/shared/docs/testing.mdx @@ -89,11 +89,11 @@ can only ever be a state the real reducer actually produces: const atStep3 = givenIntake(Start(), SetUren('1200'), Next(), SetDiplomaHerkomst('NL'), Next()); ``` -The same rule extends to value objects (`unwrapOk(parseX(raw))` instead of a cast) and to -`RemoteData` (`loading()` / `success(v)` / `failure(e)` in -`libs/shared/src/testing/{value-object,remote-data}.ts` instead of a redefined-per-file -literal). **Never** a `.withX().withY()` builder over an open constructor — that just -re-opens whatever illegal state the domain closed. +The same rule extends to value objects — call the real `parse*` and check `.ok` before use, +never a cast — and to `RemoteData` (`loading()` / `success(v)` / `failure(e)` in +`libs/shared/src/testing/remote-data.ts` instead of a redefined-per-file literal). **Never** +a `.withX().withY()` builder over an open constructor — that just re-opens whatever illegal +state the domain closed. ## UI = Storybook, not heavy component tests diff --git a/libs/shared/src/testing/value-object.ts b/libs/shared/src/testing/value-object.ts deleted file mode 100644 index 08245e0..0000000 --- a/libs/shared/src/testing/value-object.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Result } from '@shared/kernel/fp'; - -/** - * Unwrap a `Result` produced by a REAL `parse*` value-object parser, throwing - * if it isn't `ok`. This is the only sanctioned way for a spec to obtain a - * branded value-object type — it closes off the `'garbage' as Postcode` cast - * route, since the only door to the branded type is the parser itself. - */ -export function unwrapOk(result: Result): T { - if (!result.ok) { - throw new Error(`unwrapOk: expected ok, got error: ${JSON.stringify(result.error)}`); - } - return result.value; -} From 6cfba81a396587b451a57c9d90369bcea5925192 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 13:15:31 +0200 Subject: [PATCH 58/61] docs(shared): add the missing language-switcher row to the CIBG gap register (RB-32) The register at libs/shared/docs/cibg-gaps.mdx had 8 rows for 9 CIBG-GAP EXTENSION markers in code. language-switcher carries a well-formed marker with no matching row, exactly as ADR-C-008 and adr-c-007.md's handoff note flag. Add the row from the component's own marker comment. Also add a small guard to check-tokens.sh (folded into check:tokens, as ADR-C-008 suggests as an optional step): it diffs the marker set in code against the register's rows and fails CI on drift. Verified working with a scratch marker file before removing it. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++++++-------- .../refactor-backlog/implementation/rb-32.md | 86 +++++++++++++++++++ libs/shared/docs/cibg-gaps.mdx | 1 + scripts/check-tokens.sh | 14 +++ 4 files changed, 136 insertions(+), 35 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 13b661c..1a9c4d8 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | **implemented** | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md new file mode 100644 index 0000000..25ff422 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md @@ -0,0 +1,86 @@ +# RB-32 — add the missing `language-switcher` row to the CIBG gap register + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` +ADR-C-008 · `99-backlog.md` RB-32 · Depends on +`implementation/adr-c-007.md` (the same file, left one row short on purpose, +filed forward as this ticket) + +## What was wrong + +ADR-0003 §Consequences' final bullet requires every `// CIBG-GAP EXTENSION:` +marker in code to have a row in `libs/shared/docs/cibg-gaps.mdx`, "so it's +auditable rather than silently drifting." `libs/shared/src/layout/language-switcher/ +language-switcher.component.ts:7-9` carries a full, well-formed marker +("Taal instellen" — no vendored Huisstijl class ships for it — see +`cibg-gaps.mdx`) but the register table had no row for it. + +## Verified before editing + +``` +grep -rln "CIBG-GAP EXTENSION" apps libs --include=*.ts | wc -l # 9 +``` + +Nine files carry the marker: `debug-state`, `language-switcher`, `wizard-shell`, +`application-link`, `placeholder-chip`, `rich-text-editor`, `skeleton`, +`spinner`, `status-badge`. The register table had 8 rows, and `language-switcher` +was the one missing — matching ADR-C-008's finding exactly, re-verified rather +than trusted from the finding's own snapshot (per this ticket's DoD point 1, and +per `adr-c-007.md`'s own note that it left this exact row for a later ticket). + +## What changed + +One row added to `libs/shared/docs/cibg-gaps.mdx`'s register table, matching the +existing rows' two-column shape (component name, closest CIBG concept, reason — +wording taken from the component's own marker comment, not invented): + +| Component | Closest CIBG concept | Why hand-rolled | +| ------------------- | -------------------- | -------------------------------------------------------------------------------------------------- | +| `language-switcher` | Taal instellen | No vendored Huisstijl class ships for it; a small hand-rolled surface built from the token bridge. | + +No other row was touched. The 8 existing rows were each re-checked against their +component's current marker comment while the file was open; none needed a change. + +## Optional CI script: taken + +ADR-C-008 flags a ~10-line `grep -l CIBG-GAP | diff`-style script folded into +`check:tokens` as an explicitly optional second step. It is genuinely small and +fits the existing script's shape, so `scripts/check-tokens.sh` gained one more +guard after its existing hardcoded-colour check: + +```bash +gap_register='libs/shared/docs/cibg-gaps.mdx' +markers=$(grep -rl 'CIBG-GAP EXTENSION' apps libs --include='*.component.ts' | xargs -n1 dirname | xargs -n1 basename | sort -u) +rows=$(grep -oP '^\| `\K[^`]+' "$gap_register" | sort -u) +missing=$(comm -23 <(echo "$markers") <(echo "$rows")) +if [ -n "$missing" ]; then + echo "$missing" + echo "FAIL: CIBG-GAP EXTENSION marker(s) with no row in $gap_register" + exit 1 +fi +echo 'OK: every CIBG-GAP EXTENSION marker has a cibg-gaps.mdx row' +``` + +The marker's component directory basename (`dirname` of the flagged file, +`.component.ts` files only) is compared against the table's backtick-quoted +first column, extracted with `grep -oP`. This matches for all 9 current +markers, including the two rows with a parenthetical suffix +(`` `wizard-shell` (error summary only) ``, `` `application-link` (non-navigating +row) ``) — the regex stops at the closing backtick, before the parenthetical. + +**Verified working, not just written.** Added a scratch component +(`libs/shared/src/ui/scratch-gap-test/scratch-gap-test.component.ts`, a single +`// CIBG-GAP EXTENSION:` line plus a dummy export) with no matching row. Ran +`bash scripts/check-tokens.sh`: failed with exit 1, printing `scratch-gap-test` +and the expected `FAIL:` line. Deleted the scratch file and its directory (`rm`, +not `git checkout` — it was never tracked). Re-ran the script: passed, exit 0. +`git status` confirms no trace of the scratch file remains. + +## Verification + +- `npm run check:tokens` (includes the new guard): passes — 9 markers, 9 rows, + after the register row was added. +- `npm run ci`: result and step count reported in the implementing agent's final + answer. +- No code changed outside `libs/shared/docs/cibg-gaps.mdx`, + `scripts/check-tokens.sh`, `99-backlog.md`, and this note — a doc-only ticket + plus its optional, self-verifying guard script. diff --git a/libs/shared/docs/cibg-gaps.mdx b/libs/shared/docs/cibg-gaps.mdx index de8849b..577d764 100644 --- a/libs/shared/docs/cibg-gaps.mdx +++ b/libs/shared/docs/cibg-gaps.mdx @@ -31,6 +31,7 @@ placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` an | `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. | | `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). | | `placeholder-chip` | n/a | No vendored inline-chip/tag class. | +| `language-switcher` | Taal instellen | No vendored Huisstijl class ships for it; a small hand-rolled surface built from the token bridge. | Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles: [...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite diff --git a/scripts/check-tokens.sh b/scripts/check-tokens.sh index 17ae022..3e86c97 100755 --- a/scripts/check-tokens.sh +++ b/scripts/check-tokens.sh @@ -18,3 +18,17 @@ if [ -n "$hits" ]; then exit 1 fi echo 'OK: no hardcoded colours in components' + +# ADR-C-008 guard: every `// CIBG-GAP EXTENSION:` marker (ADR-0003 §Consequences) +# must have a row in the register, so the table cannot drift from the code +# again without CI catching it. +gap_register='libs/shared/docs/cibg-gaps.mdx' +markers=$(grep -rl 'CIBG-GAP EXTENSION' apps libs --include='*.component.ts' | xargs -n1 dirname | xargs -n1 basename | sort -u) +rows=$(grep -oP '^\| `\K[^`]+' "$gap_register" | sort -u) +missing=$(comm -23 <(echo "$markers") <(echo "$rows")) +if [ -n "$missing" ]; then + echo "$missing" + echo "FAIL: CIBG-GAP EXTENSION marker(s) with no row in $gap_register" + exit 1 +fi +echo 'OK: every CIBG-GAP EXTENSION marker has a cibg-gaps.mdx row' From dd5fd66fb8b8ea7cec92ff77a8015f8fe3e99dcd Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 13:20:14 +0200 Subject: [PATCH 59/61] docs(backlog): mark RB-33 done after merge Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/99-backlog.md | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 108f9b9..e3a6aeb 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | implemented | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | **done** | --- From dfc6c419f44709208b8ccd31469122eb84fc6407 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 13:26:12 +0200 Subject: [PATCH 60/61] refactor(specs): replay real messages in 4 machine specs (RB-31) Four machine specs built their starting state with a hand-rolled object literal instead of replaying real Msgs through the real reduce, the exact anti-pattern ADR-0006 section 2 forbids. Three of the four also hardcoded errors: {}, a shape the reducer might never actually produce. intake.machine.spec.ts now imports the existing givenIntake from intake.testing.ts (previously used only by intake.acceptance.spec.ts). Three new one-line *.testing.ts files export the same given(reduce, initial) wrapper for registratie-wizard, besluit, and brief. Every old literal helper (answering, invullen, editingWith, loaded) is replaced by a message replay that reaches the same state. Two tests in registratie-wizard.machine.spec.ts asserted a cursor value the real reducer cannot reach (cursor 2 with no diploma chosen yet, which requires a diploma to already be set). Both are re-pointed at the reachable cursor-1 equivalent; submit() validates the whole draft regardless of cursor, so no assertion changed. Recorded in implementation/rb-31.md, not worked around. No *.machine.ts reducer was touched. All four specs pass; npm run ci is green (lint, typecheck, dep:check, format, tokens, seam, all four test suites, both app builds, backend 293/293, api-client drift). Co-Authored-By: Claude Opus 5 --- .../domain/besluit.machine.spec.ts | 47 +++-- .../app/behandeling/domain/besluit.testing.ts | 7 + .../app/brief/domain/brief.machine.spec.ts | 25 +-- .../ssp/src/app/brief/domain/brief.testing.ts | 7 + .../domain/intake.machine.spec.ts | 148 +++++++++------ .../domain/registratie-wizard.machine.spec.ts | 177 +++++++++--------- .../domain/registratie-wizard.testing.ts | 7 + .../refactor-backlog/99-backlog.md | 70 +++---- .../refactor-backlog/implementation/rb-31.md | 155 +++++++++++++++ 9 files changed, 435 insertions(+), 208 deletions(-) create mode 100644 apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts create mode 100644 apps/ssp/src/app/brief/domain/brief.testing.ts create mode 100644 apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts index b9d2f09..a7fff87 100644 --- a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts @@ -1,12 +1,7 @@ import { describe, it, expect } from 'vitest'; import { expectTag } from '@shared/testing/expect-tag'; -import { BesluitState, reduce, initial } from './besluit.machine'; - -const editingWith = (besluit: string, toelichting = ''): BesluitState => ({ - tag: 'Editing', - draft: { besluit, toelichting }, - errors: {}, -}); +import { reduce, initial } from './besluit.machine'; +import { givenBesluit } from './besluit.testing'; describe('besluit reduce', () => { it('SetField updates the draft while editing', () => { @@ -15,17 +10,23 @@ describe('besluit reduce', () => { }); it('Submit with no besluit chosen stays Editing and reports a field error', () => { - const s = reduce(editingWith(''), { tag: 'Submit' }); + const s = reduce(initial, { tag: 'Submit' }); expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy(); }); it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => { - const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' }); + const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' }); + const s = reduce(editingAfwijzen, { tag: 'Submit' }); expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy(); }); it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => { - const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const editingGoedkeuren = givenBesluit({ + tag: 'SetField', + key: 'besluit', + value: 'Goedkeuren', + }); + const s = reduce(editingGoedkeuren, { tag: 'Submit' }); expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Goedkeuren', toelichting: undefined, @@ -33,7 +34,11 @@ describe('besluit reduce', () => { }); it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => { - const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' }); + const editingAfwijzenWithToelichting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Afwijzen' }, + { tag: 'SetField', key: 'toelichting', value: ' niet erkend ' }, + ); + const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' }); expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Afwijzen', toelichting: 'niet erkend', @@ -41,24 +46,36 @@ describe('besluit reduce', () => { }); it('SubmitConfirmed maps Submitting to Submitted', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted'); }); it('SubmitFailed maps Submitting to Failed with the error', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' }); }); it('Retry re-submits a failure', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting'); }); it('Reset returns to the initial editing state', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial); }); }); diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts new file mode 100644 index 0000000..e268c38 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './besluit.machine'; + +/** Replay real `BesluitMsg`s through the real `reduce`, starting from `initial`. + Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser + `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenBesluit = given(reduce, initial); diff --git a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts index e37c46b..5ce6a1d 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts @@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b import { RichTextBlock } from '@shared/kernel/rich-text'; import { PlaceholderDef } from './placeholders'; import { BriefState, reduce } from './brief.machine'; +import { givenBrief } from './brief.testing'; const placeholders: PlaceholderDef[] = [ { key: 'naam', label: 'Naam', autoResolvable: true }, @@ -64,15 +65,15 @@ const decisions: BriefDecisions = { canRevealBigNummer: true, }; -const loaded = ( - status: BriefStatus = { tag: 'draft' }, - sections?: Brief['sections'], -): BriefState => ({ - tag: 'loaded', - brief: briefWith(status, sections), - availablePassages: lib, - decisions, -}); +// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2) +// instead of hand-assembling the 'loaded' state directly. +const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => + givenBrief({ + tag: 'BriefLoaded', + brief: briefWith(status, sections), + availablePassages: lib, + decisions, + }); const sectionBlocks = (s: BriefState, key: string) => s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; @@ -128,12 +129,12 @@ describe('brief.machine reduce', () => { it('BesluitSelected deep-copies content — later library mutation does not leak in', () => { const passage = libPassage('intro', 'kern'); // shared → offered for any besluit - const st: BriefState = { - tag: 'loaded', + const st = givenBrief({ + tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [passage], decisions, - }; + }); const s = reduce(st, besluit('positief')); // Mutate the source passage object after composition. (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; diff --git a/apps/ssp/src/app/brief/domain/brief.testing.ts b/apps/ssp/src/app/brief/domain/brief.testing.ts new file mode 100644 index 0000000..56af8ca --- /dev/null +++ b/apps/ssp/src/app/brief/domain/brief.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './brief.machine'; + +/** Replay real `BriefMsg`s through the real `reduce`, starting from `initial`. + Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser + `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenBrief = given(reduce, initial); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts index c5360f3..9987b85 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { expectTag } from '@shared/testing/expect-tag'; import { - Answers, initial, STEPS, lageUren, @@ -15,14 +14,7 @@ import { reduce, IntakeState, } from './intake.machine'; - -const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ - tag: 'Answering', - answers, - cursor, - errors: {}, - scholingThreshold, -}); +import { givenIntake } from './intake.testing'; describe('STEPS (fixed) and inline questions', () => { it('always has the same three steps', () => { @@ -31,12 +23,12 @@ describe('STEPS (fixed) and inline questions', () => { it('reveals the buitenland detail questions inline only when worked abroad', () => { // No new step; instead these fields become required within the buitenland step. - expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked - expect( - expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land, - ).toBeTruthy(); - expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) - expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1); + const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); + expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked + expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy(); + const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves) + expect(expectTag(next(domestic), 'Answering').cursor).toBe(1); }); it('reveals the scholing question only when NL-hours are below the threshold', () => { @@ -49,9 +41,13 @@ describe('STEPS (fixed) and inline questions', () => { expect(lageUren({ uren: '1500' }, 1000)).toBe(false); expect(lageUren({ uren: '1500' }, 2000)).toBe(true); // And the threshold from state flows through submit: - const lowThreshold = submit( - answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000), + const lowThresholdState = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'punten', value: '200' }, + { tag: 'SetPolicy', scholingThreshold: 2000 }, ); + const lowThreshold = submit(lowThresholdState); expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy(); }); @@ -65,18 +61,21 @@ describe('navigation', () => { }); it('Next advances once the step is valid', () => { - const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering'); + const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + const s = expectTag(next(domestic), 'Answering'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('werk'); }); it('editing an answer leaves the cursor fixed (steps never collapse)', () => { + const atWerk = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }, + { tag: 'SetAnswer', key: 'land', value: 'België' }, + { tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' }, + { tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1 + ); const edited = expectTag( - reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { - tag: 'SetAnswer', - key: 'buitenlandGewerkt', - value: 'nee', - }), + reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }), 'Answering', ); expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change @@ -87,57 +86,86 @@ describe('navigation', () => { }); it('gaNaarStap jumps back to an earlier step, clearing errors', () => { - const s = answering({ buitenlandGewerkt: 'nee' }, 2); + const s = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + { tag: 'Next' }, // cursor 1 -> 2 + ); expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0); }); it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => { - const s = answering({ buitenlandGewerkt: 'nee' }, 1); + const s = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + ); expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op - const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2)); + const atReview = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + { tag: 'Next' }, // cursor 1 -> 2 + ); + const submitting = submit(atReview); expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op }); }); describe('submit', () => { // High hours: no scholing question, so no punten is asked or collected. - const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' }; + const highUren = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + ); it('reaches Submitting ONLY with valid answers', () => { // Bad punten only blocks when scholing was followed (otherwise punten is ignored). - expect( - submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }), - ).tag, - ).toBe('Answering'); - const good = expectTag(submit(answering(complete)), 'Submitting'); + const badPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: 'x' }, + ); + expect(submit(badPunten).tag).toBe('Answering'); + const good = expectTag(submit(highUren), 'Submitting'); expect(good.data.uren).toBe(4160); expect(good.data.punten).toBeUndefined(); // not collected without scholing }); it('punten is required only when aanvullende scholing was gevolgd', () => { // scholing = ja but punten missing -> blocked on punten. - const missing = expectTag( - submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })), - 'Answering', + const scholingJaNoPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); + const missing = expectTag(submit(scholingJaNoPunten), 'Answering'); expect(missing.errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. - expect( - submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag, - ).toBe('Submitting'); + const scholingNee = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' }, + ); + expect(submit(scholingNee).tag).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { - const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); - expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered - const withScholing = expectTag( - submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }), - ), - 'Submitting', + const lowUrenNoScholing = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, ); + const noScholing = submit(lowUrenNoScholing); + expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered + const lowUrenWithScholing = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: '200' }, + ); + const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting'); expect(withScholing.data.aanvullendeScholing).toBe(true); expect(withScholing.data.punten).toBe(200); }); @@ -145,38 +173,36 @@ describe('submit', () => { it('does not require punten for a hidden question (WP-69 §6)', () => { // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // threshold — the template hides the question, so punten must not be required either. - const good = expectTag( - submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })), - 'Submitting', + const staleScholingNoPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); + const good = expectTag(submit(staleScholingNoPunten), 'Submitting'); expect(good.data.aanvullendeScholing).toBeUndefined(); }); it('drops punten when raising uren hides the question (WP-69 §6)', () => { // Same stale answer, but this time punten was also filled in while uren was low. - const good = expectTag( - submit( - answering({ - buitenlandGewerkt: 'nee', - uren: '1500', - scholingGevolgd: 'ja', - punten: '150', - }), - ), - 'Submitting', + const staleScholingWithPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: '150' }, ); + const good = expectTag(submit(staleScholingWithPunten), 'Submitting'); // ValidIntake stays honest: neither the stale 'ja' nor its punten leak through. expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.punten).toBeUndefined(); }); it('resolve maps Submitting to Submitted on a successful submit', () => { - const submitting = submit(answering(complete)); + const submitting = submit(highUren); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); }); it('resolve maps Submitting to Failed on a failed submit', () => { - const submitting = submit(answering(complete)); + const submitting = submit(highUren); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); }); diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 30aac1a..f137d23 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,9 +1,8 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; -import { initialUpload } from '@shared/domain/upload.machine'; +import { given } from '@shared/testing/machine'; import { expectTag } from '@shared/testing/expect-tag'; import { - Draft, RegistratieState, STEPS, initial, @@ -21,28 +20,50 @@ import { resolve, reduce, } from './registratie-wizard.machine'; +import { givenRegistratieWizard } from './registratie-wizard.testing'; -const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ - tag: 'Invullen', - draft: { antwoorden: {}, ...draft }, - cursor, - errors: {}, - upload: initialUpload, -}); +/** + * Every fixture below is built by replaying real `RegistratieMsg`s through the + * real `reduce` (ADR-0006 §2) — never a hand-assembled `RegistratieState` + * literal. Each helper reaches a named point in the wizard one transition at a + * time, so a spec can only assert on a state the reducer can actually produce. + */ +const toAdresValid = (): RegistratieState => + givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'post' }, + ); -const validAdres = { - straat: 'Lange Voorhout 9', - postcode: '2514 EA', - woonplaats: 'Den Haag', - correspondentie: 'post' as const, - adresHerkomst: 'brp' as const, -}; -const validDraft: Partial = { - ...validAdres, - diplomaId: 'd1', - beroep: 'Arts', - diplomaHerkomst: 'duo', -}; +const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet + +const toBeroepStepWithDiploma = (): RegistratieState => + reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }); + +const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2 + +const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' }); + +// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a +// field or choosing a diploma is never gated by cursor position, so this is a +// real, reachable 'Invullen' state at cursor 0 — matching what `submit()` +// (which validates the whole draft regardless of cursor) is exercised against +// in the tests below. +const toFullDraftAtCursor0 = (): RegistratieState => + givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }, + ); describe('STEPS (fixed)', () => { it('always has the same three steps', () => { @@ -60,46 +81,55 @@ describe('navigation', () => { }); it('Next advances once the adres step is valid', () => { - const s = expectTag(next(invullen(validAdres)), 'Invullen'); + const s = expectTag(next(toAdresValid()), 'Invullen'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('beroep'); }); it('requires a valid e-mail only when the channel is email', () => { - const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen'); + const withEmailChannel = givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'email' }, + ); + const bad = expectTag(next(withEmailChannel), 'Invullen'); expect(bad.errors.email).toBeTruthy(); const good = expectTag( - next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })), + next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })), 'Invullen', ); expect(good.cursor).toBe(1); }); it('beroep step requires a chosen diploma', () => { - const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen'); + const noDiploma = expectTag(next(toBeroepStep()), 'Invullen'); expect(noDiploma.cursor).toBe(1); expect(noDiploma.errors.diploma).toBeTruthy(); - const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen'); + const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen'); expect(withDiploma.cursor).toBe(2); }); it('Back never goes below the first step and preserves the draft', () => { expect(back(initial)).toBe(initial); - const s = expectTag(back(invullen(validDraft, 2)), 'Invullen'); + const s = expectTag(back(toControleStep()), 'Invullen'); expect(s.cursor).toBe(1); expect(s.draft.beroep).toBe('Arts'); }); it('GaNaarStap only jumps backwards', () => { - expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0); - expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected + expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0); + expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected }); }); describe('adres origin (BRP vs handmatig)', () => { it('prefillAdres flags origin brp', () => { const s = expectTag( - prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'), + prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'), 'Invullen', ); expect(s.draft.adresHerkomst).toBe('brp'); @@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => { }); it('editing a prefilled address field flips origin to handmatig', () => { - const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); + const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'); const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen'); expect(edited.draft.adresHerkomst).toBe('handmatig'); }); it('typing an address with no BRP prefill yields handmatig', () => { - const s = expectTag(setField(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen'); + const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen'); expect(s.draft.adresHerkomst).toBe('handmatig'); }); it('editing the e-mail field does not change the address origin', () => { - const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); + const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'); const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen'); expect(edited.draft.adresHerkomst).toBe('brp'); }); it('a manually entered address still submits (only manual diploma is gated)', () => { - const s = submit( - invullen({ - straat: 'Kerkstraat 1', - postcode: '1234 AB', - woonplaats: 'Utrecht', - correspondentie: 'post', - adresHerkomst: 'handmatig', - diplomaId: 'd1', - beroep: 'Arts', - diplomaHerkomst: 'duo', - }), + const manualAdres = givenRegistratieWizard( + { tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' }, + { tag: 'SetField', key: 'postcode', value: '1234 AB' }, + { tag: 'SetField', key: 'woonplaats', value: 'Utrecht' }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }, ); - const indienen = expectTag(s, 'Indienen'); + const indienen = expectTag(submit(manualAdres), 'Indienen'); expect(indienen.data.adresHerkomst).toBe('handmatig'); }); }); describe('kiesDiploma', () => { it('derives the beroep from the chosen diploma and flags origin duo', () => { - const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen'); + const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen'); expect(s.draft.diplomaId).toBe('d9'); expect(s.draft.beroep).toBe('Verpleegkundige'); expect(s.draft.diplomaHerkomst).toBe('duo'); @@ -152,7 +177,7 @@ describe('kiesDiploma', () => { describe('policy questions (geldigheidsvragen)', () => { it('a diploma with questions blocks Next until they are answered', () => { - let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']); + let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']); const blocked = expectTag(next(s), 'Invullen'); expect(blocked.cursor).toBe(1); expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy(); @@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => { }); it('validateAll keeps only the answers to the questions that applied', () => { - let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']); + // DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any + // diploma was chosen. That combination cannot occur in the real reducer — + // advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be + // set. Replayed here at cursor 1 instead; submit() validates the whole draft + // regardless of cursor, so the assertion below is unaffected. + let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']); s = setAntwoord(s, 'nl-taalvaardigheid', 'ja'); s = setAntwoord(s, 'stale', 'x'); // not in vraagIds const done = expectTag(submit(s), 'Indienen'); @@ -173,14 +203,16 @@ describe('manual diploma fallback', () => { const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting']; it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => { - const s = expectTag(kiesHandmatig(invullen(validAdres, 1), maxIds), 'Invullen'); + const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen'); expect(s.draft.diplomaHerkomst).toBe('handmatig'); expect(s.draft.beroep).toBeUndefined(); expect(s.draft.vraagIds).toEqual(maxIds); }); it('requires a declared beroep + all maximal questions before submit', () => { - let s = kiesHandmatig(invullen(validAdres, 2), maxIds); + // DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination + // as above. Replayed at cursor 1; submit() is cursor-agnostic. + let s = kiesHandmatig(toBeroepStep(), maxIds); expect(submit(s).tag).toBe('Invullen'); // no beroep declared s = declareerBeroep(s, 'Fysiotherapeut'); expect(submit(s).tag).toBe('Invullen'); // questions unanswered @@ -193,11 +225,11 @@ describe('manual diploma fallback', () => { describe('submit', () => { it('stays in Invullen when the draft is incomplete (no diploma)', () => { - expect(submit(invullen(validAdres)).tag).toBe('Invullen'); + expect(submit(toAdresValid()).tag).toBe('Invullen'); }); it('reaches Indienen with a complete, valid draft, carrying its data', () => { - const good = expectTag(submit(invullen(validDraft)), 'Indienen'); + const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen'); expect(good.data.beroep).toBe('Arts'); expect(good.data.adres.postcode).toBe('2514 EA'); expect(good.data.adresHerkomst).toBe('brp'); @@ -205,43 +237,18 @@ describe('submit', () => { it('resolve maps Indienen to Ingediend with the referentie', () => { const ingediend = expectTag( - resolve(submit(invullen(validDraft)), ok('BIG-2026-001')), + resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')), 'Ingediend', ); expect(ingediend.referentie).toBe('BIG-2026-001'); }); it('resolve maps Indienen to Mislukt on a failed submit', () => { - expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt'); + expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt'); }); }); describe('reduce (message-driven happy path)', () => { - // Each helper replays real messages through the real reducer up to the named - // point — no hand-assembled state literal — so each test below Givens its own - // starting point independently, one transition at a time. - const toBeroepStep = (): RegistratieState => { - let s: RegistratieState = initial; - s = reduce(s, { - tag: 'PrefillAdres', - straat: 'Lange Voorhout 9', - postcode: '2514 EA', - woonplaats: 'Den Haag', - }); - s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); - return reduce(s, { tag: 'Next' }); - }; - const toControleStep = (): RegistratieState => { - const s = reduce(toBeroepStep(), { - tag: 'KiesDiploma', - diplomaId: 'd1', - beroep: 'Arts', - vraagIds: [], - }); - return reduce(s, { tag: 'Next' }); - }; - const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' }); - it('adres and correspondentie set, Next advances from adres to beroep', () => { // Given the initial wizard. // When the adres is prefilled, correspondentie chosen, and Next dispatched... @@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => { }); it('SubmitFailed moves Indienen to Mislukt', () => { - const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { + const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => { }); it('Retry returns Mislukt to Indienen with the same data', () => { - const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { + const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => { it('routes Upload messages through the upload reducer', () => { const s = expectTag( - reduce(invullen(validDraft), { + reduce(toFullDraftAtCursor0(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }), @@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => { }); it('blocks the beroep step until a required category is satisfied', () => { - let s = reduce(invullen(validDraft, 1), { + let s = reduce(toBeroepStepWithDiploma(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); @@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => { }); it('includes delivery refs in the submitted data', () => { - let s = reduce(invullen(validDraft), { + let s = reduce(toFullDraftAtCursor0(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts new file mode 100644 index 0000000..2949ccf --- /dev/null +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './registratie-wizard.machine'; + +/** Replay real `RegistratieMsg`s through the real `reduce`, starting from + `initial`. Pure TS only (no Angular) — domain/ stays framework-free + (dependency-cruiser `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenRegistratieWizard = given(reduce, initial); diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 13b661c..c619ec3 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | **implemented** | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md new file mode 100644 index 0000000..704ced1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md @@ -0,0 +1,155 @@ +# RB-31 — replay real messages in the four hand-rolling machine specs + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-010 · +`99-backlog.md` RB-31 + +RB-31 replaces four hand-rolled state-literal fixtures with `given(reduce, initial)` +replays, per ADR-0006 §2 ("no object is built directly; a fixture is the result of +running real `Msg`s through the real `reduce`"). This is a fixture-construction change +only. No `*.machine.ts` production file was touched. + +## What was wrong + +Four machine specs built their starting `Answering`/`Invullen`/`Editing`/`loaded` state +with a local object-literal helper instead of replaying messages: + +- `intake.machine.spec.ts` — `answering(answers, cursor, scholingThreshold)` hardcoded + `errors: {}`. `intake.testing.ts` (exporting `givenIntake`) already existed next to + it and was already correct, but was imported only by `intake.acceptance.spec.ts`. +- `registratie-wizard.machine.spec.ts` — `invullen(draft, cursor)` hardcoded `errors: {}` + and `upload: initialUpload`. +- `besluit.machine.spec.ts` — `editingWith(besluit, toelichting)` hardcoded `errors: {}`. +- `brief.machine.spec.ts` — `loaded(status, sections)` built the `'loaded'` tag object + directly (no `errors` field on this union, so this one did not hardcode `errors: {}`, + but it still skipped the reducer). + +## What changed + +| File | Change | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts` | Removed the local `answering(...)` helper. Every fixture is now built with the existing `givenIntake` (imported from `intake.testing.ts`), matching `intake.acceptance.spec.ts`'s own style. | +| `apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts` | New. One-liner: `export const givenRegistratieWizard = given(reduce, initial)`. | +| `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` | Removed the local `invullen(...)` helper and the now-unused `validAdres`/`validDraft`/`Draft`/`initialUpload` fixtures. Added module-level replay helpers (`toAdresValid`, `toBeroepStep`, `toBeroepStepWithDiploma`, `toControleStep`, `toIndienen`, `toFullDraftAtCursor0`) built from `givenRegistratieWizard` + `reduce`, reused across every `describe` block (the file's pre-existing `reduce (message-driven happy path)` block already had three of these, scoped locally; they are now module-level and shared, removing the duplication). | +| `apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts` | New. One-liner: `export const givenBesluit = given(reduce, initial)`. | +| `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts` | Removed the local `editingWith(...)` helper. Every fixture is now built with `givenBesluit` (or, for the empty-draft case, the machine's own `initial` — see below). | +| `apps/ssp/src/app/brief/domain/brief.testing.ts` | New. One-liner: `export const givenBrief = given(reduce, initial)`. | +| `apps/ssp/src/app/brief/domain/brief.machine.spec.ts` | Rewrote the `loaded(...)` helper to replay a real `BriefLoaded` message through `givenBrief` instead of building the `'loaded'` tag object directly. Also converted one further inline `BriefState` literal in the "deep-copies content" test to the same replay (same anti-pattern, same file, not named individually by the finding's evidence list but visibly the same shape — see "Beyond the letter of the finding" below). | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-31's status cell: `open` → `implemented`. | + +## Message sequence used per machine + +### `intake.machine.spec.ts` + +Every fixture in this file has `cursor` 0, 1, or 2 and the default or an overridden +`scholingThreshold`. All are built as direct sequences of `SetAnswer`/`Next`/`SetPolicy` +through `givenIntake`, mirroring `intake.acceptance.spec.ts`'s own explicit style (no new +generic wrapper was added — the finding's own resolution is "wire the spec to +`givenIntake`", not "invent a second helper"). Representative sequences: + +- Cursor 0, plain answers (most tests): `givenIntake({SetAnswer buitenlandGewerkt}, {SetAnswer uren}, ...)`. +- Cursor 0 with an overridden threshold: adds a trailing `{tag:'SetPolicy', scholingThreshold: N}`. +- Cursor 1 ("editing an answer leaves the cursor fixed"): `SetAnswer buitenlandGewerkt=ja`, + `SetAnswer land`, `SetAnswer buitenlandseUren`, `Next` (buitenland step now valid -> + cursor 1), then the edit under test. +- Cursor 2 ("gaNaarStap jumps back..."): the above sequence continued with + `SetAnswer uren=4160`, `Next` (werk step valid -> cursor 2). + +No drift found here: `intake.testing.ts` already existed correctly, and every one of the +nine cursor/threshold combinations the old literal used turned out to be reachable by a +real message sequence. + +### `registratie-wizard.machine.spec.ts` + +- `toAdresValid()` = `PrefillAdres(straat, postcode, woonplaats)`, `SetCorrespondentie('post')` + — cursor 0, matches the old `invullen(validAdres)`. +- `toBeroepStep()` = `reduce(toAdresValid(), Next)` — cursor 0 -> 1, no diploma. Matches + `invullen(validAdres, 1)`. +- `toBeroepStepWithDiploma()` = `reduce(toBeroepStep(), KiesDiploma('d1','Arts',[]))` — + cursor 1, diploma set. Matches `invullen(validDraft, 1)`. +- `toControleStep()` = `reduce(toBeroepStepWithDiploma(), Next)` — cursor 1 -> 2. Matches + `invullen(validDraft, 2)`. +- `toFullDraftAtCursor0()` = `PrefillAdres`, `SetCorrespondentie('post')`, `KiesDiploma(...)`, + never advancing the cursor — matches `invullen(validDraft)` (cursor 0). `SetField`/ + `SetCorrespondentie`/`KiesDiploma` carry no cursor gate, so setting every field before + ever pressing `Next` is a genuinely reachable cursor-0 state with a complete draft. +- `invullen({})` (five call sites) is exactly the machine's own `initial` value + (`{tag:'Invullen', draft:{antwoorden:{}}, cursor:0, errors:{}, upload:initialUpload}`) + — replaced with `initial` directly, no message needed. + +### `besluit.machine.spec.ts` + +- `editingWith('')` is exactly `initial` (`draft:{besluit:'',toelichting:''}`) — replaced + with `initial` directly. +- `editingWith('Afwijzen')` / `editingWith('Goedkeuren')` = `givenBesluit({SetField besluit})`. +- `editingWith('Afwijzen', ' niet erkend ')` = `givenBesluit({SetField besluit=Afwijzen}, {SetField toelichting=' niet erkend '})`. +- Every `Submitting`/`Failed` fixture is now `givenBesluit({SetField besluit}, {Submit})` + composed further with `reduce(..., {SubmitFailed}/{Retry}/{Reset})`. + +### `brief.machine.spec.ts` + +- `loaded(status, sections)` = `givenBrief({tag:'BriefLoaded', brief: briefWith(status, sections), availablePassages: lib, decisions})`. + This is a 1:1 replacement: the `'BriefLoaded'` reducer case sets exactly + `{tag:'loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions}` + — the same three fields the old literal built by hand, with the same values. No drift. + +## Drift found + +Two tests in `registratie-wizard.machine.spec.ts` asserted against a cursor value the +real reducer cannot reach: + +- `'validateAll keeps only the answers to the questions that applied'` built + `invullen(validAdres, 2)` then called `kiesDiploma(...)` on it — i.e. a wizard already + at cursor 2 (`controle`) with **no diploma chosen yet**. That is impossible by replay: + advancing past `beroep` (cursor 1 -> 2) requires `validateStep('beroep', ...)` to pass, + which requires `diplomaId` and `beroep` to already be set. The literal encoded a state + the reducer can never produce. +- `'requires a declared beroep + all maximal questions before submit'` had the same + problem: `invullen(validAdres, 2)` then `kiesHandmatig(...)`, which leaves `beroep` + `undefined` — again a cursor-2 state that could never have been reached via `Next`. + +In both cases the cursor value is not actually load-bearing for the test: `submit()` +calls `validateAll(s.draft, s.upload)`, which validates every step regardless of +`s.cursor`. Both tests were re-pointed at the reachable **cursor-1** equivalent +(`toBeroepStep()` then `kiesDiploma`/`kiesHandmatig`), with an inline `// DRIFT (see +rb-31.md)` comment at each site. No assertion changed — both tests still check the same +`submit(...)` outcome on the same field values; only the now-irrelevant cursor number +in the starting fixture moved from an unreachable 2 to a reachable 1. + +No other named state, across any of the four machines, turned out to be unreachable. + +## Beyond the letter of the finding + +`brief.machine.spec.ts`'s `'BesluitSelected deep-copies content...'` test built a second, +separate `BriefState` literal inline (not through the `loaded(...)` helper the finding +cited) — same anti-pattern, same file, not itself named in ADR-C-010's evidence list. +Since it sits inside one of the four files already being brought into line, and the fix +is the identical one-line `BriefLoaded` replay, it was converted too rather than left as +a residual violation in a file this ticket otherwise fixed. No other spec, in any other +file, was touched. + +## `intake.acceptance.spec.ts` — confirmed unaffected + +`intake.testing.ts` and its `givenIntake` export were not modified. The acceptance spec +still imports and uses `givenIntake` exactly as before; it was not read or edited by +this ticket beyond confirming (by running it) that it still passes. + +## Verification + +- `npx ng test ssp`: 44 test files, 276 tests, all passing (includes + `intake.machine.spec.ts`, `intake.acceptance.spec.ts`, + `registratie-wizard.machine.spec.ts`, `brief.machine.spec.ts`, and every other ssp + spec, unmodified ones included). +- `npx ng test behandelportal`: 6 test files, 37 tests, all passing (includes + `besluit.machine.spec.ts`). +- `npx eslint` on all seven touched/added files: clean. +- `npx prettier --check` on all seven touched/added files: clean (one file needed + `--write` once, then verified clean). +- `npm run ci`: see the commit message / session report for the exit code and step + count. + +## What this ticket did not touch + +No `*.machine.ts` reducer or production domain file was changed — every fixture change +is confined to the four `*.spec.ts` files and the three new `*.testing.ts` files listed +above. No other machine spec (including `change-request.machine.spec.ts`, which the +finding notes already honours the idiom inline) was touched. From c30d5ec5a5adf4cf2167b72a89ab80f12c1f5885 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 28 Aug 2026 13:33:09 +0200 Subject: [PATCH 61/61] docs(backlog): CD batch 6 complete, close the refactor backlog arc All three tickets RB-31 to RB-33 merged, one commit per ticket. RB-31 found a genuine ADR-0006 violation: two registratie-wizard tests asserted a state the real reducer cannot produce. RB-32 closed ADR-0003's own predicted failure mode with a permanent CI drift guard rather than a one-time fix. RB-33 chose deletion over adoption for an unused test helper, since manufacturing a first caller would have removed no real duplication. This closes the CD implementation phase. All 33 code tickets and the four gated ADR-fixes are merged; npm run ci is green after every merge in this arc, each verified independently rather than trusting an agent's own report. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog-setup/refactor-backlog/_status.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md index 41a3be7..27cbb80 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -14,6 +14,10 @@ ## Phase 3 — implementation +**All six batches complete, 2026-08-28. All 33 code tickets (RB-01..RB-33) and the four gated ADR-fixes (ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009) merged to `refactor/adr-c-006-shared-route-guards`, one commit per ticket. `npm run ci` green after every merge, verified independently before trusting any agent's own report.** Every batch carrying a **SIGN-OFF** ticket shipped only after the architect approval recorded 2026-08-27 (HALT lifted). Three of the four ADR-fixes required a matching CLAUDE.md correction (§2 once, §4 twice); all three landed in the same diff as their ADR amendment, per CLAUDE.md's own precedence rule. + +Across the six batches, several tickets turned out to be factually wrong, incomplete, or overstated relative to the actual code, and every one was reported rather than silently patched over — among them: two ADR-fixes (ADR-C-001's stale out-of-scope bullet, ADR-C-007 catching only half of its own finding), RB-18 (real scope one endpoint, not the several the stale line numbers implied), RB-23 (an unmentioned second `GetOrCreate` call site forced onto the same contract change), RB-25 (overstated which methods the missing token actually blocked), RB-27 (correctly declined to extract abort-vs-error into a signature that cannot express it, and declined an optional move outside its stated scope), RB-28 (TE-006's "cannot test the success case" claim was already false when written), and RB-31 (found a genuinely unreachable state — cursor 2 with no diploma chosen — that the old hand-rolled fixture had been asserting). None of these weakened a ticket; each was implemented correctly once the discrepancy was named. + | CD batch | Tickets | Status | Notes | | -------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | @@ -21,7 +25,7 @@ | 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | | 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. | | 5 | RB-24..RB-30 | **complete** | All seven merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Ran as three waves, not the two the backlog implied: RB-24 rewrites imports in `brief.store.ts` and `org-template.store.ts`, which are two of RB-28's three targets — a dependency the backlog's "25/26/27 depend on 24" note never mentioned. **A** = RB-24 alone (the move), then **A2** = RB-29 + RB-30 in parallel (backend, no file overlap with the move or each other), **B** = RB-25 + RB-26 + RB-28 in parallel once RB-24 landed, **C** = RB-27 alone last, since it depends on RB-25's transport token. **RB-24 expanded its own scope, correctly.** Deleting the dependency-cruiser carve-out — the ticket's own acceptance criterion — exposed a second, real `ui-not-infrastructure` violation the old path had hidden: three UI components injected `UploadAdapter` for nothing but a one-line wrapper over its own exported pure function. The dispatch prompt said to report a second violation, not fix it; the agent judged this one was on the critical path (`dep:check` cannot pass with the carve-out gone otherwise) and fixed it minimally, reusing the existing pure function. Reviewed before merging — sound. **Two more findings were shown to be stale or overstated, on top of the two ADR-fixes found wrong and RB-18/RB-23's incompleteness from batch 4 — nine total now.** RB-25 found TE-003 overstated its own blocker: of the four methods named, only `upload()` and `cancel()` were actually unfakeable through the missing token — `delete()`/`pollReturning()` already went through the exported `UploadAdapter`. RB-28 found TE-006 already false at the time it was written: `brief.store.spec.ts` already had a `previewLetter` success test via jsdom's spyable `URL`/`window` stubs, contradicting the finding's "cannot test the success case" claim — the overall three-site diagnosis still held and was shipped as instructed. **RB-26 made one real design call**, reviewed before merging: `planFileSelection` must return `UploadMsg[]` per its literal signature, but an accepted file's real `localId` needs `crypto.randomUUID()`, which the ticket itself keeps in the controller. It ships a placeholder `localId: ''` discriminated by `.type` alone and never dispatched — verified the index alignment holds for both the multiple-rejection short-circuit and the per-file path. **RB-27 left one thing unextracted, correctly**: TE-005 lumped abort-vs-error disambiguation into the same extraction as `uploadOutcome`, but abort fires on a different event with no `status`/`responseText` at all — it structurally cannot fit the proposed signature. Left in place as a one-line ternary. The optional `currentScenario()` move into `KeepaliveTransport` was also correctly declined — it would have crossed into `upload-shell.service.ts`, outside this ticket's stated single-file scope. **End state of `libs/shared/upload`** (now split across proper layers): every layer that can hold pure logic has one and is spec'd — `upload.machine.ts` (domain, `planFileSelection`), `upload-shell.service.ts` (application, the `UPLOAD_TRANSPORT` seam), `upload-controller.ts` (application), `upload.adapter.ts` (infrastructure, `uploadOutcome`). Only the XHR/DOM boundary itself stays untested by design — TE-005 was explicit that abstracting `XMLHttpRequest` away is not wanted, since the file documents why XHR (not `fetch`) is required. | -| 6 | RB-31, RB-32, RB-33 | not started | | +| 6 | RB-31, RB-32, RB-33 | **complete** | All three merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Dispatched as one wave — no file overlap at all (four machine specs in three apps, one docs file, one testing helper plus its one call site). **RB-31 found a real ADR-0006 violation, not a false alarm.** Two `registratie-wizard` tests asserted a state — cursor 2, no diploma chosen — that the real reducer cannot produce, since advancing past `beroep` (cursor 1→2) requires `KiesDiploma`/`KiesHandmatig` to have already run. This is exactly what forcing fixtures through message replay is for: a hand-rolled literal let an impossible state sit in the suite undetected. Fixed by replaying to cursor 1 and applying the diploma choice there instead; `submit()` validates the whole draft regardless of cursor, so the assertions are byte-identical to before — reviewed the diff before merging to confirm the old and new test bodies check the same thing. `intake.machine.spec.ts` also had an existing, correct `intake.testing.ts` sitting unused in its own folder, imported only by the acceptance spec — now wired to both. **RB-32 added the missing `language-switcher` row and took the ticket's explicitly-optional second step**: a ~14-line drift guard in `check-tokens.sh` that diffs every `CIBG-GAP EXTENSION` marker's component directory against the register's rows and fails naming what's missing. Verified the regex before trusting it — two existing rows carry parenthetical suffixes (`wizard-shell (error summary only)`) and the extraction correctly captures only the backtick-quoted name. This closes ADR-0003's own predicted failure mode ("if markers and this table drift, trust the code and fix the table") permanently rather than fixing it once more. **RB-33 made the real adopt-or-delete call the finding asked for, and chose delete.** `unwrapOk` had zero consumers anywhere in the repo since it shipped; manufacturing a first caller purely to satisfy the ticket would have removed no actual duplication, since there was only one occurrence to begin with. Deleted the helper and its doc mention; left the one candidate call site's inline guard alone, since it already satisfies ADR-0006 §3's real requirement (never a cast). | | ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | **Standing caveat for every batch:** `dotnet test` reports one failure,