docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Refactoring backlog — automated setup
|
||||
|
||||
## What's in this package
|
||||
|
||||
```
|
||||
refactor-backlog-setup/
|
||||
setup.sh ← run this once, from the root of the target repo
|
||||
agents/ ← source prompts (edit these if you need to tweak
|
||||
scope/wording before running setup.sh)
|
||||
_persistence-protocol.md
|
||||
00-baseline.prompt.md
|
||||
01-readability.prompt.md
|
||||
02-testability.prompt.md
|
||||
03-ddd-hexagonal.prompt.md
|
||||
04-cqrs-light.prompt.md
|
||||
05-bdd.prompt.md
|
||||
06-adr-conformance.prompt.md
|
||||
07-bio2-compliance.prompt.md
|
||||
08-consolidation.prompt.md
|
||||
09-implementation.prompt.md (template — one TICKET-ID per Phase 3 dispatch)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Copy this `refactor-backlog-setup/` folder into the root of the target repo
|
||||
(or reference it via a relative path).
|
||||
2. Edit anything in `agents/` if scope/exclusions need repo-specific detail
|
||||
(e.g. exact module paths, ADR folder location) — the prompts currently use
|
||||
the defaults agreed in the design conversation.
|
||||
3. Run:
|
||||
```
|
||||
bash refactor-backlog-setup/setup.sh
|
||||
```
|
||||
This creates `./refactor-backlog/` with:
|
||||
- `_status.md` initialized, all agents `not_started`
|
||||
- `00-baseline.md` through `07-bio2-compliance.md` initialized with headers
|
||||
- `99-backlog.md` empty, ready for Consolidation
|
||||
- `implementation/` folder for Phase 3 notes
|
||||
- `final-prompts/` — every agent prompt with the persistence protocol
|
||||
already merged in. **These are the exact prompts to dispatch — no manual
|
||||
copy-paste needed.**
|
||||
|
||||
## Dispatch order
|
||||
|
||||
1. Dispatch `final-prompts/00-baseline.prompt.md` (Opus). Wait for
|
||||
`_status.md` → baseline: complete.
|
||||
2. Dispatch the 7 Phase 1 prompts in parallel (Opus): `01` through `07`.
|
||||
Each checks its own dependency in `_status.md` before starting.
|
||||
3. Once all 7 show `complete`, dispatch `final-prompts/08-consolidation.prompt.md`
|
||||
(Opus). It writes `99-backlog.md` and halts for human approval — check the
|
||||
file for any `ADR-fix` or BIO2-flagged tickets before proceeding.
|
||||
4. For each approved ticket, copy `final-prompts/09-implementation.prompt.md`,
|
||||
fill in `TICKET-ID:`, dispatch (Sonnet). Run tickets in parallel within a CD
|
||||
batch, sequential across batches, per the `Depends on` column in
|
||||
`99-backlog.md`.
|
||||
|
||||
## Re-running / resuming
|
||||
|
||||
Safe to re-run `setup.sh` only on a fresh workspace — it does not check for an
|
||||
existing `./refactor-backlog/` and will overwrite `_status.md` and the phase
|
||||
output files. If a run is already in progress, don't re-run `setup.sh`; just
|
||||
re-dispatch the relevant `final-prompts/*.prompt.md` — each agent reads
|
||||
`_status.md` and its own output file first and resumes from where it left off.
|
||||
@@ -0,0 +1,22 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/00-baseline.md
|
||||
DEPENDS ON: none
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
ROLE: Metrics Baseline Agent
|
||||
|
||||
Before any refactoring suggestions, establish a baseline for the scoped codebase:
|
||||
- Test coverage (line/branch) per module, .NET and Angular separately.
|
||||
- Cyclomatic complexity per method/function (flag >10).
|
||||
- Duplication percentage (tool-based, e.g. jscpd/SonarQube if configured).
|
||||
- Dependency graph / layering violations (existing static analysis if present).
|
||||
- Count and location of existing CQRS-light and hexagonal architecture patterns
|
||||
already in use (so later agents compare against actual current state, not
|
||||
assumed absence).
|
||||
|
||||
Output: a metrics table per module, plus a short list of modules ranked
|
||||
worst-to-best on each metric. This file is fixed input to every Phase 1 agent —
|
||||
no agent may propose a change without citing which baseline metric it improves.
|
||||
@@ -0,0 +1,15 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/01-readability.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: Readability Agent
|
||||
|
||||
Junior = fluency in language constructs, not domain knowledge. Assume familiarity
|
||||
with generics, async/await, LINQ, DI, RxJS operators, TS type system — do NOT flag
|
||||
idiomatic use of these as "unreadable". Flag only: unclear naming, methods/components
|
||||
exceeding [N] lines, nesting >3 levels, magic values, misleading types, missing guard
|
||||
clauses. Cite baseline complexity score per finding.
|
||||
@@ -0,0 +1,14 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/02-testability.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: Testability Agent
|
||||
|
||||
Flag constructs that block unit testing without excessive mocking: static/singleton
|
||||
dependencies, hidden I/O, constructors doing work, mixed pure/impure logic. Cite
|
||||
baseline coverage gap per finding. Propose the minimal seam needed (interface
|
||||
extraction, pure function split) — not a rewrite.
|
||||
@@ -0,0 +1,16 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/03-ddd-hexagonal.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: DDD/Hexagonal Agent
|
||||
|
||||
Target architecture: hexagonal (ports/adapters), already partially present in the
|
||||
codebase per baseline.md — treat that as the pattern to extend, not reinvent. Do NOT
|
||||
introduce hexagonal structure into modules where it is absent; only propose closing
|
||||
gaps where the pattern is already started. Flag anemic domain models, domain logic
|
||||
leaked into controllers/services/components, primitive obsession, missing ubiquitous
|
||||
language.
|
||||
@@ -0,0 +1,15 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/04-cqrs-light.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: CQRS-light Agent
|
||||
|
||||
Target: command/query separation at the application-service level (not event
|
||||
sourcing or separate read models unless already present per baseline.md). Only
|
||||
extend existing CQRS-light patterns — do not introduce the pattern into modules
|
||||
where it's absent. Identify handlers/services mixing reads and writes within
|
||||
modules that already show the pattern elsewhere.
|
||||
@@ -0,0 +1,14 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/05-bdd.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: BDD Agent
|
||||
|
||||
Check whether existing tests express behavior in domain/business language mapped
|
||||
to acceptance criteria, or only technical steps. Flag test names/structure gaps.
|
||||
Do not propose new BDD tooling if none is present — flag as a separate structural
|
||||
item instead, not a per-module ticket.
|
||||
@@ -0,0 +1,16 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/06-adr-conformance.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: ADR-Conformance Agent
|
||||
|
||||
Read all ADRs/docs in the repo. Compare code against each. Two outcomes per
|
||||
deviation:
|
||||
(a) code violates a correct ADR → refactoring ticket, cite ADR.
|
||||
(b) ADR itself appears outdated/wrong given current code or constraints → propose
|
||||
an ADR amendment as a separate ticket type ("ADR-fix"), with rationale — not
|
||||
a code ticket.
|
||||
@@ -0,0 +1,24 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/07-bio2-compliance.md
|
||||
DEPENDS ON: 00-baseline.md (complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here]
|
||||
|
||||
AGENT: BIO2/Compliance Agent
|
||||
|
||||
No explicit control list supplied — using the following BIO2/ISO 27002:2022
|
||||
controls, selected for privacy and security relevance. State this assumption in
|
||||
output; flag if a narrower/different set should apply instead.
|
||||
|
||||
- Access control (9.1, 9.2, 9.4): authorization checks, RBAC, least privilege.
|
||||
- Logging & monitoring (8.15, 8.16): audit trails, esp. BIG-register/DUO data access.
|
||||
- Data classification & handling (5.12, 5.13): BSN, health data, AVG-sensitive fields.
|
||||
- Cryptography (8.24): encryption at rest/in transit.
|
||||
- Secure development (8.25, 8.28, 8.29): secure coding, review, security testing gates.
|
||||
- Change control (8.32): deployment register / change approval exceptions.
|
||||
- Input validation (8.26): boundary validation on public-facing forms/APIs.
|
||||
|
||||
Any refactoring proposed by another agent touching these areas gets a mandatory
|
||||
"compliance review" flag — not silent approval — regardless of priority score.
|
||||
@@ -0,0 +1,35 @@
|
||||
MODEL: Opus
|
||||
OUTPUT FILE: /refactor-backlog/99-backlog.md
|
||||
DEPENDS ON: 01-readability.md through 07-bio2-compliance.md (all complete)
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here — file-level: re-run only if a
|
||||
Phase 1 file changed since last run]
|
||||
|
||||
ROLE: Consolidation & CD-Sequencing Agent
|
||||
|
||||
Input: all Phase 1 files (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.
|
||||
@@ -0,0 +1,33 @@
|
||||
MODEL: Sonnet
|
||||
OUTPUT: status update in /refactor-backlog/99-backlog.md +
|
||||
/refactor-backlog/implementation/[ticket-id].md
|
||||
DEPENDS ON: ticket status = not_started, no unresolved compliance/ADR-fix flag,
|
||||
all tickets in "Depends on" column = implemented or needs_review
|
||||
|
||||
---
|
||||
|
||||
[Insert contents of _persistence-protocol.md here — scoped to one ticket, not a
|
||||
module list]
|
||||
|
||||
AGENT: Implementation Agent
|
||||
|
||||
Input: one ticket from 99-backlog.md (fill in TICKET-ID below), the Phase 1
|
||||
file(s) that produced it, and 00-baseline.md.
|
||||
|
||||
TICKET-ID: [fill in before dispatching this agent]
|
||||
|
||||
Scope discipline:
|
||||
- Implement exactly the change described in the ticket. No scope expansion, no
|
||||
incidental fixes.
|
||||
- If the ticket is ambiguous or underspecified for implementation, do not guess —
|
||||
write a blocker note to the ticket's status and stop.
|
||||
- Do not modify architecture/pattern decisions (hexagonal boundaries, CQRS-light
|
||||
structure) — those are Opus-level design calls already made in the ticket. If
|
||||
implementation reveals the design call was wrong, flag back to Consolidation
|
||||
rather than deciding unilaterally.
|
||||
- Tickets touching a BIO2-flagged item are blocked until human compliance
|
||||
sign-off is recorded in the ticket status — do not implement first and flag
|
||||
after.
|
||||
|
||||
Update ticket status in 99-backlog.md: not_started → in_progress → implemented
|
||||
→ needs_review. Append implementation notes to implementation/[ticket-id].md.
|
||||
@@ -0,0 +1,25 @@
|
||||
PERSISTENCE & RESUME PROTOCOL
|
||||
|
||||
Before starting work:
|
||||
1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run.
|
||||
2. If "in_progress", read your own output file. Treat modules already listed as done.
|
||||
Resume from "Last module processed" + 1.
|
||||
3. If "not_started", confirm your dependencies show "complete" in _status.md. If not,
|
||||
stop and report a blocking dependency instead of guessing.
|
||||
|
||||
While working:
|
||||
4. Append findings incrementally, one module at a time. After each module, update
|
||||
_status.md: "Last module processed" and "Last updated".
|
||||
5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs.
|
||||
6. If interrupted, the file + status row is the full recovery state.
|
||||
|
||||
On completion:
|
||||
7. Mark your _status.md row "complete" only once every module in scope has a
|
||||
corresponding section in your output file.
|
||||
|
||||
Every output file starts with:
|
||||
## Scope: [modules covered]
|
||||
## Status: [not_started | in_progress | complete]
|
||||
## Last updated: [timestamp]
|
||||
## Depends on: [file(s)]
|
||||
## ---
|
||||
@@ -0,0 +1,547 @@
|
||||
## Scope: apps/ssp (6 contexts), apps/behandelportal (3 contexts), libs/shared (11 layers), libs/beheer (5 layers), backend/src/BigRegister.Api (6 folders), backend/tests
|
||||
|
||||
## Status: complete
|
||||
|
||||
## Last updated: 2026-08-26
|
||||
|
||||
## Depends on: none
|
||||
|
||||
## ---
|
||||
|
||||
# 00 — Metrics Baseline
|
||||
|
||||
Fixed input to every Phase 1 agent (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/<app>/src libs --config .dependency-cruiser.<app>.js --metrics --output-type metrics` | exact |
|
||||
|
||||
`tools/baseline-scan.mjs` is new and lives in this workspace so the numbers reproduce.
|
||||
It is a crude text scan: line-window hashing for duplication (not token-based like
|
||||
jscpd), regex method detection for C# (does not understand expression-bodied members or
|
||||
nested lambdas). Deterministic, zero-install, good enough to answer "did this move".
|
||||
Upgrade path if a ticket ever needs more precision: jscpd, and a Roslyn analyzer.
|
||||
|
||||
**Excluded as generated** (counted once, then excluded everywhere):
|
||||
`libs/shared/src/infrastructure/api-client.ts` (2372 lines, NSwag) and
|
||||
`backend/.../Data/Migrations/**` (17 files, 2690 lines, EF Core).
|
||||
|
||||
**LCOV aggregation rule.** The four FE project runs overlap — `libs/shared` files appear
|
||||
in `ssp`'s lcov as well as `shared`'s. Each file is attributed to its owning module and
|
||||
the **best** result across the four runs is taken; a shared file exercised by an app's
|
||||
specs is genuinely covered.
|
||||
|
||||
**Protocol deviation, stated for the record.** The measurement runs are repo-global (one
|
||||
coverage run, one depcruise run) and cannot be executed module-by-module; only the
|
||||
write-up is per module. The resume contract still holds — the file is the recovery state.
|
||||
|
||||
---
|
||||
|
||||
## 2. Size inventory
|
||||
|
||||
| Unit | src .ts/.cs | src lines | spec files | spec lines | stories |
|
||||
| ------------------------------ | ----------: | ---------: | ---------: | ---------: | ------: |
|
||||
| apps/ssp | 92 | 10 415 | 39 | 3 564 | 21 |
|
||||
| apps/behandelportal | 29 | 1 309 | 7 | 396 | 4 |
|
||||
| libs/shared (excl. generated) | 86 | 5 194 | 21 | 1 226 | 40 |
|
||||
| libs/beheer | 13 | 1 146 | 4 | 269 | 1 |
|
||||
| **Frontend total** | **220** | **18 064** | **71** | **5 455** | **66** |
|
||||
| backend/src (excl. Migrations) | 54 | 4 989 | — | — | — |
|
||||
| backend/tests | 39 | — | 39 | 4 253 | — |
|
||||
| e2e | 5 | 332 | — | — | — |
|
||||
|
||||
Backend source by folder: `Program.cs` 940 · `Data` (excl. Migrations) 1 697 · `Domain`
|
||||
991 · `Zgw` 710 · `Contracts` 329 · `Stamdata` 322. Zero `.html` template files exist
|
||||
anywhere in `apps/`/`libs/` — every Angular template is inline.
|
||||
|
||||
---
|
||||
|
||||
## 3. Coverage
|
||||
|
||||
### 3a. Frontend — line/branch coverage of files a spec actually reaches
|
||||
|
||||
| Module | Files | Lines | Line % | Branches | Branch % |
|
||||
| -------------------------- | -----: | --------: | --------: | --------: | --------: |
|
||||
| ssp/auth | 4 | 35 | 42.9% | 26 | 46.2% |
|
||||
| bhp/auth | 4 | 35 | 42.9% | 26 | 46.2% |
|
||||
| libs/shared/upload | 2 | 125 | 52.0% | 118 | 50.0% |
|
||||
| libs/beheer/application | 1 | 70 | 65.7% | 37 | 40.5% |
|
||||
| libs/beheer/infrastructure | 1 | 37 | 67.6% | 43 | 60.5% |
|
||||
| ssp/herregistratie | 5 | 302 | 70.9% | 286 | 67.8% |
|
||||
| libs/shared/layout | 2 | 80 | 72.5% | 36 | 55.6% |
|
||||
| libs/shared/ui | 13 | 279 | 72.8% | 230 | 75.2% |
|
||||
| ssp/brief | 11 | 486 | 75.3% | 461 | 68.8% |
|
||||
| ssp/registratie | 21 | 430 | 80.0% | 471 | 77.3% |
|
||||
| libs/shared/application | 8 | 76 | 80.3% | 50 | 70.0% |
|
||||
| bhp/behandeling | 5 | 107 | 91.6% | 146 | 81.5% |
|
||||
| libs/shared/infrastructure | 9 | 75 | 94.7% | 84 | 81.0% |
|
||||
| libs/shared/kernel | 5 | 28 | 96.4% | 20 | 90.0% |
|
||||
| libs/beheer/domain | 2 | 54 | 98.1% | 64 | 75.0% |
|
||||
| ssp/showcase | 1 | 11 | 100.0% | 4 | 100.0% |
|
||||
| libs/shared/testing | 3 | 9 | 100.0% | 2 | 50.0% |
|
||||
| **TOTAL** | **98** | **2 240** | **75.1%** | **2 104** | **70.6%** |
|
||||
|
||||
### 3b. Frontend — spec _reach_ (the number that matters)
|
||||
|
||||
75.1% is coverage **of the 98 files a spec imports**. It is not coverage of the codebase.
|
||||
122 of 220 source files are never loaded by any Vitest run at all.
|
||||
|
||||
| Module | Source files | Reached | Never reached | % reached |
|
||||
| ---------------------------------------- | -----------: | ------: | ------------: | --------: |
|
||||
| ssp/root, ssp/shell, bhp/root, bhp/shell | 12 | 0 | 12 | 0% |
|
||||
| libs/shared/domain | 3 | 0 | 3 | 0% |
|
||||
| libs/beheer/ui | 4 | 0 | 4 | 0% |
|
||||
| libs/beheer/contracts | 1 | 0 | 1 | 0% |
|
||||
| libs/shared/layout | 11 | 2 | 9 | 18% |
|
||||
| bhp/behandeling | 16 | 5 | 11 | 31% |
|
||||
| ssp/showcase | 3 | 1 | 2 | 33% |
|
||||
| libs/shared/ui | 34 | 13 | 21 | 38% |
|
||||
| ssp/brief | 26 | 11 | 15 | 42% |
|
||||
| bhp/auth | 8 | 4 | 4 | 50% |
|
||||
| libs/shared/upload | 4 | 2 | 2 | 50% |
|
||||
| ssp/registratie | 41 | 21 | 20 | 51% |
|
||||
| ssp/herregistratie | 9 | 5 | 4 | 56% |
|
||||
| ssp/auth | 6 | 4 | 2 | 67% |
|
||||
| libs/beheer/domain | 3 | 2 | 1 | 67% |
|
||||
| libs/shared/application | 11 | 8 | 3 | 73% |
|
||||
| libs/shared/infrastructure | 11 | 9 | 2 | 82% |
|
||||
| libs/shared/kernel | 5 | 5 | 0 | 100% |
|
||||
| **TOTAL** | **220** | **98** | **122** | **45%** |
|
||||
|
||||
Most of the 122 are `ui/` components, which CLAUDE.md §5 deliberately exercises through
|
||||
**Storybook + the a11y addon (66 stories)**, not Vitest. That is a house decision, not a
|
||||
gap — see BL-004 before filing anything against it.
|
||||
|
||||
### 3c. Backend
|
||||
|
||||
| Module | Files | Lines | Line % | Branches | Branch % |
|
||||
| ------------------ | -----: | --------: | --------: | --------: | --------: |
|
||||
| backend/Domain | 17 | 730 | 94.2% | 356 | 82.0% |
|
||||
| backend/Stamdata | 10 | 252 | 96.8% | 120 | 71.7% |
|
||||
| backend/Program.cs | 1 | 1 160 | 97.4% | 316 | 84.8% |
|
||||
| backend/Contracts | 2 | 334 | 97.6% | 40 | 65.0% |
|
||||
| backend/Zgw | 8 | 624 | 98.1% | 124 | 85.5% |
|
||||
| backend/Data | 13 | 1 852 | 99.0% | 400 | 75.5% |
|
||||
| **TOTAL** | **51** | **4 952** | **97.6%** | **1 356** | **79.6%** |
|
||||
|
||||
241 tests, all green. Every backend source file is reached. Branch coverage is the weak
|
||||
axis, not line coverage — `Contracts` 65.0%, `Stamdata` 71.7%, `Data` 75.5%.
|
||||
|
||||
---
|
||||
|
||||
## 4. Complexity
|
||||
|
||||
### 4a. TypeScript (exact — ESLint core `complexity`)
|
||||
|
||||
Distribution over 2 085 source functions (specs/stories excluded):
|
||||
**p50 1 · p75 2 · p90 3 · p95 5 · p99 12 · max 27.** 25 functions exceed CC 10 (1.2%).
|
||||
|
||||
Function length over 1 149 functions: **p50 3 · p75 7 · p90 13 · p95 20 · p99 34 ·
|
||||
max 143.** Only 2 functions exceed 75 lines.
|
||||
|
||||
| Module | Fns | max CC | p90 CC | CC>10 | max fn lines | fn>75 |
|
||||
| -------------------------------------------------------- | --: | -----: | -----: | ----: | -----------: | ----: |
|
||||
| libs/shared/upload | 90 | 27 | 4 | 1 | 109 | 1 |
|
||||
| ssp/registratie | 361 | 23 | 4 | 7 | 143 | 1 |
|
||||
| ssp/herregistratie | 144 | 19 | 3 | 4 | 31 | 0 |
|
||||
| libs/shared/infrastructure | 32 | 19 | 6 | 1 | 33 | 0 |
|
||||
| ssp/brief | 608 | 17 | 3 | 6 | 73 | 0 |
|
||||
| bhp/behandeling | 85 | 16 | 5 | 3 | 28 | 0 |
|
||||
| libs/beheer/domain | 22 | 11 | 7 | 1 | 24 | 0 |
|
||||
| libs/beheer/infrastructure | 13 | 11 | 7 | 1 | 25 | 0 |
|
||||
| libs/shared/ui | 285 | 11 | 3 | 1 | 37 | 0 |
|
||||
| libs/beheer/ui | 100 | 10 | 1 | 0 | 8 | 0 |
|
||||
| libs/shared/application | 61 | 7 | 3 | 0 | 30 | 0 |
|
||||
| bhp/auth · ssp/auth · ssp/shell | 63 | 5 | 3 | 0 | 13 | 0 |
|
||||
| libs/beheer/application | 42 | 4 | 3 | 0 | 15 | 0 |
|
||||
| libs/shared/kernel | 18 | 4 | 4 | 0 | 13 | 0 |
|
||||
| libs/shared/layout | 75 | 4 | 2 | 0 | 29 | 0 |
|
||||
| ssp/showcase · ssp/root · bhp/root · libs/shared/testing | 86 | 3 | 1 | 0 | 15 | 0 |
|
||||
|
||||
**Read BL-001 before filing any complexity ticket.** 23 of the 25 CC>10 functions are
|
||||
reducers (9), `parse*` trust boundaries (10), or `validate*` (4) — all three are
|
||||
mandated house idioms.
|
||||
|
||||
The 25, in full:
|
||||
|
||||
| CC | Function | Location | Kind |
|
||||
| --: | ------------------------ | -------------------------------------------------------------------------------- | --------- |
|
||||
| 27 | `reduceUpload` | libs/shared/src/upload/upload.machine.ts:131 | reducer |
|
||||
| 23 | `validateStep` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:117 | validate |
|
||||
| 20 | `reduce` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:321 | reducer |
|
||||
| 19 | `validateStep` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:97 | validate |
|
||||
| 19 | `parseAanvraagStatus` | apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts:72 | parse |
|
||||
| 19 | `fetch` | libs/shared/src/infrastructure/api-client.provider.ts:49 | **other** |
|
||||
| 17 | `parseOrgTemplate` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:331 | parse |
|
||||
| 17 | `parseDuoLookup` | apps/ssp/src/app/registratie/infrastructure/duo.adapter.ts:46 | parse |
|
||||
| 16 | `parseBeoordelingStatus` | apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:31 | parse |
|
||||
| 16 | `parseStatus` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:202 | parse |
|
||||
| 16 | `validateAll` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:166 | validate |
|
||||
| 15 | `parseBeoordelingView` | apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:68 | parse |
|
||||
| 15 | `validateAll` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:135 | validate |
|
||||
| 14 | `reduce` | apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts:70 | reducer |
|
||||
| 14 | `reduce` | apps/ssp/src/app/brief/domain/org-template.machine.ts:57 | reducer |
|
||||
| 14 | `reduce` | apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts:161 | reducer |
|
||||
| 14 | `reduce` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:222 | reducer |
|
||||
| 14 | `reduce` | apps/ssp/src/app/registratie/domain/change-request.machine.ts:57 | reducer |
|
||||
| 13 | `reduce` | apps/ssp/src/app/brief/domain/brief.machine.ts:188 | reducer |
|
||||
| 12 | `parsePassage` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:235 | parse |
|
||||
| 12 | `parseBrief` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:277 | parse |
|
||||
| 12 | `parseDashboardView` | apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts:50 | parse |
|
||||
| 11 | `reduce` | libs/beheer/src/domain/stamdata-editor.machine.ts:31 | reducer |
|
||||
| 11 | `parseAuditEntries` | libs/beheer/src/infrastructure/audit.adapter.ts:21 | parse |
|
||||
| 11 | `collect` | libs/shared/src/ui/rich-text-editor/rich-text-dom.ts:130 | **other** |
|
||||
|
||||
Functions over 75 lines — the entire population:
|
||||
`createDraftSync` 143 lines (apps/ssp/src/app/registratie/application/draft-sync.ts:50),
|
||||
`reduceUpload` 109 lines (libs/shared/src/upload/upload.machine.ts:131).
|
||||
|
||||
### 4b. C# (approximate)
|
||||
|
||||
| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 | CC>10 |
|
||||
| ------------------ | ----: | --------: | ----------: | ------: | ------------: | --: | ----: |
|
||||
| backend/Program.cs | 1 | **78** | **78** | 13 | 12 | 7 | 1 |
|
||||
| backend/Data | 23 | 101 | 27 | 77 | 16 | 4 | 1 |
|
||||
| backend/tests | 39 | 121 | 26 | 230 | 20 | 2 | 1 |
|
||||
| backend/Domain | 18 | 71 | 21 | 36 | 8 | 6 | 0 |
|
||||
| backend/Stamdata | 10 | 34 | 21 | 11 | 10 | 6 | 0 |
|
||||
| backend/Zgw | 8 | 39 | 11 | 20 | 8 | 4 | 0 |
|
||||
| backend/Contracts | 2 | 4 | 3 | 6 | 3 | 3 | 0 |
|
||||
|
||||
Method-length distribution (n=393): p50 9 · p90 22 · p99 90 · max 293.
|
||||
|
||||
Highest-CC files: `Program.cs` 78 · `Data/ApplicationStore.cs` 27 ·
|
||||
`tests/OpenZaakZaakSourceTests.cs` 26 · `Domain/Letters/LetterHtml.cs` 21 ·
|
||||
`Stamdata/StamdataTable.cs` 21 · `Data/BriefStore.cs` 17.
|
||||
|
||||
Methods over CC 10: `ToDto` (Data/BriefStore.cs:34, CC 16),
|
||||
`LogBrief` (Program.cs:889, CC 12), and one 293-line test method
|
||||
(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`, CC 20).
|
||||
|
||||
---
|
||||
|
||||
## 5. Duplication (approximate — 6-line normalized window)
|
||||
|
||||
Repo-wide: **1 755 of 24 701 significant lines duplicated = 7.1%.**
|
||||
|
||||
| Module | Sig. lines | Duplicated | % |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------: | ---------: | ---------: |
|
||||
| ssp/auth | 211 | 211 | **100.0%** |
|
||||
| bhp/auth | 243 | 211 | **86.8%** |
|
||||
| bhp/root | 90 | 68 | 75.6% |
|
||||
| ssp/root | 122 | 68 | 55.7% |
|
||||
| bhp/shell | 21 | 8 | 38.1% |
|
||||
| ssp/herregistratie | 1 536 | 121 | 7.9% |
|
||||
| ssp/brief | 5 015 | 393 | 7.8% |
|
||||
| backend/tests | 2 538 | 197 | 7.8% |
|
||||
| backend/Data | 1 068 | 82 | 7.7% |
|
||||
| libs/shared/infrastructure | 510 | 36 | 7.1% |
|
||||
| libs/beheer/domain | 250 | 17 | 6.8% |
|
||||
| libs/beheer/ui | 509 | 34 | 6.7% |
|
||||
| libs/beheer/application | 185 | 12 | 6.5% |
|
||||
| bhp/behandeling | 954 | 55 | 5.8% |
|
||||
| ssp/shell | 194 | 8 | 4.1% |
|
||||
| ssp/registratie | 3 798 | 144 | 3.8% |
|
||||
| libs/shared/ui | 2 604 | 58 | 2.2% |
|
||||
| backend/Zgw | 327 | 6 | 1.8% |
|
||||
| ssp/showcase | 487 | 8 | 1.6% |
|
||||
| backend/Program.cs | 510 | 6 | 1.2% |
|
||||
| libs/shared/application | 566 | 6 | 1.1% |
|
||||
| libs/shared/layout | 869 | 6 | 0.7% |
|
||||
| backend/Contracts, backend/Domain, backend/Stamdata, libs/shared/{domain,kernel,upload,testing,environments}, libs/beheer/{contracts,infrastructure}, e2e | 1 894 | 0 | 0.0% |
|
||||
|
||||
Top clone pairs:
|
||||
|
||||
| Windows | Pair |
|
||||
| ------: | ------------------------------------------------------------------------------------------------ |
|
||||
| 39 | `bhp/auth/application/session.store.ts` ↔ `ssp/auth/application/session.store.ts` |
|
||||
| 36 | `bhp/auth/auth.guard.spec.ts` ↔ `ssp/auth/auth.guard.spec.ts` |
|
||||
| 35 | `bhp/auth/ui/login-form/…` ↔ `ssp/auth/ui/login-form/…` |
|
||||
| 25 | `bhp/app.config.ts` ↔ `ssp/app.config.ts` |
|
||||
| 23 | `bhp/auth/ui/login.page.ts` ↔ `ssp/auth/ui/login.page.ts` |
|
||||
| 21 | `bhp/auth/auth.guard.ts` ↔ `ssp/auth/auth.guard.ts` |
|
||||
| 13 | `libs/shared/…/role.interceptor.spec.ts` ↔ `…/subject.interceptor.spec.ts` |
|
||||
| 11 | `brief/ui/letter-canvas.stories.ts` ↔ `brief/ui/letter-composer.stories.ts` |
|
||||
| 9 | `ssp/brief/ui/org-template.page.ts` ↔ `libs/beheer/src/ui/stamdata.page.ts` |
|
||||
| 8 | `bhp/behandeling/domain/besluit.machine.ts` ↔ `ssp/registratie/domain/change-request.machine.ts` |
|
||||
| 8 | `tests/OpenZaakZaakSourceTests.cs` ↔ `tests/ZgwDivergenceTests.cs` |
|
||||
| 7 | `bhp/…/besluit-form.component.ts` ↔ `ssp/…/change-request-form.component.ts` |
|
||||
| 7 | `ssp/…/intake-wizard.component.ts` ↔ `ssp/…/registratie-wizard.component.ts` |
|
||||
|
||||
**The auth duplication is a deliberate decision, not an accident** — see BL-002 before
|
||||
proposing to merge it.
|
||||
|
||||
---
|
||||
|
||||
## 6. Layering and coupling
|
||||
|
||||
`npm run dep:check`: **0 violations** (223 modules, 584 dependencies cruised) across 11
|
||||
`severity: error` rules — `shared-no-features`, `beheer-no-features`, `shared-no-beheer`,
|
||||
`<app>-no-other-app`, per-context `<app>-<context>-scope`, `domain-is-pure`,
|
||||
`contracts-import-nothing`, `ui-not-infrastructure`, `apiclient-infrastructure-only`,
|
||||
`no-testing-in-production`, `no-circular`.
|
||||
|
||||
Instability I = Ce/(Ca+Ce). Low I = stable base, high I = volatile leaf. Measured in the
|
||||
ssp cruise (behandelportal's figures for `libs/*` differ only in Ca, same shape):
|
||||
|
||||
| Folder | N | Ca | Ce | I |
|
||||
| --------------------------------------- | --: | --: | --: | -----: |
|
||||
| apps/ssp/src/app/brief | 46 | 2 | 143 | 99% |
|
||||
| apps/ssp/src/app/herregistratie | 18 | 4 | 80 | 95% |
|
||||
| apps/ssp/src/app/showcase | 4 | 1 | 17 | 94% |
|
||||
| apps/ssp/src/app/registratie | 67 | 19 | 181 | 91% |
|
||||
| apps/behandelportal/src/app/behandeling | 24 | 2 | 62 | 97% |
|
||||
| apps/behandelportal/src/app/auth | 11 | 4 | 25 | 86% |
|
||||
| apps/ssp/src/app/auth | 9 | 5 | 24 | 83% |
|
||||
| libs/beheer/src/ui | 5 | 3 | 27 | 90% |
|
||||
| libs/beheer/src/application | 3 | 2 | 16 | 89% |
|
||||
| libs/beheer/src/infrastructure | 3 | 3 | 11 | 79% |
|
||||
| libs/shared/src/layout | 19 | 22 | 52 | 70% |
|
||||
| libs/shared/src/ui | 68 | 128 | 111 | 46% |
|
||||
| libs/shared/src/infrastructure | 19 | 43 | 25 | 37% |
|
||||
| libs/shared/src/application | 18 | 55 | 30 | 35% |
|
||||
| libs/shared/src/upload | 5 | 30 | 13 | 30% |
|
||||
| libs/beheer/src/domain | 5 | 8 | 3 | 27% |
|
||||
| libs/shared/src/testing | 4 | 17 | 2 | 11% |
|
||||
| libs/shared/src/kernel | 9 | 71 | 4 | **5%** |
|
||||
| libs/shared/src/domain | 3 | 11 | 0 | 0% |
|
||||
| libs/beheer/src/contracts | 1 | 0 | 0 | 0% |
|
||||
|
||||
This is textbook: `kernel`/`domain` are the stable base (I ≤ 5%), feature contexts are
|
||||
volatile leaves (I ≥ 83%), nothing depends on them. **The frontend dependency structure
|
||||
is not a problem area** — do not spend tickets here.
|
||||
|
||||
**The backend has no equivalent enforcement at all.** `Domain/` purity (verified: zero
|
||||
`Microsoft.EntityFrameworkCore` / `Microsoft.AspNetCore` imports) holds by convention and
|
||||
code review only. See BL-006.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pattern inventory — what already exists
|
||||
|
||||
Agents 03 (DDD/hexagonal) and 04 (CQRS-light) may only **extend** what is listed here.
|
||||
They may not introduce either pattern into a module where it is absent.
|
||||
|
||||
### Backend
|
||||
|
||||
| Pattern | State |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Ports with 2+ implementations | `IZaakSource` (`Data/LocalZaakSource.cs` ↔ `Zgw/OpenZaakZaakSource.cs`), `IDocumentSource` (`Data/LocalDocumentSource.cs` ↔ `Zgw/OpenZaakDocumentSource.cs`) — config-switched on `Zgw:Enabled` at `Program.cs:57-84`, ADR-0005 |
|
||||
| Single-impl interface | `IIdentityProvider` → `StubIdentityProvider` |
|
||||
| **Not behind any port** | 7 static, non-DI stores: `ApplicationStore`, `DocumentStore`, `BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`. No `AddDbContext`; each opens a short-lived context via `Db.Create()` under its own lock. Deliberate, documented in `Data/Db.cs` and `Program.cs:40-45` |
|
||||
| Domain purity | `Domain/` is EF-free and ASP-free (verified). Rules are static classes of pure functions with co-located tests in `tests/Domain/`: `SubmissionRules`, `DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`, `HerregistratieRule`, `LetterHtml`, `OrgTemplateRules`, `Authz`, `FeatureFlags`. `Domain/Applications/Aanvraag.cs` is a C# tagged union (`Concept`/`Submitted`/`Decided`) |
|
||||
| CQRS-light | **Partial.** `Contracts/Dtos.cs` holds 65 records split by direction (`*Request` in, `*Dto`/`*Response` out). Read/write split exists as _comment banners_ inside a single **940-line `Program.cs`** carrying all 48 endpoint mappings. No handler types, no mediator, no `Features/` folders. Cross-cutting behaviour is factored into local helpers (`Submit`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) — authorization/idempotency wrappers, not handlers |
|
||||
| Mapping | `Contracts/Mappers.cs` (`.ToDto()`, `.ToDetailDto()`), `Data/AanvraagMapper.cs` |
|
||||
| ZGW anti-corruption layer | Fully built: `Zgw/{OpenZaakZaakSource,OpenZaakDocumentSource,ZgwHttpClient,ZgwTokenProvider,ZgwZaakMapper,ZgwOptions,ZgwDiagnosticHandler}.cs`, 5 test files. ADR-0005 |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Pattern | Count | Notes |
|
||||
| ------------------------------------- | ----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Command factories (write side) | 3 | `registratie/application/submit-change-request.ts`, `behandeling/application/submit-besluit.ts`, `registratie/application/draft-sync.ts` |
|
||||
| Mutations living _inline in adapters_ | ~13 | `brief.adapter.ts` (save/submit/approve/reject/send/reset), `org-template.adapter.ts` (save/publish/rollback), `stamdata.adapter.ts` — they call `runSubmit` directly instead of going through a command factory |
|
||||
| Infrastructure adapters (read side) | 20 | +1 outside an `infrastructure/` folder: `libs/shared/src/upload/upload.adapter.ts` |
|
||||
| `parse*` trust boundaries | 30 | 24 in adapters, 6 in value objects / kernel |
|
||||
| Application stores | 15 | all `providedIn: 'root'` |
|
||||
| Elm-style machines | 9 | 8 under a `domain/` folder; outlier `libs/shared/src/upload/upload.machine.ts` |
|
||||
| Explicit port | 1 | `SessionPort` + `SESSION_PORT` token (`libs/shared/src/application/session.port.ts`) |
|
||||
| Config-seam tokens | 3 | `DEBUG_PANEL`, `HEADER_NAV_ITEMS`, `HEADER_ADMIN_LINKS` |
|
||||
| `contracts/` DTO files | 4 | most adapters consume NSwag-generated types directly instead |
|
||||
|
||||
Shared application kit (`libs/shared/src/application/`): `remote-data.ts`, `store.ts`,
|
||||
`submit.ts`, `action-state.ts`, `debounced-save.ts`, `history.ts`,
|
||||
`machine-remote-data.ts`, `pending-saves.ts`.
|
||||
|
||||
### ADRs on record
|
||||
|
||||
`0001` BFF-lite + decision DTOs (Accepted) · `0002` user groups as actors, not bounded
|
||||
contexts (was **Proposed** when measured; promoted to **Accepted** 2026-08-26 by ADR-C-005,
|
||||
with §3's `Principal` omission recorded in the ADR as known debt) · `0003` CIBG Huisstijl
|
||||
(Accepted) · `0004`
|
||||
stamdata as code (Accepted) · `0005` OpenZaak behind the BFF — the explicit
|
||||
ports-and-adapters ADR (Accepted) · `0006` test data through the production door
|
||||
(Accepted).
|
||||
|
||||
---
|
||||
|
||||
## 8. Rankings, worst to best
|
||||
|
||||
**Spec reach (FE):** ssp/root · ssp/shell · bhp/root · bhp/shell · libs/shared/domain ·
|
||||
libs/beheer/ui · libs/beheer/contracts (all 0%) → libs/shared/layout 18% →
|
||||
bhp/behandeling 31% → libs/shared/ui 38% → ssp/brief 42% → … → libs/shared/kernel 100%.
|
||||
|
||||
**Line coverage (FE, of reached files):** ssp/auth · bhp/auth 42.9% →
|
||||
libs/shared/upload 52.0% → libs/beheer/application 65.7% → … → ssp/showcase 100%.
|
||||
|
||||
**Branch coverage (BE):** Contracts 65.0% → Stamdata 71.7% → Data 75.5% → Domain 82.0% →
|
||||
Program.cs 84.8% → Zgw 85.5%.
|
||||
|
||||
**Duplication:** ssp/auth 100% → bhp/auth 86.8% → bhp/root 75.6% → ssp/root 55.7% →
|
||||
bhp/shell 38.1% → ssp/herregistratie 7.9% → … → 12 modules at 0%.
|
||||
|
||||
**Complexity (TS, CC>10 count):** ssp/registratie 7 → ssp/brief 6 → ssp/herregistratie 4
|
||||
→ bhp/behandeling 3 → 5 modules with 1 → the rest 0.
|
||||
|
||||
**Complexity (C#, file CC):** Program.cs 78 → Data 101 across 23 files (max 27) →
|
||||
Domain 71 (max 21) → Zgw 39 → Stamdata 34 → Contracts 4.
|
||||
|
||||
**Coupling:** nothing to rank — 0 violations, healthy instability gradient (§6).
|
||||
|
||||
---
|
||||
|
||||
## 9. Thresholds Phase 1 must use
|
||||
|
||||
Derived from the measured distributions above, not invented.
|
||||
|
||||
| Threshold | Value | Basis |
|
||||
| ---------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Cyclomatic complexity | **> 10** | fixed by the agent prompt; TS p99 is 12, so this catches the top ~1% |
|
||||
| **Agent 01's `[N] lines` — TS function** | **> 40** | TS fn-length p99 is 34; only 4 functions exceed 50 |
|
||||
| Agent 01's `[N] lines` — C# method | **> 60** | C# method p99 is 90, p90 is 22 |
|
||||
| Agent 01's `[N] lines` — component/file | **> 400** | 6 TS files and 1 C# file exceed it |
|
||||
| Nesting depth | **> 3** | fixed by the agent prompt |
|
||||
| Duplication worth a ticket | **> 10% of a module's significant lines** | repo mean is 7.1% |
|
||||
|
||||
Largest files (the >400 population): `backend/Program.cs` 940 ·
|
||||
`registratie/ui/registratie-wizard.component.ts` 645 · `showcase/concepts.page.ts` 496 ·
|
||||
`brief/ui/letter-canvas.component.ts` 463 · `brief/infrastructure/brief.adapter.ts` 437 ·
|
||||
`herregistratie/ui/intake-wizard.component.ts` 406.
|
||||
|
||||
---
|
||||
|
||||
## 10. Baseline observations (cite these)
|
||||
|
||||
Stable IDs. A Phase 1 finding must reference one of these or a metric row above.
|
||||
|
||||
**BL-001 — 23 of 25 TS functions over CC 10 are mandated house idioms.**
|
||||
Reducers (9), `parse*` trust boundaries (10) and `validate*` (4) are switch/guard-dense
|
||||
by construction: CLAUDE.md §3 requires a tagged-union reducer over booleans, and ADR-0001
|
||||
requires a hand-written `parse*` at every wire boundary. High CC there is the design
|
||||
working, not decaying. Only two CC>10 functions are outside the idiom:
|
||||
`api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11).
|
||||
_Any complexity ticket against a `reduce*`/`parse*`/`validate*` must argue why this case
|
||||
differs — a bare CC number is not sufficient grounds._
|
||||
|
||||
**BL-002 — `ssp/auth` is 100% duplicated against `bhp/auth`, deliberately.**
|
||||
211 of 211 significant lines; `session.store.ts`, `login-form.component.ts`, `login.page.ts`
|
||||
and `auth.guard.ts` are near-identical. CLAUDE.md §1 and ADR-0002 state auth is _not_
|
||||
shared because Zorgverlener and Medewerker are different `Principal` variants expected to
|
||||
diverge. **The divergence has not happened yet.** This is legitimately in scope for
|
||||
agent 06 (ADR conformance) as either an `ADR-fix` — the prediction has not held over two
|
||||
phases of work — or a "still waiting" note. It is _not_ a straightforward
|
||||
extract-to-shared refactor; that would contradict an accepted ADR.
|
||||
|
||||
> **Sharpened 2026-08-26 by agent 06 (ADR-C-004/005) — verified.** "The prediction has
|
||||
> not held" is the wrong diagnosis. ADR-0002 §3's concrete deliverable, `Session →
|
||||
Principal`, was **never built**: `grep -rn "Principal" apps libs` returns exactly one
|
||||
> hit, a comment in `libs/shared/src/infrastructure/role.ts:8`, and no type. `diff -rq`
|
||||
> over the two auth folders shows **zero** content differences — 9 of 11 files identical,
|
||||
> the only delta being two extra files in behandelportal. `behandelportal`'s Behandelaar
|
||||
> still carries a `bsn` and `login.page.ts:31` logs a backoffice user in through DigiD.
|
||||
> The rule was not falsified, it was untested; the divergence that did occur went through
|
||||
> an orthogonal side door (`medewerker.interceptor.ts`) that never touches `Session`.
|
||||
> Amending ADR-0002 would ratify the omission. Use agent 06's sequencing instead.
|
||||
|
||||
**BL-003 — `Program.cs` is the single largest complexity concentration in the repo.**
|
||||
940 lines, 48 endpoint mappings, file CC 78 (next-highest file: 27), read/write separated
|
||||
only by comment banner. 97.4% line / 84.8% branch covered, so it is well-tested, not
|
||||
fragile — this is a structure finding, not a correctness one. It is the one place where
|
||||
agent 04's CQRS-light and agent 03's vertical-slice thinking both have real purchase,
|
||||
_and_ the one place where "extend the existing pattern, don't introduce one" is hardest to
|
||||
honour: there is no `Features/` folder to extend.
|
||||
|
||||
**BL-004 — 122 of 220 FE source files (55%) are never loaded by any Vitest run.**
|
||||
Overwhelmingly `ui/` components, which CLAUDE.md §5 covers via 66 Storybook stories with
|
||||
the a11y addon instead. Agent 02 must distinguish _"untested"_ from _"tested through
|
||||
Storybook"_ before filing; the genuine gaps are non-`ui/` files with no spec —
|
||||
`libs/shared/domain` (3 files, 0%), `libs/beheer/contracts`, and the app root/shell files.
|
||||
|
||||
> **Corrected 2026-08-26 by agent 02 — verified.** The two named "genuine gaps" are false
|
||||
> positives. `libs/shared/src/domain` is 30 lines across 3 files — interfaces and type
|
||||
> aliases plus a single string const — and `libs/beheer/src/contracts/stamdata.dto.ts` is
|
||||
> 30 lines of DTO shapes. Neither contains an executable statement, so 0% is correct and
|
||||
> unimprovable. Close both rows rather than ticketing them. Only the app root/shell files
|
||||
> remain as candidates from this observation.
|
||||
|
||||
**BL-005 — backend branch coverage lags line coverage by 18 points** (97.6% vs 79.6%).
|
||||
Weakest: `Contracts` 65.0%, `Stamdata` 71.7%, `Data` 75.5%. Line coverage is near-total,
|
||||
so the missing tests are edge-case branches, not whole units.
|
||||
|
||||
**BL-006 — the backend has zero automated architecture enforcement.**
|
||||
The frontend has 11 dependency-cruiser rules at `severity: error`, 0 violations, running
|
||||
in CI. The backend has none: one assembly, no `Domain.csproj` boundary, no
|
||||
NetArchTest/ArchUnitNET. `Domain/` purity currently holds by convention. Any agent-03
|
||||
proposal that depends on the backend's layering staying clean should note that nothing
|
||||
enforces it.
|
||||
|
||||
**BL-007 — the FE write side is inconsistently placed.**
|
||||
3 command factories vs ~13 call sites invoking `runSubmit` directly inside adapters
|
||||
(`brief.adapter.ts`, `org-template.adapter.ts`, `stamdata.adapter.ts`). CLAUDE.md §3 makes
|
||||
the command factory the idiom. This is agent 04's clearest extend-an-existing-pattern
|
||||
target — the pattern exists, it is just not applied uniformly.
|
||||
|
||||
> **Corrected 2026-08-26 by agent 04 (CQ-003/CQ-005) — verified.** The "~13 mutations"
|
||||
> count was derived from the `runSubmit` helper name and is wrong: 5 of those call sites
|
||||
> are **reads**, not writes. `libs/beheer/src/infrastructure/stamdata.adapter.ts` exposes
|
||||
> only `list()` and `load()` and its own docstring says "Both endpoints are reads … There
|
||||
> is no write method", yet both call `runSubmit`. Same at `brief.adapter.ts:56` and
|
||||
> `org-template.adapter.ts:39,51`. `runSubmit` is the write fold — it mints the
|
||||
> Idempotency-Key — so the name, not the code, produced the miscount. Conversely agent 04
|
||||
> found **3 mutations this baseline missed entirely**: `ApplicationsStore.cancel`,
|
||||
> `AdminCasesStore.delete`, `FeatureFlagStore.set` reach the raw `ApiClient` and never
|
||||
> return a `Result`. Use agent 04's inventory, not this count.
|
||||
|
||||
**BL-008 — `coverageExclude` does not exclude the generated API client.**
|
||||
`libs/shared/src/infrastructure/api-client.ts` is listed in `coverageExclude` in all four
|
||||
`angular.json` test targets, yet appears in all four `lcov.info` files (987 lines at ~7%),
|
||||
dragging the reported `libs/shared/infrastructure` figure from 94.7% down to 6.9%. This
|
||||
file excludes it manually. Small, real, and cheap to fix.
|
||||
|
||||
**BL-009 — no coverage threshold is enforced anywhere.**
|
||||
Neither `angular.json` nor CI sets a minimum; `npm run test:coverage` runs in CI and the
|
||||
output is discarded. There is no ratchet, so no ticket can be verified as "improved
|
||||
coverage" by CI alone — verify against the numbers in this file.
|
||||
|
||||
**BL-010 — `libs/shared/src/upload/` sits outside the layer convention.**
|
||||
`upload.machine.ts` (CC 27, 109 lines — the highest in the repo) lives in its own
|
||||
top-level folder rather than under `domain/`, and `upload.adapter.ts` outside
|
||||
`infrastructure/`. It is carved out by name in the `apiclient-infrastructure-only`
|
||||
dependency-cruiser rule, i.e. the exception is already encoded rather than resolved.
|
||||
|
||||
**BL-011 — the FE test suite is flaky under parallel load.**
|
||||
`npm run test:coverage` failed on the first run with two `[vitest-pool] Timeout waiting
|
||||
for worker to respond` errors in `libs/shared`, then passed 21/21 when re-run serially.
|
||||
Not a code defect; relevant to any ticket whose acceptance is "CI green".
|
||||
|
||||
---
|
||||
|
||||
## 11. Reproducing this file
|
||||
|
||||
```bash
|
||||
npm run test:coverage # if flaky, re-run the failing project alone
|
||||
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" \
|
||||
--collect:"XPlat Code Coverage"
|
||||
npx eslint apps libs --rule '{"complexity":["warn",0],"max-lines-per-function":["warn",{"max":0,"skipBlankLines":true,"skipComments":true}]}' -f json
|
||||
node docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs
|
||||
npm run dep:check
|
||||
npx depcruise apps/ssp/src libs --config .dependency-cruiser.ssp.js --metrics --output-type metrics
|
||||
npx depcruise apps/behandelportal/src libs --config .dependency-cruiser.behandelportal.js --metrics --output-type metrics
|
||||
```
|
||||
|
||||
The `baseline-scan.mjs` figures are deterministic and must reproduce exactly. Coverage
|
||||
figures move with the tests.
|
||||
@@ -0,0 +1,9 @@
|
||||
## Scope: [to be filled by agent]
|
||||
|
||||
## Status: not_started
|
||||
|
||||
## Last updated: -
|
||||
|
||||
## Depends on: [see agent prompt]
|
||||
|
||||
## ---
|
||||
@@ -0,0 +1,641 @@
|
||||
## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (domain, application, infrastructure, ui, layout, kernel, upload, testing), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata, tests)
|
||||
|
||||
## Status: complete
|
||||
|
||||
## Last updated: 2026-08-26
|
||||
|
||||
## Depends on: 00-baseline.md
|
||||
|
||||
## ---
|
||||
|
||||
# 02 — Testability
|
||||
|
||||
What blocks a **unit** test — static/singleton dependencies, hidden I/O, work in
|
||||
constructors/field initializers, pure logic entangled with impure. Every finding cites a
|
||||
`BL-###` or a metric row from `00-baseline.md`. Seams proposed are extractions, never
|
||||
rewrites.
|
||||
|
||||
## How this file reads the baseline
|
||||
|
||||
Three filters were applied before anything was written down, and they killed more
|
||||
candidates than they kept:
|
||||
|
||||
1. **BL-004's carve-out is honoured.** No finding is filed against a `ui/` (or
|
||||
`layout/` component) file for lacking a Vitest spec. 66 Storybook stories + the a11y
|
||||
addon are the house strategy (CLAUDE.md §5), not a gap.
|
||||
2. **"Untested" ≠ "untestable".** Several of the worst-covered non-`ui/` files are
|
||||
perfectly injectable and simply have no spec (`submit-besluit.ts`,
|
||||
`Contracts/Mappers.cs`, `breadcrumb-trail.ts`). Those are noted in their module
|
||||
section but not filed as testability findings — there is no seam to add. Whoever
|
||||
owns coverage should pick them up.
|
||||
3. **Already-covered code needs a positive argument.** Backend line coverage is 97.6%
|
||||
(BL-005); a "this is untestable" claim there has to point at a branch the current
|
||||
test shape genuinely cannot reach. Two do (TE-007, TE-008); one points at the cost
|
||||
of how it is reached (TE-009).
|
||||
|
||||
**Two baseline items are closed as false gaps** — see `libs/shared/domain` and
|
||||
`libs/beheer/contracts` below. BL-004 names both as "genuine gaps"; on inspection
|
||||
neither contains an executable statement.
|
||||
|
||||
**Deliberate decisions engaged with, not overridden:** the 7 static backend stores
|
||||
(documented in `Data/Db.cs`) are left alone — TE-009 extracts rules _out_ of one of
|
||||
them without touching its shape. BL-002's auth duplication is respected — TE-004 lands
|
||||
the same seam twice rather than proposing a shared extraction.
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — auth
|
||||
|
||||
**TE-001 — `SessionStore.restore()` reads `localStorage` inline, so its shape guard cannot be unit-tested**
|
||||
|
||||
- Module / file:line — `apps/ssp/src/app/auth/application/session.store.ts:12-21`
|
||||
- **What blocks unit testing.** `restore()` is module-private and calls
|
||||
`localStorage.getItem(STORAGE_KEY)` itself, then does the parse + shape validation in
|
||||
the same function. It is invoked from a field initializer
|
||||
(`private _session = signal<Session | null>(restore())`, L37), so the storage read
|
||||
happens the instant the singleton is constructed. A spec cannot feed it a raw string;
|
||||
it must stub the `localStorage` global before the injector builds the store. The
|
||||
logic being guarded is not incidental — the comments mark it G1 (never persist the
|
||||
BSN) and G2 (validate the shape before trusting it), i.e. a trust boundary, and
|
||||
CLAUDE.md §5 mandates a spec for boundary `parse*` adapters.
|
||||
- **Baseline citation.** §3a: `ssp/auth` 42.9% line / 46.2% branch — **jointly the worst
|
||||
line coverage in the frontend table** (§8 ranking). Per-file lcov for this file:
|
||||
**LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13 (23.1% branch)** — 4 of the module's 6
|
||||
files are spec-reached (§3b, 67%), yet this one barely executes.
|
||||
- **Minimal seam.** Split the pure half out and move it next to the type it produces:
|
||||
`export function parseStoredSession(raw: string | null): Session | null` in
|
||||
`auth/domain/session.ts` — which **already has a spec file**
|
||||
(`auth/domain/session.spec.ts`) and is pure TS, so no new test scaffolding is needed.
|
||||
`restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))`. Three
|
||||
test cases (absent, non-JSON, wrong shape) cover the guard.
|
||||
- **Effort S.** Independently shippable in one deploy — pure move, no call-site change
|
||||
outside the file.
|
||||
- **Note on BL-002.** `bhp/auth` carries the identical function; the seam lands **twice**,
|
||||
once per app. That is correct, not duplication to fix — ADR-0002 / CLAUDE.md §1 make
|
||||
`auth` deliberately unshared, and BL-002 flags any extract-to-shared here as
|
||||
contradicting an accepted ADR. Agent 06 owns whether that prediction still holds.
|
||||
|
||||
## apps/ssp — registratie
|
||||
|
||||
**No findings.**
|
||||
|
||||
The module's shape is the reason. Every `parse*` in its six adapters is exported and
|
||||
directly spec'd (`applications`, `big-register`, `brp`, `dashboard-view`, `duo` all have
|
||||
`.spec.ts` files); the machines are pure `domain/` units with specs; the five value
|
||||
objects each have one.
|
||||
|
||||
`createDraftSync` deserves an explicit acquittal: at 143 lines it is the longest function
|
||||
in the repo (§4a, "Functions over 75 lines — the entire population") and it owns a
|
||||
`setTimeout` debounce, a `Router` navigation and an in-flight-create race guard. It is
|
||||
nevertheless **the best-seamed effectful unit in the frontend** — deps arrive through an
|
||||
explicit `DraftSyncDeps` object (`draft-sync.ts:24-33`), `Router`/`ActivatedRoute` are
|
||||
`inject(..., { optional: true })` so it is inert without them, and `enabled()` exists
|
||||
specifically so stories and tests can neutralize it (L31-32). It has a spec. Its length
|
||||
is agent 01's call, not a testability defect.
|
||||
|
||||
`BigProfileStore` creates two `resource()`s in field initializers (constructor-time I/O),
|
||||
which is normally a blocker — but the store is pure glue over `parseDashboardView`
|
||||
(exported, spec'd) and `map`/`fromResource` (spec'd), so there is no untested decision
|
||||
hiding behind the construction. §3a: 80.0% line / 77.3% branch, §3b 51% reach with the
|
||||
20 unreached files being 11 `ui/` components (BL-004) and 3 pure-type `contracts/` files.
|
||||
|
||||
## apps/ssp — herregistratie
|
||||
|
||||
**No findings.** §3a 70.9% / 67.8%, §3b 56% reach. The four unreached files are the
|
||||
`ui/` pages and wizard organisms (BL-004) plus `intake-policy.store.ts`, a thin
|
||||
`resource()` wrapper over the exported-and-spec'd `parseIntakePolicy`. Both machines are
|
||||
pure, Angular-free and carry four spec files between them, including an acceptance spec.
|
||||
`intake.testing.ts` gives the wizard specs a fixture builder — the seam already exists.
|
||||
|
||||
## apps/ssp — brief
|
||||
|
||||
**TE-002 — `RevealBigNummerAdapter` hides a trust boundary inside a global-`fetch` method**
|
||||
|
||||
- Module / file:line — `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:32-41`
|
||||
- **What blocks unit testing.** The shape validation of the response body — the code's
|
||||
own comment calls it a "Trust boundary" — is written inline inside `async reveal()`,
|
||||
after an `await fetch(...)` on the **global** `fetch` (L24). There is no injected transport. To assert that a
|
||||
`{ bigNummer: 42 }` response is rejected, a spec must stub `globalThis.fetch`; the
|
||||
boundary itself is not callable. Every other `parse*` in the repo is exported (30 of
|
||||
them, §7) — this one is the outlier, and it guards a PII reveal (PRD-0002 §5c). The
|
||||
same shape recurs in the sibling hand-written-`fetch` adapters:
|
||||
`letter-preview.adapter.ts:56-62` (`errorMessage`) and
|
||||
`org-template.adapter.ts:82-89` (the proefbrief error mapping) — both un-exported,
|
||||
both unreachable without a `fetch` stub.
|
||||
- **Baseline citation.** §3b: `ssp/brief` **42% spec reach** (11 of 26 files) — the
|
||||
lowest of any non-zero context outside `bhp/behandeling`; §3a 75.3% line / **68.8%
|
||||
branch**. All three `fetch` adapters are among the 15 unreached files, and none of
|
||||
them is a `ui/` component, so BL-004's Storybook carve-out does not cover them.
|
||||
- **Minimal seam.** Export the pure half as a named boundary, matching the file's 30
|
||||
siblings: `export function parseRevealed(body: unknown): Result<string, string>` —
|
||||
five lines moved verbatim out of the method, which becomes
|
||||
`return res.ok ? parseRevealed(await res.json().catch(() => null)) : err(...)`. Same
|
||||
move for `errorMessage` in the other two adapters (already separate functions; they
|
||||
only need `export` + a spec). No transport abstraction, no `HttpClient` migration —
|
||||
the hand-written `fetch` stays, and the documented reasons for it
|
||||
(`.ExcludeFromDescription()`, per-request headers) are untouched.
|
||||
- **Effort S.** Independently shippable.
|
||||
|
||||
## apps/ssp — showcase, shell, root
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3b lists `ssp/root` + `ssp/shell` (with the behandelportal equivalents) at **0% reach,
|
||||
12 files**. That is the correct number for what these files are: `main.ts`,
|
||||
`app.config.ts`, `app.routes.ts`, `app.ts` and `shell/nav.config.ts` are composition
|
||||
roots and static data — a spec asserting a provider array restates it. `debug-state`
|
||||
is a dev-only devtool with a Storybook story.
|
||||
|
||||
One honest note, no ticket: `shell/debug-state/mask.ts::redactProfile` is a pure,
|
||||
Angular-free PII-redaction function (it maps a `BigProfile` to a redacted shape) with no
|
||||
spec and **no blocker** — it is directly callable today. Its dependencies
|
||||
(`maskTail`, `REDACTED`) are in `libs/shared/kernel/pii.ts`, which is spec'd at 96.4%.
|
||||
Missing test, not blocked test.
|
||||
|
||||
`showcase` is 100% line coverage on its one reached file (§3a); `snippets.generated.ts`
|
||||
is generated and `concepts.page.ts` is a teaching page.
|
||||
|
||||
## apps/behandelportal — auth
|
||||
|
||||
**TE-001 applies here identically** — `apps/behandelportal/src/app/auth/application/session.store.ts:12-21`,
|
||||
same `restore()`, same `localStorage` read in a field initializer, same §3a row
|
||||
(`bhp/auth` 42.9% / 46.2%). Fix it in `apps/behandelportal/src/app/auth/domain/session.ts`,
|
||||
which also already has a spec. Counted once as TE-001; it is two commits, or one commit
|
||||
touching two apps.
|
||||
|
||||
No additional findings. `medewerker.ts:17-22` reads `window.location.search` +
|
||||
`sessionStorage` directly, but it is a five-line dev-only role stand-in with the same
|
||||
shape as `libs/shared/infrastructure/role.ts` — whose twin `subject.ts` is spec'd, so
|
||||
the pattern is demonstrably testable as written.
|
||||
|
||||
## apps/behandelportal — behandeling
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3b 31% reach (5 of 16) looks alarming and is not: the 11 unreached are 5 `ui/` files
|
||||
(BL-004), the three `resource()`-wrapper stores, and the two command factories. §3a
|
||||
records **91.6% line / 81.5% branch** on what is reached — the highest line coverage of
|
||||
any frontend module in the table.
|
||||
|
||||
Explicitly not a testability finding: `application/submit-besluit.ts` is
|
||||
**structurally identical** to `apps/ssp/src/app/registratie/application/submit-change-request.ts`,
|
||||
which has a spec (`submit-change-request.spec.ts`). Same `inject()` + `runSubmit`
|
||||
factory, same signature shape. It is not blocked by anything; it is a missing spec whose
|
||||
template already exists in the repo. Both adapters' `parse*` functions are exported and
|
||||
spec'd (`beoordeling.adapter.spec.ts`, `werkvoorraad.adapter.spec.ts`).
|
||||
|
||||
## apps/behandelportal — shell, root
|
||||
|
||||
**No findings.** Same composition-root reasoning as `ssp/shell + root` above.
|
||||
|
||||
## libs/shared — domain
|
||||
|
||||
**No findings — BL-004's "genuine gap" is a false positive here, and can be closed.**
|
||||
|
||||
§3b lists `libs/shared/domain` at **0% reached, 3 files**, and BL-004 names it first
|
||||
among "the genuine gaps are non-`ui/` files with no spec". Reading all three files
|
||||
(30 lines total): `capability.ts` is a 9-member string-literal union, `role.ts` is a
|
||||
3-member union, `feature-flag.ts` is one `interface` plus one exported string constant.
|
||||
**There is no executable statement in the folder.** 0% is the correct and unimprovable
|
||||
number; the types are checked by `tsc` and their runtime counterparts are validated in
|
||||
`parseMe` (spec'd, 94.7% infrastructure coverage). No ticket should be written against
|
||||
this row.
|
||||
|
||||
## libs/shared — application
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3a 80.3% / 70.0%, §3b 73% reach (8 of 11). The three unreached are `session.port.ts`
|
||||
(an `InjectionToken` + interface — a declaration, nothing to run), `feature-flags.store.ts`
|
||||
and `access.store.ts`. The seam kit itself (`remote-data`, `store`, `submit`,
|
||||
`history`, `pending-saves`, `machine-remote-data`, `debounced-save`) is fully spec'd —
|
||||
this is the folder that makes the rest of the frontend testable.
|
||||
|
||||
Noted without a ticket: `AccessStore.can()` (`access.store.ts:34-37`) is a
|
||||
deny-by-default security gate whose decision reduces to
|
||||
`rd.tag === 'Success' && rd.value.includes(capability)` over a `resource()` created in a
|
||||
field initializer. The decision is two lines; the substance it guards
|
||||
(`parseMe`, where a real silent-deny bug shipped — see the WP-66 regression test in
|
||||
`me.adapter.spec.ts:26-31`) is already exported and thoroughly spec'd. Extracting a pure
|
||||
`canFrom(rd, cap)` would be honest but buys close to nothing. Filing it would be volume,
|
||||
not quality.
|
||||
|
||||
## libs/shared — infrastructure
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3a 94.7% line / 81.0% branch, §3b 82% reach — the second-best module in the repo.
|
||||
BL-001 singles out `api-client.provider.ts:49 fetch` (CC 19) as one of only two CC>10
|
||||
functions outside the mandated idioms, so it is worth stating why it is _not_ a
|
||||
testability finding: `httpClientFetch(http: HttpClient)` takes its dependency as an
|
||||
ordinary function parameter (L47) rather than injecting it, and it has a spec
|
||||
(`api-client.provider.spec.ts`). Its complexity is agent 01's call. The module-level
|
||||
mutable `pendingIdempotencyKey` (L21) is self-clearing in a `finally` (L25), so it does
|
||||
not leak between tests.
|
||||
|
||||
## libs/shared — ui
|
||||
|
||||
**No findings — BL-004 governs.** 34 files, 13 reached; the unreached 21 are components
|
||||
covered by the Storybook + a11y strategy CLAUDE.md §5 mandates. The one non-component
|
||||
module in the folder, `rich-text-editor/rich-text-dom.ts` (home of `collect`, CC 11 —
|
||||
the other non-idiom CC>10 function per BL-001), **is** spec'd
|
||||
(`rich-text-dom.spec.ts`). The layer is doing what the house rules ask.
|
||||
|
||||
## libs/shared — layout
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3b 18% reach (2 of 11) is the lowest non-zero row, but 8 of the 9 unreached are
|
||||
components (`shell`, `page-shell`, `site-header`, `site-footer`, `breadcrumb`,
|
||||
`language-switcher`, `wizard-shell`) — BL-004 applies to `layout/` exactly as to `ui/`,
|
||||
since CLAUDE.md §5 titles both under `Design System/`.
|
||||
|
||||
Two non-component files, neither ticketed:
|
||||
|
||||
- `breadcrumb/breadcrumb-trail.ts::trailFor` is a pure exported function with a subtle
|
||||
parent-walk and a `delete trail[last].link` mutation, and has no spec. **No blocker** —
|
||||
it is directly callable, and its sibling `language-switcher/locale-links.ts` is the
|
||||
spec'd proof. Missing test, not blocked test.
|
||||
- `route-focus.ts` is a 20-line `ENVIRONMENT_INITIALIZER` wrapping a `Router` subscription
|
||||
and `afterNextRender`. Genuinely awkward to unit-test, but it is a11y wiring with no
|
||||
branch worth asserting; a seam here would cost more than it returns.
|
||||
|
||||
## libs/shared — kernel
|
||||
|
||||
**No findings.** §3a 96.4% line / 90.0% branch, §3b **100% reach** — the best module in
|
||||
the repo, and (§6) the most-depended-on at I = 5% with Ca 71. Pure functions, all spec'd.
|
||||
This is the reference standard the other findings point back at.
|
||||
|
||||
## libs/shared — upload
|
||||
|
||||
Three findings. This module carries the frontend's weakest testability profile, and
|
||||
BL-010 already flags it as sitting outside the layer convention.
|
||||
|
||||
**TE-003 — `UploadShellService` declares a port, then injects the concrete class instead**
|
||||
|
||||
- Module / file:line — `libs/shared/src/upload/upload-shell.service.ts:12-24` and `:35`
|
||||
- **What blocks unit testing.** The file defines `export interface UploadTransport` and
|
||||
documents it as _the_ swap seam ("swapping it in touches only this interface", L10-11).
|
||||
It then binds it as
|
||||
`private transport: UploadTransport = inject(KeepaliveTransport)` (L35) — the
|
||||
**concrete class**, which is `@Injectable` but **not exported** (L18). A spec that
|
||||
wants a fake transport cannot reference the class to override its provider, and cannot
|
||||
provide against the interface (interfaces are not DI tokens). Result: every one of
|
||||
`upload()`, `delete()`, `cancel()` and `pollReturning()` — the code that translates
|
||||
transport outcomes into `UploadMsg`s — is reachable only through a real
|
||||
`XMLHttpRequest`. The port exists on paper and does nothing.
|
||||
- **Baseline citation.** §3a: `libs/shared/upload` 52.0% line / 50.0% branch — the
|
||||
worst line coverage of any module except the two `auth` rows. §3b: 50% reach, and
|
||||
per the lcov file list the **two unreached files are `upload-shell.service.ts` and
|
||||
`upload-controller.ts`** — neither is a `ui/` component, so this is exactly the
|
||||
non-`ui/` gap BL-004 says is genuine.
|
||||
- **Minimal seam.** Add the token the repo already uses elsewhere:
|
||||
`export const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT',
|
||||
{ providedIn: 'root', factory: () => inject(KeepaliveTransport) })`, then
|
||||
`inject(UPLOAD_TRANSPORT)` on L35. This **extends an existing pattern** — §7 records
|
||||
exactly one explicit port in the frontend, `SessionPort` + `SESSION_PORT`
|
||||
(`libs/shared/src/application/session.port.ts`), with the same interface-plus-token
|
||||
shape. Runtime behaviour is byte-identical; the default factory returns the same
|
||||
instance.
|
||||
- **Effort S.** Independently shippable in one deploy.
|
||||
|
||||
**TE-004 — `createUploadController` performs injection, DOM subscription and an `effect()` at call time**
|
||||
|
||||
- Module / file:line — `libs/shared/src/upload/upload-controller.ts:23-46`, policy at `:62-74`
|
||||
- **What blocks unit testing.** Calling the factory does four irreversible things before
|
||||
returning: three `inject()` calls (L24-25, L46), an `effect()` registration (L31), and
|
||||
`window.addEventListener('focus', onFocus)` (L45). It must therefore run inside a
|
||||
`TestBed` injection context with `UploadAdapter`, `UploadShellService` and `DestroyRef`
|
||||
all satisfied — and `UploadShellService` is itself un-fakeable per TE-003, so the
|
||||
mocking cost compounds. What is trapped behind that cost is real policy:
|
||||
`onFileSelected` (L62-74) decides per file whether to emit `FileRejected` with reason
|
||||
`'multiple'`, `FileRejected` with a `rejectReason` result, or to start an upload — a
|
||||
decision over `(categories, categoryId, files)` with no I/O in it.
|
||||
- **Baseline citation.** §3a `libs/shared/upload` 52.0% / 50.0%; §3b 50% reach with this
|
||||
file among the two unreached. §4a additionally records the module's `max CC 27` and the
|
||||
repo's only two >75-line functions include `reduceUpload` (109 lines) — the reducer this
|
||||
controller feeds. The reducer is spec'd (`upload.machine.spec.ts`); the code choosing
|
||||
_which_ messages reach it is not.
|
||||
- **Minimal seam.** Pure-function split into the file that is already the tested unit:
|
||||
add `export function planFileSelection(state: UploadState, categoryId: string, files:
|
||||
{ name: string; type: string; size: number }[]): UploadMsg[]` to `upload.machine.ts`,
|
||||
moving L62-73 verbatim. `rejectReason` — the predicate it calls — is already exported
|
||||
from that file and already spec'd, so the move is downhill. The controller keeps the
|
||||
`crypto.randomUUID()` + `files.set()` + `shell.upload()` side effects and just executes
|
||||
the plan. No change to the controller's public surface or to the organism that calls it.
|
||||
- **Effort S.** Independently shippable.
|
||||
|
||||
**TE-005 — `UploadAdapter.xhrUpload` buries response interpretation inside an `XMLHttpRequest` closure**
|
||||
|
||||
- Module / file:line — `libs/shared/src/upload/upload.adapter.ts:113-157`, helpers at `:169-199`
|
||||
- **What blocks unit testing.** The method constructs `new XMLHttpRequest()` directly
|
||||
(L118) — no transport parameter, no injected factory — and attaches four listeners
|
||||
whose bodies contain the actual decisions: 2xx-vs-not (L131), `JSON.parse` of the body
|
||||
with a fallback (L132-136), ProblemDetails mapping via the un-exported `parseError`
|
||||
(L193-199), and abort-vs-error disambiguation (L142-144). None of it can be reached
|
||||
without stubbing the XHR global. Compounding it, the method also branches on
|
||||
`currentScenario()` at L115 and returns a `setTimeout`-driven dev simulator
|
||||
(`simulateUpload`, L169-192), so a dev-only fake and the production transport share
|
||||
one entry point.
|
||||
- **Baseline citation.** Per-file lcov: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57
|
||||
(5.3% branch)**. The file is counted as "reached" in §3b only because another spec
|
||||
imports it — essentially nothing in it executes. It is the single largest contributor
|
||||
to the module's 52.0% / 50.0% row in §3a.
|
||||
- **Minimal seam.** Extract the interpretation, not the transport:
|
||||
`export function uploadOutcome(status: number, responseText: string): Result<string,
|
||||
{ documentId: string }>` containing L131-139's logic plus `parseError`. The listener
|
||||
becomes a two-line dispatch into it. Optionally (same ticket, still small) move the
|
||||
`currentScenario()` branch from L115 up into `KeepaliveTransport.send()` — the seam
|
||||
TE-003 makes usable — so `xhrUpload` is transport only. Do **not** abstract
|
||||
`XMLHttpRequest`: the file documents why XHR is required (progress events +
|
||||
cancellation, which `fetch` cannot give) and that reason still holds.
|
||||
- **Effort S** for `uploadOutcome` alone, **M** if the scenario branch moves too.
|
||||
Independently shippable; sequence it after TE-003 if both are taken.
|
||||
|
||||
## libs/shared — testing
|
||||
|
||||
**No findings.** §3a 100% line coverage. `given()` (`machine.ts`) and the `RemoteData`
|
||||
constructors (`remote-data.ts`) are the DSL the domain specs are built on, and the
|
||||
`no-testing-in-production` dependency-cruiser rule (§6) keeps them out of shipped code.
|
||||
The gap this folder does _not_ yet cover is a `resource()`-shaped fake — which is why
|
||||
`AccessStore`/`BigProfileStore` stay unreached — but adding one is a test-infrastructure
|
||||
task, not a source-code seam, and no metric row demands it.
|
||||
|
||||
## libs/beheer
|
||||
|
||||
**TE-006 — blob-to-browser handoff is inlined in three application-layer commands**
|
||||
|
||||
- Module / file:line — `libs/beheer/src/application/stamdata.store.ts:137-147`;
|
||||
also `apps/ssp/src/app/brief/application/brief.store.ts:230` and
|
||||
`apps/ssp/src/app/brief/application/org-template.store.ts:217`
|
||||
- **What blocks unit testing.** Each of the three commands ends in raw DOM/browser API
|
||||
calls that jsdom cannot meaningfully execute: `StamdataStore.download()` does
|
||||
`URL.createObjectURL` → `document.createElement('a')` → `a.click()` →
|
||||
`URL.revokeObjectURL`; `BriefStore.previewLetter()` and
|
||||
`OrgTemplateStore.proefbrief()` both do `window.open(URL.createObjectURL(blob),
|
||||
'_blank')`. Because the call is the **last statement**, the entire success path of each
|
||||
command is unassertable — a spec can only exercise the early-return/failure branches.
|
||||
`brief.store.spec.ts` demonstrates this exactly: it tests `previewLetter`'s failure
|
||||
case (which returns at the `!r.ok` guard) and cannot test the success case. In
|
||||
`download()` the untestable tail sits directly behind a two-clause guard
|
||||
(`if (!s || !this.canDownload()) return;`, L139), so the guard's true-branch is
|
||||
permanently dark.
|
||||
- **Baseline citation.** §3a: `libs/beheer/application` **40.5% branch — the worst
|
||||
branch coverage of any frontend module in the table**, and its 65.7% line figure is
|
||||
third-worst. Per-file lcov confirms `stamdata.store.ts` _is_ that row: LH 46 / LF 70,
|
||||
**BRH 15 / BRF 37**. On the brief side, §3a `ssp/brief` is 68.8% branch and
|
||||
`brief.store.ts` measures **BRH 32 / BRF 64 — exactly 50%**.
|
||||
- **Minimal seam.** One small injectable in `libs/shared/src/application`, mirroring the
|
||||
`SESSION_PORT` token shape already in that folder:
|
||||
`export const BLOB_PRESENTER = new InjectionToken<{ open(b: Blob): void; download(b:
|
||||
Blob, filename: string): void }>('BLOB_PRESENTER', { providedIn: 'root', factory: () =>
|
||||
realBlobPresenter })`. The three commands each lose 1-4 lines of DOM code and gain one
|
||||
method call; specs provide a recording fake and finally assert the success paths
|
||||
(including `toJson(...)`'s output actually reaching the file, which today is only
|
||||
tested one level down in `beheer/domain`). The content-producing logic stays exactly
|
||||
where it is.
|
||||
- **Effort S** (one token + three one-line edits) — **M** including the specs the seam
|
||||
unlocks. Independently shippable; the three call sites can also land separately.
|
||||
|
||||
**Other beheer layers — no findings.**
|
||||
|
||||
- `libs/beheer/contracts` — §3b lists it at **0% reached, 1 file**, and BL-004 names it
|
||||
as a genuine gap. It is not: `stamdata.dto.ts` is 30 lines of `interface` and `type`
|
||||
declarations with **zero executable statements** and, by design, zero imports (it is
|
||||
the wire seam). Like `libs/shared/domain`, this row should be closed rather than
|
||||
ticketed.
|
||||
- `libs/beheer/ui` — 0% reach, 4 files, all components → BL-004 / Storybook.
|
||||
- `libs/beheer/domain` — 98.1% line, spec'd machine and rules. Nothing blocked.
|
||||
- `libs/beheer/infrastructure` — `parseStamdataTable`/`parseColumn`/`parseRows` all
|
||||
exported and spec'd; the 60.5% branch figure is unexercised defensive arms in an
|
||||
otherwise open unit.
|
||||
|
||||
---
|
||||
|
||||
## backend/Program.cs
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3c: 97.4% line / 84.8% branch, the second-best branch figure on the backend. Ten
|
||||
endpoint bodies read the wall clock inline (`DateTimeOffset.UtcNow` at L282, L286, L301,
|
||||
L390, L394, L426, L435, L449, L482, L931; `DateOnly.FromDateTime(DateTime.Today)` at
|
||||
L138), which would normally be a finding — but **every rule and mapper they hand it to
|
||||
already takes the instant as a parameter**: `HerregistratieRule.Evaluate(reg, today)`,
|
||||
`ToDetailDto(a, now)`, `ToStatusDto(a, now)`, `ListCases(now)`,
|
||||
`ApplicationStore.RecordBesluit(..., now)`. The clock-dependent _decisions_ are all
|
||||
testable at any date; only the endpoint wiring is pinned to now, and that wiring is what
|
||||
`EndpointTests`/`AdminCasesTests` legitimately cover through the host. Injecting
|
||||
`TimeProvider` into 48 minimal-API lambdas would be a rewrite, not a seam, and no metric
|
||||
row asks for it.
|
||||
|
||||
BL-003 (940 lines, file CC 78, read/write split by comment banner) is a **structure**
|
||||
finding, explicitly, and belongs to agents 03/04.
|
||||
|
||||
## backend/Domain
|
||||
|
||||
**TE-007 — `LetterHtml.ResolveAuto` reads the wall clock although `Render` is already given the instant**
|
||||
|
||||
- Module / file:line — `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs:138`
|
||||
(resolver) vs. `:27` (signature) and `:49` (the correct usage)
|
||||
- **What blocks unit testing.** `Render(BriefEntity brief, OrgTemplateDto template,
|
||||
string at, bool watermark)` already accepts the letter's instant, and uses it properly
|
||||
for the letterhead: `sb.Append(Enc(FormatDatumNl(at)))` at L49. But the body's
|
||||
`datum` placeholder resolves through `ResolveAuto`, which ignores `at` and calls
|
||||
`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` (L138). `ResolveAuto` is
|
||||
`private static`, reached only via `RenderNode` ← `RenderParagraphs` ← `Render`, so a
|
||||
test has no way to pin the value: it can only assert "whatever today is". The gap is
|
||||
visible in the existing test file — `LetterHtmlTests.cs:26` declares a
|
||||
`new PlaceholderDefDto("datum", "Datum", true)` in the fixture and **no assertion
|
||||
anywhere in the file checks what it renders to**. This is a pure `Domain/` rule class
|
||||
reaching for ambient state, which is precisely the purity §7 credits the folder with.
|
||||
- **Baseline citation.** §3c: `backend/Domain` **82.0% branch** — the third-weakest
|
||||
branch axis (BL-005). §4b: `Domain/Letters/LetterHtml.cs` file **CC 21**, the
|
||||
third-highest-CC file in the backend after `Program.cs` (78) and
|
||||
`Data/ApplicationStore.cs` (27).
|
||||
- **Minimal seam.** Thread the parameter that already exists: `ResolveAuto(string key,
|
||||
string label, string at)` → `"datum" => FormatDatumNl(at)`, passing `at` down through
|
||||
`RenderParagraphs`/`RenderNode` (both private, both already in `Render`'s call chain
|
||||
with `at` in scope). Two signature changes, one expression change, zero public API
|
||||
change, zero call-site change. Then assert the rendered `datum` against a fixed
|
||||
expected string in `LetterHtmlTests`.
|
||||
- **Secondary benefit, stated conservatively.** This is not a shipped bug today — every
|
||||
caller (`Program.cs:697`, `:708`, `BriefStore.cs:120`) passes `Now()` at render time,
|
||||
so the two dates coincide. It becomes one the moment `Render` is called with a
|
||||
historical `at` (re-rendering an archive, back-dating a letter), at which point the
|
||||
letterhead and the body would disagree within a single document.
|
||||
- **Effort S.** Independently shippable.
|
||||
|
||||
No other Domain findings. §7's claim holds under inspection: `SubmissionRules`,
|
||||
`DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`,
|
||||
`HerregistratieRule`, `OrgTemplateRules`, `Authz` and `FeatureFlags` are static classes of
|
||||
pure functions with a matching file in `tests/Domain/`, and the clock-dependent ones take
|
||||
their instant as an argument. That is the correct shape.
|
||||
|
||||
## backend/Data
|
||||
|
||||
**TE-008 — brief state-transition and authorization rules live inside DB-opening, lock-held store methods**
|
||||
|
||||
- Module / file:line — `backend/src/BigRegister.Api/Data/BriefStore.cs`, five guard
|
||||
clusters: `:72-76` (Save), `:88-90` (Submit), `:111-112` (Send), `:162-164`
|
||||
(Approve/Reject shared path), plus the `RequiredFilled(e)` predicate
|
||||
- **What blocks unit testing.** Each guard is a pure decision over
|
||||
`(status tag, actor role, entity completeness)` — e.g. `Save` returns `Forbidden` if
|
||||
`!isDrafter`, `Conflict` unless the status is `draft` or `rejected`, and reopens a
|
||||
`rejected` letter to `draft`; `Submit` additionally requires `RequiredFilled`. But each
|
||||
sits **inside** a method that has already done `lock (_gate) { using var db =
|
||||
Db.Create(); ... }`, so exercising any of them requires a booted host and a real SQLite
|
||||
file. There is no `BriefRules` class: `Domain/Letters/` contains only `LetterHtml.cs`
|
||||
and `OrgTemplateRules.cs`. The pattern is visibly **half-applied** — `Authz.CanActOn`
|
||||
at L163 _is_ a pure `Domain/` call, sitting one line away from three guards that are not.
|
||||
The `Save` guard's own comment says it "mirrors the FE reducer", i.e. it is business
|
||||
logic with a known pure counterpart on the other side of the wire.
|
||||
- **Baseline citation.** §3c: `backend/Data` **75.5% branch** — named in BL-005 as one
|
||||
of the three weak branch axes, against 99.0% line coverage (the exact signature of
|
||||
"every unit is entered, edge branches are not"). §4b: `Data/BriefStore.cs` file
|
||||
**CC 17**, and its `ToDto` at **CC 16** is the highest-CC non-`Program.cs` method in
|
||||
the backend. §5: `backend/Data` 7.7% duplication.
|
||||
- **Minimal seam.** Add `Domain/Letters/BriefRules.cs` with pure statics —
|
||||
`CanSave(BriefStatusDto status, bool isDrafter) → Outcome`,
|
||||
`StatusAfterSave(BriefStatusDto) → BriefStatusDto`,
|
||||
`CanSubmit(status, isDrafter, bool requiredFilled) → Outcome`, `CanSend(status)`,
|
||||
`CanDecide(status, Principal, drafterId)` — and have each store method call one.
|
||||
The store keeps its lock, its `Db.Create()`, its static shape and its signature; only
|
||||
the `if` cascade moves. This **extends the pattern §7 already records** for
|
||||
`SubmissionRules` / `BeoordelingRules` / `OrgTemplateRules` / `DocumentRules`, and adds
|
||||
a `tests/Domain/BriefRuleTests.cs` alongside the seven that exist.
|
||||
- **Explicitly NOT proposed: changing the static-store shape.** `Data/Db.cs:6-12`
|
||||
documents the static, non-DI store decision, and
|
||||
`tests/TestWebApplicationFactory.cs:1-12` states the position outright — "Serializing
|
||||
test classes is the fix, not a redesign of the stores for a test-only concern."
|
||||
TE-008 respects that completely: it is orthogonal, and works _because_ the rules never
|
||||
needed the DbContext in the first place.
|
||||
- **The cost this seam actually pays down.** Because `Db.ConnectionString` is one static
|
||||
field, that same file carries
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]` — **all 241 backend
|
||||
tests run serially, process-wide**, and every brief-rule assertion currently pays a
|
||||
host boot + SQLite file for a decision that is a pure function of two enums. Each rule
|
||||
moved out of `BriefStore` moves a test out of the serialized integration lane into the
|
||||
free-running unit lane. That is the argument for the seam; it is not an argument for
|
||||
touching the stores.
|
||||
- **Effort M** (five extractions + one new test file). Independently shippable, and
|
||||
splittable one method at a time if preferred.
|
||||
|
||||
Two smaller Data notes, neither ticketed: `IdempotencyStore` is the only store that is
|
||||
purely in-memory with no `Reset()` and no TTL, so its dictionary survives
|
||||
`TestWebApplicationFactory` disposal and is shared by every test class in the process —
|
||||
harmless today only because `IdempotencyTests.cs:24` keys on `Guid.NewGuid()`. And
|
||||
`DocumentStore.cs:54` / `AuthzAuditStore.cs:35` stamp `DateTimeOffset.UtcNow` inline
|
||||
while `ApplicationStore.RecordBesluit` correctly takes `now` — an inconsistency, but
|
||||
neither audit timestamp is asserted on, so no metric supports a ticket.
|
||||
|
||||
## backend/Zgw
|
||||
|
||||
**No findings.** §3c 98.1% line / **85.5% branch — the strongest branch figure on the
|
||||
backend**, and §5 records 1.8% duplication. This is the module that was built as
|
||||
ports-and-adapters from the start (ADR-0005): `IZaakSource`/`IDocumentSource` each have
|
||||
two implementations (§7), `ZgwHttpClient` takes an injected `HttpClient`, and
|
||||
`tests/ZgwStubHandler.cs` provides the transport fake — five test files ride on it. It
|
||||
is the backend's worked example of the seam TE-003 asks the upload module for.
|
||||
|
||||
## backend/Contracts
|
||||
|
||||
**No testability findings — but state the gap accurately.**
|
||||
|
||||
§3c records `backend/Contracts` at **65.0% branch, the worst branch figure in the repo**
|
||||
(BL-005 names it first). It is nonetheless not a testability finding: `Mappers.cs` is 79
|
||||
lines of pure `static` extension methods over records, with the clock already injected
|
||||
where it matters (`ToStatusDto(this Aanvraag a, DateTimeOffset now)` at `:52`,
|
||||
`ToSummaryDto(..., now)` at `:68`, `ToDetailDto(..., now)` at `:76`), and `Dtos.cs` is
|
||||
250 lines of `record` declarations. §4b confirms the shape: file CC 4, max method CC 3,
|
||||
the lowest complexity of any backend folder. **Nothing blocks a unit test here.**
|
||||
|
||||
What is missing is a test _file_: `backend/tests/` has `Domain/`, `Acceptance/` and
|
||||
`Builders/` folders but no `Contracts/`, so all 65% is incidental coverage picked up
|
||||
through endpoint tests. That is a coverage ticket for whoever owns coverage, requiring
|
||||
zero source change — and per BL-009 there is no ratchet, so it would have to be verified
|
||||
against §3c's numbers by hand.
|
||||
|
||||
## backend/Stamdata
|
||||
|
||||
**TE-009 — `Professions.ByProgram` freezes its valid-time filter at type-load from `DateTime.Today`**
|
||||
|
||||
- Module / file:line — `backend/src/BigRegister.Api/Stamdata/Professions.cs:25-27`
|
||||
- **What blocks unit testing.** `ByProgram` is a `static readonly IReadOnlyDictionary`
|
||||
whose initializer runs `Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan,
|
||||
m.GeldigTot, DateOnly.FromDateTime(DateTime.Today)))`. Two compounding problems: the
|
||||
peildatum is the ambient wall clock, **and** the result is computed once per process at
|
||||
type-load and then immutable. A test cannot ask "which mappings are active on
|
||||
2030-01-01" — not by arranging state, not by ordering, not at all. The temporal
|
||||
behaviour of the one business-tunable table that has a validity window is therefore
|
||||
unreachable. The file's own comment concedes the consequence: it "preserves the
|
||||
pre-valid-time behaviour exactly **while the file's rows are all current**" — i.e. the
|
||||
`ActiveOn` call is presently a constant-true filter, so both of its interesting
|
||||
branches (not-yet-valid, expired) are dead in every run.
|
||||
- **Baseline citation.** §3c: `backend/Stamdata` 96.8% line but **71.7% branch** —
|
||||
named in BL-005 as the second-weakest branch axis, an exact 25-point line/branch split.
|
||||
§4b: `Stamdata/StamdataTable.cs` file CC 21, joint-third-highest in the backend.
|
||||
This is the rare backend case where "untestable" is defensible against 97.6% line
|
||||
coverage: the lines run, the branches provably cannot.
|
||||
- **Minimal seam.** Add the parameterized overload and define the existing field in terms
|
||||
of it:
|
||||
`public static IReadOnlyDictionary<string,string> ByProgramOn(DateOnly on) => Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)).ToDictionary(...);`
|
||||
then `public static readonly IReadOnlyDictionary<string,string> ByProgram = ByProgramOn(DateOnly.FromDateTime(DateTime.Today));`.
|
||||
**Zero call-site changes** — `DiplomaRules.ProfessionFor` and `All()` keep using
|
||||
`ByProgram`. `StamdataValidationTests` gains the ability to assert both validity-window
|
||||
branches against authored future/expired rows.
|
||||
- **This extends an existing pattern in the same folder.** `StamdataTable.cs:63` already
|
||||
does exactly this — `Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows()`,
|
||||
with `on` as a parameter — and `StamdataFile.ActiveOn(van, tot, on)` (`:36`) is already
|
||||
clock-free. `Professions.cs` is the one caller that swallows the parameter.
|
||||
- **Effort S.** Independently shippable; additive only.
|
||||
|
||||
## backend/tests
|
||||
|
||||
**No findings.** 39 files, 4 253 lines, 241 green tests, every backend source file
|
||||
reached (§3c). The suite already carries the fixtures a unit lane needs
|
||||
(`Builders/AanvraagBuilder.cs`, `ZgwStubHandler.cs`, `TestWebApplicationFactory` with
|
||||
per-class throwaway SQLite files).
|
||||
|
||||
The one structural observation is not a defect to fix here:
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
|
||||
(`TestWebApplicationFactory.cs:12`) serializes the entire suite because
|
||||
`Db.ConnectionString` is a process-global. The repo reached that decision deliberately
|
||||
and documented the race it prevents. Rather than reopen it, TE-008 and TE-009 reduce how
|
||||
much _needs_ to run in that serialized lane. Note also §4b's outlier in this folder: a
|
||||
**293-line test method at CC 20**
|
||||
(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`,
|
||||
`OpenZaakZaakSourceTests.cs`) — a test-readability item for agent 01, not a testability
|
||||
seam.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Title | Module | Blocker | Baseline | Effort | 1 deploy |
|
||||
| ------ | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ------ | -------- |
|
||||
| TE-001 | `SessionStore.restore()` reads `localStorage` inline | ssp/auth + bhp/auth | hidden I/O in a field initializer; guard is module-private | §3a 42.9%/46.2% (worst line); file LH 2/20, BRH 3/13 | S ×2 | yes |
|
||||
| TE-002 | Trust boundary hidden inside a global-`fetch` method | ssp/brief | un-exported shape validation behind `await fetch` | §3b 42% reach; §3a 68.8% branch | S | yes |
|
||||
| TE-003 | `UploadTransport` port declared, concrete class injected | libs/shared/upload | `inject(KeepaliveTransport)`; class not exported | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes |
|
||||
| TE-004 | `createUploadController` injects + binds `window` at call time | libs/shared/upload | 3× `inject()`, `effect()`, `addEventListener` before returning | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes |
|
||||
| TE-005 | `xhrUpload` interprets responses inside an XHR closure | libs/shared/upload | `new XMLHttpRequest()` hard-coded; dev simulator shares the method | file LH 5/64 (7.8%), BRH 3/57 (5.3%) | 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
## Scope: [to be filled by agent]
|
||||
|
||||
## Status: not_started
|
||||
|
||||
## Last updated: -
|
||||
|
||||
## Depends on: [see agent prompt]
|
||||
|
||||
## ---
|
||||
@@ -0,0 +1,543 @@
|
||||
## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata)
|
||||
|
||||
## Status: complete
|
||||
|
||||
## Last updated: 2026-08-26
|
||||
|
||||
## Depends on: 00-baseline.md
|
||||
|
||||
## ---
|
||||
|
||||
# 04 — CQRS-light: command/query separation at the application-service level
|
||||
|
||||
**Mandate reminder, applied literally.** This agent may only _extend_ CQRS-light where
|
||||
baseline §7 records it already exists. It may not introduce it. Every finding below names
|
||||
the concrete existing artifact it extends. Three things the baseline flagged as tempting
|
||||
are therefore **not** filed as tickets — they are in "Out of mandate (pattern absent)" at
|
||||
the end.
|
||||
|
||||
**What "the pattern" concretely is in this repo** (from §7 + CLAUDE.md §3, so later
|
||||
sections can just point at it):
|
||||
|
||||
| Side | The existing artifact |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FE query | `resource({ loader })` in an `infrastructure/*.adapter.ts` + a `parse*` boundary + a `providedIn:'root'` read store exposing `RemoteData` |
|
||||
| FE command | `application/submit-*.ts` command factory → `runSubmit(fn, fallback)` → `Result<string,T>` → the caller dispatches a Msg |
|
||||
| FE fold | `libs/shared/src/application/submit.ts` — the single try/catch + ProblemDetails → error-string fold, **and** the Idempotency-Key mint point |
|
||||
| BE | `Contracts/Dtos.cs` direction split (`*Request` in / `*Dto`+`*Response` out); read/write comment banners in `Program.cs` |
|
||||
| BE read-mdl | `ToDetailDto(now)` / `ToDto(now)` — the read side _projects_ status from timestamps rather than the write side storing it |
|
||||
|
||||
**The cleanest module in the repo is `bhp/behandeling`**, and it is worth naming up front
|
||||
because three findings below propose making another module look like it: it splits its
|
||||
query adapter (`beoordeling.adapter.ts` `get`, `werkvoorraad.adapter.ts` `list`) from its
|
||||
command adapter (`besluit.adapter.ts` `besluit`) into **separate files**, wraps only the
|
||||
command in a command factory (`application/submit-besluit.ts`), and keeps the read store
|
||||
(`beoordeling.store.ts`, `werkvoorraad.store.ts`) write-free. Its backend counterpart does
|
||||
the same: `Program.cs:441` "read side only" banner, `Program.cs:464` the write banner. That
|
||||
is the target shape, and it is already in the tree.
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — auth
|
||||
|
||||
**No findings.**
|
||||
|
||||
`SessionStore.login` (`apps/ssp/src/app/auth/application/session.store.ts:58`) is a write
|
||||
and `session`/`isAuthenticated` are reads, but they share one three-field aggregate with no
|
||||
network read path at all — `DigidAdapter.authenticate` is the only I/O and it is a command.
|
||||
There is nothing to separate. §7 lists no command factory or read adapter in this context to
|
||||
extend. BL-002 is agent 06's, not this agent's.
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — registratie
|
||||
|
||||
### CQ-001 — `createDraftSync` is registered as a command factory but owns three query paths
|
||||
|
||||
- **Module / file:line** — `apps/ssp/src/app/registratie/application/draft-sync.ts:50-236`
|
||||
(queries at `:141 load`, `:160 findConcept`, `:179 resume`; commands at `:63 ensureId`,
|
||||
`:100 flush`, `:213 submit`, `:221 reset`)
|
||||
- **Extends** — the command-factory idiom itself. §7 counts `draft-sync.ts` as one of the
|
||||
repo's **3 command factories**, alongside `submit-change-request.ts` (18 lines, one
|
||||
command, zero reads) and `submit-besluit.ts` (16 lines, one command, zero reads). This
|
||||
finding asks the third member of that set to look like the other two.
|
||||
- **Baseline citation** — §7 Frontend, "Command factories (write side) | 3"; §4a metric
|
||||
row **`createDraftSync` 143 lines** (the largest function in the codebase, tied to
|
||||
`reduceUpload`'s 109 only in the two-member `fn>75` population); §9 threshold "TS
|
||||
function > 40 lines".
|
||||
- **The mixing, concretely** — the factory returns four members. `resume()` is pure query
|
||||
orchestration: read `?aanvraag`, `adapter.detail(linked)`, else `findConcept()` →
|
||||
`adapter.list()` → `parseApplications`. `submit()`/`reset()`/the debounce `effect` are
|
||||
writes. They are entangled through three pieces of shared mutable closure state — `id`,
|
||||
`ensuring`, and `resumeGate` (`:56-61`) — where `resumeGate` exists _only_ so the write
|
||||
path (`ensureId`) can wait for the read path (`resume`) to finish. That coupling is
|
||||
genuine and load-bearing, which is exactly why it is worth naming rather than leaving as
|
||||
an unexplained 143-line function.
|
||||
- **Proposed change, minimal** — extract the read half into
|
||||
`application/find-concept.ts`: `findConcept(adapter, type)` and `loadConcept(adapter, id)`
|
||||
as free functions taking the adapter (no `inject`, so they get a direct spec —
|
||||
`draft-sync.spec.ts` already exists and would shrink). `createDraftSync` keeps `resumeGate`
|
||||
and the write path and calls them. This is a move, not a redesign; the closure state stays
|
||||
where it is.
|
||||
- **Effort** — M. Independently shippable in one deploy (no wire change, no DTO change).
|
||||
|
||||
### CQ-002 — two read stores perform writes that bypass the `runSubmit` fold
|
||||
|
||||
- **Module / file:line** — `apps/ssp/src/app/registratie/application/applications.store.ts:54-64`
|
||||
(`cancel`) and `apps/ssp/src/app/registratie/application/admin-cases.store.ts:46-56`
|
||||
(`delete`); the adapter methods are
|
||||
`infrastructure/applications.adapter.ts:60 cancel` and `:41 deleteAny`.
|
||||
- **Extends** — `runSubmit` + `SUBMIT_FAILED` (`libs/shared/src/application/submit.ts:15,28`),
|
||||
the fold that all 16 other mutations in the repo pass through, and the
|
||||
`createSubmitChangeRequest` command factory that lives _in this same folder_
|
||||
(`registratie/application/submit-change-request.ts`) and does exactly this for the other
|
||||
registratie write.
|
||||
- **Baseline citation** — **BL-007** ("the FE write side is inconsistently placed");
|
||||
§7 Frontend, "Command factories | 3" vs "Mutations living inline in adapters | ~13".
|
||||
Note these two are a _fourteenth and fifteenth_ case BL-007 did not enumerate: they are
|
||||
worse than the ~13, because those at least reach `runSubmit` inside the adapter — these
|
||||
reach the raw `ApiClient` and never produce a `Result` at all.
|
||||
- **The mixing, concretely** — both stores own a `RemoteData` read signal _and_ a write, and
|
||||
the write's failure path is `catch { this.state.set(before); }` — a bare rollback with no
|
||||
error channel. A failed cancel makes the row silently reappear with no message, no
|
||||
`ActionState`, no ProblemDetails `detail`. `BriefStore`/`OrgTemplateStore` in the sibling
|
||||
context both hold an `ActionState` + `lastError` for exactly this. The bare
|
||||
`adapter.cancel()` also means the `Idempotency-Key` on the wire is a fresh UUID minted per
|
||||
HTTP attempt by `api-client.provider.ts:58`, not the per-logical-submit key `runSubmit`
|
||||
promises at `submit.ts:11-13` — that invariant's docstring is currently false for these
|
||||
two calls (harmless today: `Program.cs` only honours the header inside the `Submit` helper,
|
||||
see CQ-005's note).
|
||||
- **Proposed change, minimal** — route both through `runSubmit` and surface the error.
|
||||
Two options, pick one and apply to both stores identically:
|
||||
(a) smallest — `const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
|
||||
if (!r.ok) { this.state.set(before); this.error.set(r.error); }` plus one `error` signal;
|
||||
(b) fuller — a `application/cancel-application.ts` command factory mirroring
|
||||
`submit-change-request.ts`, which the store injects. (a) is enough to close the finding.
|
||||
- **Effort** — S. Independently shippable; (a) touches 2 files plus a UI line each to render
|
||||
the error.
|
||||
|
||||
### Not filed — `applications.adapter.ts` mixes 3 reads and 5 writes in one file
|
||||
|
||||
`infrastructure/applications.adapter.ts:31-66` holds `list`/`listAll`/`detail` next to
|
||||
`create`/`syncDraft`/`cancel`/`deleteAny`/`submit`, where `bhp/behandeling` splits the
|
||||
equivalent into `beoordeling.adapter.ts` + `besluit.adapter.ts`. The split would be the
|
||||
structural enabler for CQ-002(b). On its own, though, it moves 8 thin one-line
|
||||
`this.client.x()` wrappers between files and changes nothing observable — file placement is
|
||||
agent 03's axis, not a read/write-mixing defect. Noted here so it is a deliberate omission
|
||||
rather than a miss; fold it into CQ-002 if that ticket takes option (b).
|
||||
|
||||
### Not filed — `BigProfileStore`
|
||||
|
||||
`application/big-profile.store.ts` is the reference implementation of the split and needs no
|
||||
change: reads are two resources projected through `parseDashboardView`, and the only
|
||||
write-adjacent members are the `beginHerregistratie`/`confirmHerregistratie`/
|
||||
`rollbackHerregistratie` invalidation hooks (`:65-74`) — the store never performs the write
|
||||
itself, `createDraftSync.submit` does. ADR-0001's "Out of scope" section already records the
|
||||
optimistic-flag race; that is a correctness note, not a CQRS-light one.
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — herregistratie
|
||||
|
||||
**No findings.**
|
||||
|
||||
`IntakePolicyStore` (`application/intake-policy.store.ts`) is a pure query facade over one
|
||||
`resource()`. The context has no write of its own — its submit is `createDraftSync.submit`,
|
||||
owned by `registratie` and covered by CQ-001. `intake.machine.ts` / `herregistratie.machine.ts`
|
||||
are reducers; §3's "side effects stay out of the reducer" is honoured (verified: no `inject`,
|
||||
no adapter import in either).
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — brief
|
||||
|
||||
### CQ-003 — `runSubmit` (the write-side fold, incl. the Idempotency-Key mint) is used for reads
|
||||
|
||||
- **Module / file:line** — `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:56`
|
||||
(`load` → `briefGET`), `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:39`
|
||||
(`list` → `orgTemplates`) and `:51` (`load` → `orgTemplateGET`). Same defect in
|
||||
libs/beheer — see that section; one ticket should fix all five call sites.
|
||||
- **Extends** — the two halves already present in `libs/shared/src/application/submit.ts`:
|
||||
the ProblemDetails→`Result` **fold** (which reads legitimately want) and the
|
||||
**`withIdempotencyKey` wrapper** (which is write-only by construction). The read idiom it
|
||||
should join is the one the other 6 read adapters use — `resource({ loader })` +
|
||||
`parse*` — or, for these imperative reads, the fold alone.
|
||||
- **Baseline citation** — **BL-007**; §7 Frontend "Infrastructure adapters (read side) | 20"
|
||||
and "Mutations living inline in adapters | ~13" — these three GETs are counted in the
|
||||
wrong column of that inventory, because the code cannot tell them apart from the writes
|
||||
beside them.
|
||||
- **The mixing, concretely** — `submit.ts:11-13` states `runSubmit` is "the one place a
|
||||
logical submit's Idempotency-Key is minted — once per `runSubmit` call". Each of these
|
||||
reads therefore mints a UUID and assigns the module-level `pendingIdempotencyKey`
|
||||
(`api-client.provider.ts:21-26`) for the duration of a GET. It is inert today: the header
|
||||
is attached only when `method !== 'GET'` (`api-client.provider.ts:58`). But
|
||||
`api-client.provider.ts:15-19` explicitly documents that the module-level variable "holds
|
||||
up because every submit command calls its adapter synchronously (no await before reaching
|
||||
this file)". Reads have no such discipline — `BriefStore.load()` and
|
||||
`OrgTemplateStore.load()` are awaited across `await`s — so routing reads through the write
|
||||
helper quietly widens the assumption that comment relies on. Reading `brief.adapter.ts:55-92`
|
||||
it is also simply impossible to see which of the seven methods are commands: all seven are
|
||||
`await runSubmit(...) → parseBriefView`.
|
||||
- **Proposed change, minimal** — split `submit.ts` in place, no new concept:
|
||||
`runResult(fn, fallback)` = the existing try/catch + `problemDetail` fold;
|
||||
`runSubmit(fn, fallback)` = `runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback)`.
|
||||
Point the 5 reads at `runResult`. Zero behaviour change, and afterwards the keyword at each
|
||||
call site states the side. `submit.spec.ts` already exists and covers the fold.
|
||||
- **Effort** — S. Independently shippable in one deploy; 1 shared file + 3 adapters (+2 in
|
||||
libs/beheer).
|
||||
|
||||
### Not filed — `BriefStore` and `OrgTemplateStore` own both the read path and the write commands
|
||||
|
||||
`application/brief.store.ts` holds `load()` alongside `save`/`submit`/`approve`/`reject`/
|
||||
`send`/`resetDemo`/`revealBigNummer`; `application/org-template.store.ts` holds
|
||||
`load`/`selectSubOrg` alongside `flushSave`/`confirmPublish`/`rollback`. BL-007 points at
|
||||
these as "mutations living inline in adapters", and it is tempting to file them.
|
||||
|
||||
They are deliberately not filed, and the reason matters for whoever reads this next.
|
||||
CLAUDE.md §3 defines a command as "does the HTTP, then dispatches a message describing the
|
||||
outcome" — and that is precisely what `BriefStore.transition()` (`:248-259`) and
|
||||
`OrgTemplateStore.confirmPublish()` (`:175-189`) do: `ActionState → Busy`, cancel the
|
||||
debounce, call the adapter, then `store.dispatch(...)` or `actionState.set(Failed)`. The
|
||||
reducer stays pure. These stores _are_ the command layer; they are not a store that
|
||||
accidentally grew writes. Both also implement the write→invalidate-read handoff correctly
|
||||
(`confirmPublish` reloads via `selectSubOrg`, mirroring `BigProfileStore.confirmHerregistratie`'s
|
||||
`viewRes.reload()`). Extracting six `createSubmitX()` factories out of `BriefStore` would move
|
||||
code without changing which layer performs which effect. **The real defect in these two files
|
||||
is CQ-003, and that is filed.**
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — showcase, shell, root
|
||||
|
||||
**No findings.** `showcase/concepts.page.ts`, `app.ts`, `app.config.ts`, `app.routes.ts`
|
||||
contain no application services, no adapter calls and no state writes — routing, providers
|
||||
and a teaching page. §7 lists no pattern here to extend. (Their 0% spec reach in §3b is
|
||||
agent 02's; their `bhp/root` duplication in §5 is agent 01's.)
|
||||
|
||||
---
|
||||
|
||||
## apps/behandelportal — auth
|
||||
|
||||
**No findings.** Identical to ssp/auth by BL-002; the same reasoning applies.
|
||||
|
||||
---
|
||||
|
||||
## apps/behandelportal — behandeling
|
||||
|
||||
**No findings — this module is the reference implementation.**
|
||||
|
||||
Stated positively so later phases do not "clean it up" into something worse:
|
||||
`werkvoorraad.adapter.ts` (`list`) and `beoordeling.adapter.ts` (`get`) are query-only files;
|
||||
`besluit.adapter.ts` (`besluit`) is a command-only file; `submit-besluit.ts` is the command
|
||||
factory; `werkvoorraad.store.ts` and `beoordeling.store.ts` contain no writes at all — not
|
||||
even a rollback. `besluit-form.component.ts:88` holds the command
|
||||
(`private submit = createSubmitBesluit()`), never the adapter. §3a records the highest FE
|
||||
line coverage of any feature context here (91.6%), which is consistent with the split: the
|
||||
read stores are trivially testable because nothing writes through them.
|
||||
|
||||
---
|
||||
|
||||
## apps/behandelportal — shell, root
|
||||
|
||||
**No findings.** Same as ssp/shell+root.
|
||||
|
||||
---
|
||||
|
||||
## libs/shared — application
|
||||
|
||||
### CQ-004 — `FeatureFlagStore.set` writes without the fold and drops the error entirely
|
||||
|
||||
- **Module / file:line** — `libs/shared/src/application/feature-flags.store.ts:53-59`;
|
||||
adapter at `libs/shared/src/infrastructure/feature-flags.adapter.ts:18`; caller at
|
||||
`libs/beheer/src/ui/feature-flags.page.ts:93`.
|
||||
- **Extends** — `runSubmit`/`SUBMIT_FAILED` (`libs/shared/src/application/submit.ts`), which
|
||||
lives in this very folder — the store sits three files away from the fold it skips.
|
||||
- **Baseline citation** — **BL-007**; §7 Frontend "Mutations living inline in adapters | ~13"
|
||||
(this is another case not in BL-007's enumeration, which named only `brief.adapter.ts`,
|
||||
`org-template.adapter.ts` and `stamdata.adapter.ts`).
|
||||
- **The mixing, concretely** — the store owns the read (`load`, `flags`, `all`, `enabled`)
|
||||
and the admin write. The write is `try { await this.adapter.set(...) } finally { await this.load() }`
|
||||
— **no `catch`**. The rejection propagates out of `set()`; the caller is
|
||||
`void this.store.set(key, enabled)` (`feature-flags.page.ts:93`), so a failed toggle
|
||||
becomes an unhandled promise rejection. The admin sees the switch flick back after the
|
||||
`finally`'s reload with no explanation and no ProblemDetails `detail`, on a write gated by
|
||||
`flags:manage` that is exactly the kind an operator needs confirmation of. Every other
|
||||
write in the repo that goes through `runSubmit` gets `problemDetail(e, fallback)`.
|
||||
- **Proposed change, minimal** — `const r = await runSubmit(() => this.adapter.set(key, enabled), SUBMIT_FAILED);
|
||||
await this.load(); if (!r.ok) this.error.set(r.error);` with one `error` signal rendered by
|
||||
`feature-flags.page.ts`. Keep the reload unconditional (it is the read-side invalidation and
|
||||
is correct).
|
||||
- **Effort** — S. Independently shippable; 2 files.
|
||||
|
||||
### Not filed — the rest of the layer
|
||||
|
||||
`access.store.ts` (query-only over `/me`), `remote-data.ts`, `store.ts`, `action-state.ts`,
|
||||
`debounced-save.ts`, `history.ts`, `machine-remote-data.ts`, `pending-saves.ts`,
|
||||
`session.port.ts` are the read/state kit itself, not services. `submit.ts` is the subject of
|
||||
CQ-003 rather than a finding of its own.
|
||||
|
||||
---
|
||||
|
||||
## libs/shared — infrastructure
|
||||
|
||||
**No findings.** `me.adapter.ts` and `feature-flags.adapter.ts` are correctly
|
||||
direction-labelled (`list` vs `set`); `api-client.provider.ts` is the single HTTP seam and
|
||||
already gates the Idempotency-Key on `method !== 'GET'`, i.e. the _transport_ layer honours
|
||||
the command/query split that CQ-003 shows the _application_ layer blurring. The CC-19 `fetch`
|
||||
there is BL-001's "outside the idiom" case and belongs to agent 01.
|
||||
|
||||
---
|
||||
|
||||
## libs/shared — upload
|
||||
|
||||
**No findings.**
|
||||
|
||||
`upload.adapter.ts` mixes reads (`categoriesResource`, `status`) and writes (`xhrUpload`,
|
||||
`deleteDocument`), and `upload-shell.service.ts` mixes `upload`/`delete`/`cancel` (commands)
|
||||
with `pollReturning` (a query). It is tempting to file, and it is deliberately not: **BL-010**
|
||||
records that this whole folder sits outside the layer convention by design and is carved out
|
||||
by name in the `apiclient-infrastructure-only` dependency-cruiser rule. §7 lists no adapter
|
||||
or command factory here to extend — `upload.adapter.ts` is explicitly footnoted as the
|
||||
adapter that is _not_ in an `infrastructure/` folder. Resolving BL-010 (agent 03's call) has
|
||||
to come first; a read/write split layered on top of an already-exceptional layout would
|
||||
entrench the exception. Worth noting for whoever takes BL-010: `UploadShellService` is
|
||||
otherwise the FE's most complete command implementation — every method takes a `Dispatch` and
|
||||
reports its outcome as a Msg, which is the CLAUDE.md §3 shape done exactly right.
|
||||
|
||||
---
|
||||
|
||||
## libs/shared — domain, contracts, kernel, ui, layout, testing, environments
|
||||
|
||||
**No findings.** No application services, no adapters, no writes. `kernel/fp.ts`'s `Result`
|
||||
is the return type the command side is built on, not a service.
|
||||
|
||||
---
|
||||
|
||||
## libs/beheer
|
||||
|
||||
### CQ-005 — both stamdata reads run through `runSubmit`, in a file that documents itself as write-free
|
||||
|
||||
- **Module / file:line** — `libs/beheer/src/infrastructure/stamdata.adapter.ts:27` (`list`)
|
||||
and `:42` (`load`).
|
||||
- **Extends** — the same `submit.ts` split proposed in CQ-003. **Fix them in one ticket**;
|
||||
they are listed separately only because the module scope requires it.
|
||||
- **Baseline citation** — **BL-007** (which names `stamdata.adapter.ts` explicitly among the
|
||||
~13 "mutations living inline in adapters"); §7 Frontend "Infrastructure adapters (read
|
||||
side) | 20".
|
||||
- **The mixing, concretely** — sharper here than anywhere else in the repo, because the
|
||||
file's own docstring (`:16-20`) says: _"Both endpoints are reads … There is no write method
|
||||
— the edit is downloaded and lands as a PR."_ Both nevertheless call `runSubmit`. BL-007
|
||||
counts this adapter on the write side of the inventory on the strength of that call, when
|
||||
the module is in fact the repo's only genuinely CQRS-clean context: `StamdataStore` has no
|
||||
write command at all (`download()` at `:137` is a local `Blob` + anchor click, zero
|
||||
network), and `AuditStore` is read-only. The tooling and the baseline both mis-classify
|
||||
this module purely because of the helper name.
|
||||
- **Proposed change, minimal** — point both at `runResult` per CQ-003. Nothing else in this
|
||||
library changes.
|
||||
- **Effort** — S. Ships with CQ-003 in the same deploy.
|
||||
|
||||
### Not filed — `libs/beheer` application/domain/ui/contracts otherwise
|
||||
|
||||
`stamdata.store.ts` and `audit.store.ts` are query-only (see above). `stamdata-editor.machine.ts`
|
||||
is a pure reducer. The `ui/` layer holds no adapter calls.
|
||||
|
||||
---
|
||||
|
||||
## backend — Program.cs
|
||||
|
||||
### CQ-006 — the read/write banner split is established, honoured once, then abandoned for 5 of 7 feature sections
|
||||
|
||||
- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs`. Banners at `:133`
|
||||
("GET: screen-shaped reads"), `:185` ("POST: submits"), `:441` ("read side only") and
|
||||
`:464` ("record a behandelaar's decision"). Mixed sections: `:197` Document upload,
|
||||
`:275` Applications, `:598` Brief, `:721` Organization templates, and admin-cases split
|
||||
non-contiguously across `:424`, `:554`, `:566`.
|
||||
- **Extends** — the banner convention _inside this file_, specifically the WP-65 pair at
|
||||
`:441`/`:464`, which already splits one feature's query endpoints from its command endpoint
|
||||
under two banners. This proposal applies the `:441`/`:464` treatment to the five sections
|
||||
that predate it. **No handler types, no mediator, no `Features/` folders** — see the
|
||||
out-of-mandate section for why that larger move is not proposed here.
|
||||
- **Baseline citation** — **BL-003** (940 lines, 48 endpoints, file CC 78 vs next-highest 27,
|
||||
"read/write separated only by comment banner"); §7 Backend CQRS-light row ("Read/write
|
||||
split exists as _comment banners_"); §3c `Program.cs` 97.4% line / 84.8% branch.
|
||||
- **The mixing, concretely** — the file opens by declaring direction as its organising
|
||||
principle (`:133` reads, `:185` writes), then from `:197` switches to feature grouping
|
||||
without saying so, and every subsequent section interleaves:
|
||||
| Section | Line | Reads | Writes |
|
||||
| -------------------------------------------------------------------------------------------- | ------------: | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Document upload | 197 | `GET /uploads/categories`, `GET /uploads/{id}/content`, `GET /uploads/status` | `POST /uploads`, `DELETE /uploads/{id}`, `DELETE /admin/uploads/{id}` |
|
||||
| Applications | 275 | `GET /applications`, `GET /applications/{id}` | `POST`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/submit` |
|
||||
| Admin cases | 424, 554, 566 | `GET /admin/cases`, `GET /admin/audit` | `DELETE /admin/cases/{id}` — **129 lines away** from its list, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in between |
|
||||
| Brief | 598 | `GET /brief`, `GET /brief/preview` | `PUT /brief`, `POST submit/approve/reject/send/reveal-bignummer/reset` |
|
||||
| Org templates | 721 | `GET /admin/org-templates`, `GET /admin/org-template/{id}` | `PUT /{id}`, `POST /{id}/publish`, `POST /{id}/rollback` |
|
||||
| `GET /admin/org-template/{subOrgId}/preview` (`:703`) is additionally filed under the Brief |
|
||||
| banner rather than the Org-templates one. The consequence is not a bug — §3c confirms the |
|
||||
| file is well tested, and this is a **structure finding, not a correctness one** — it is that |
|
||||
| a reader cannot answer "what can mutate state here?" without reading all 940 lines, and that |
|
||||
| the two cross-cutting write wrappers (`Submit`'s idempotency replay, `RecordZgwDivergence`) |
|
||||
| have no visible scope. |
|
||||
- **Proposed change, minimal** — within each existing feature section, order reads first then
|
||||
writes and insert the `:441`/`:464`-style sub-banners; move `DELETE /admin/cases/{id}`
|
||||
(`:554`) and `GET /admin/audit` (`:566`) up beside `GET /admin/cases` (`:424`); move the
|
||||
org-template preview from the Brief section to the org-template one. **Pure reordering and
|
||||
comments** — no signature, route, DTO or behaviour change, so §3c's 97.4%/84.8% coverage is
|
||||
the regression net and the diff is reviewable line-for-line.
|
||||
- **Effort** — S. Independently shippable in one deploy. One caveat for whoever schedules it:
|
||||
it is a large-diff/zero-semantic-change commit, so land it alone, never mixed with a
|
||||
behaviour change.
|
||||
|
||||
### CQ-007 — `GET /brief` creates a brief, though the explicit create command already exists
|
||||
|
||||
- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs:603`
|
||||
(`api.MapGet("/brief", …)`) → `backend/src/BigRegister.Api/Data/BriefStore.cs:50`
|
||||
(`GetOrCreate` — `db.Briefs.Add(created); db.SaveChanges();`).
|
||||
- **Extends** — the command/query direction split that `Contracts/Dtos.cs` encodes
|
||||
(`*Request` in / `*Dto` out) and that the banners at `:133`/`:185` state as the file's
|
||||
premise; and concretely, **`POST /brief/reset` (`:712` → `BriefStore.ResetAndCreate`)** —
|
||||
the create-a-fresh-brief command already exists as a POST. The write half of `GetOrCreate`
|
||||
has a command counterpart; the query does not need to duplicate it.
|
||||
- **Baseline citation** — **BL-003**; §7 Backend CQRS-light row.
|
||||
- **The mixing, concretely** — this is the only endpoint in the backend where a GET performs
|
||||
a persisted write. Everything else respects the direction, and notably the read side goes
|
||||
out of its way to _avoid_ writing: `ToDetailDto(DateTimeOffset.UtcNow)` /
|
||||
`ToDto(now)` project Concept → InBehandeling → Goedgekeurd from stored timestamps on every
|
||||
read rather than mutating a status column (`Program.cs:284`, `Data/AanvraagMapper.cs`), which
|
||||
is a textbook CQRS read-model projection and the strongest evidence the convention is
|
||||
intended. `GET /brief` breaks it: a plain read is non-idempotent on first call, allocates a
|
||||
row, and — since the FE retries GETs automatically (`api-client.provider.ts:66`,
|
||||
`retry({ count: 2, delay: 500 })`, GET-only, precisely because GETs are assumed safe) —
|
||||
a transient failure can enter the create path more than once. `BriefStore.GetOrCreate` is
|
||||
`lock`-guarded so no duplicate row results today; the objection is that the safety depends
|
||||
on the lock rather than on the endpoint being a query.
|
||||
- **Proposed change, minimal** — `GET /brief` returns 404 when no brief exists for the owner;
|
||||
`BriefStore.GetOrCreate` splits into `Get` (query) and the existing `ResetAndCreate` (already
|
||||
there). `BriefStore.load()` on the FE (`brief.adapter.ts:55`) treats 404 by calling the
|
||||
existing `reset()` command once. **This is the least certain finding in this file** and the
|
||||
only one with a behaviour change: it costs one extra round-trip on a first visit and touches
|
||||
the brief tests. If the demo-seeding convenience is judged to outweigh the principle, the
|
||||
acceptable alternative is to leave the code alone and add one line at `:603` saying the GET
|
||||
seeds on first call — the defect is as much that it is undocumented as that it exists.
|
||||
- **Effort** — M. Independently shippable, but FE and BE must land together (the 404 contract),
|
||||
so it is the one finding here that is not a single-side deploy.
|
||||
|
||||
---
|
||||
|
||||
## backend — Domain
|
||||
|
||||
**No findings.** `Domain/` is static classes of pure functions (`SubmissionRules`,
|
||||
`IntakePolicy`, `BeoordelingRules`, `HerregistratieRule`, `OrgTemplateRules`, `Authz`,
|
||||
`FeatureFlags`, `LetterHtml`, `DiplomaRules`, `DocumentRules`) with no persistence and no I/O —
|
||||
verified EF-free and ASP-free per §7. A pure decision function has no read/write axis to
|
||||
separate. `Domain/Applications/Aanvraag.cs`'s `Concept`/`Submitted`/`Decided` tagged union is
|
||||
the write model; §3c records 94.2% line coverage.
|
||||
|
||||
---
|
||||
|
||||
## backend — Data
|
||||
|
||||
**No findings within mandate.** The seven static stores (`ApplicationStore`, `DocumentStore`,
|
||||
`BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`)
|
||||
each expose reads and writes on one type — `ApplicationStore` alone has 6 reads
|
||||
(`Get`/`List`/`GetAny`/`GetByReferentie`/`ListAll` + `ToDetailDto`) and 7 writes. That is
|
||||
ordinary repository design, and §7 records these as "Not behind any port … Deliberate,
|
||||
documented in `Data/Db.cs`". Splitting them into read/write repositories would be
|
||||
_introducing_ the pattern into a module where §7 records it absent — out of mandate. See the
|
||||
out-of-mandate section. One observation to hand on rather than file: `AanvraagMapper` /
|
||||
`ToDetailDto(now)` is a real read-model projection and is cited approvingly in CQ-007; do not
|
||||
let a future ticket "simplify" it into a stored status column.
|
||||
|
||||
---
|
||||
|
||||
## backend — Zgw
|
||||
|
||||
**No findings.** `OpenZaakZaakSource` / `OpenZaakDocumentSource` implement ports whose
|
||||
interfaces (`IZaakSource`, `IDocumentSource`) already separate by operation
|
||||
(`ListMyCases`/`ListCases` vs `CreateZaak`), and §7 records the ACL as "Fully built" under
|
||||
ADR-0005. §3c gives it the second-highest branch coverage in the backend (85.5%). Nothing to
|
||||
extend.
|
||||
|
||||
---
|
||||
|
||||
## backend — Contracts
|
||||
|
||||
**No findings.** `Contracts/Dtos.cs`'s 65 records split by direction is the backend's
|
||||
strongest CQRS-light artifact and is cited as the pattern several findings above extend.
|
||||
§3c's 65.0% branch coverage (the backend's weakest, per **BL-005**) is agent 02's axis, not
|
||||
this one — a DTO record has no read/write mixing to fix.
|
||||
|
||||
---
|
||||
|
||||
## backend — Stamdata
|
||||
|
||||
**No findings.** Config-as-code tables (ADR-0004), validated at build by
|
||||
`StamdataValidationTests`, never runtime-editable — read-only by definition. Its only
|
||||
endpoints (`Program.cs:164`, `:173`) are both GETs behind the `StamdataAdmin` gate, correctly
|
||||
placed under the reads banner at `:158`. Together with `libs/beheer` (CQ-005) this is the
|
||||
cleanest end-to-end query slice in the repo.
|
||||
|
||||
---
|
||||
|
||||
## Out of mandate (pattern absent)
|
||||
|
||||
Filed here rather than as tickets, per the "name the pattern you extend or don't file it"
|
||||
rule. Agent 08 or a human decides whether any of these becomes a ticket.
|
||||
|
||||
**OOM-A — extracting `Program.cs` into `Features/` folders with handler types.**
|
||||
This is the change BL-003 most obviously invites: 940 lines, 48 endpoints, file CC 78 against
|
||||
a next-highest of 27. It is out of mandate because §7 is explicit that the backend has
|
||||
**"No handler types, no mediator, no `Features/` folders"**, and the local helpers (`Submit`,
|
||||
`StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) are authorization and
|
||||
idempotency wrappers, not handlers — verified by reading them at `Program.cs:772-940`. There
|
||||
is no CQRS-light structure here to extend, only one to introduce. CQ-006 is the largest step
|
||||
available _within_ the mandate, and it deliberately stops at ordering and comments. Note also
|
||||
that CQ-006 is a strict prerequisite for OOM-A should it ever be taken: you cannot cut a
|
||||
940-line file into vertical slices while five of its seven sections interleave directions.
|
||||
|
||||
**OOM-B — read/write repository split in `backend/Data`.**
|
||||
`ApplicationStore` (file CC 27, the highest in `Data`) and the six sibling stores each mix
|
||||
reads and writes. Splitting them into query and command repositories would introduce the
|
||||
pattern where §7 records it absent. It would also collide with the documented static/no-DI/
|
||||
`Db.Create()`-per-call design (§7: "Deliberate, documented in `Data/Db.cs` and
|
||||
`Program.cs:40-45`"), which agent 06 may have views on.
|
||||
|
||||
**OOM-C — no read model, no event sourcing, and none proposed.**
|
||||
Stated explicitly so a later phase does not read this file as a step toward one. Baseline §7
|
||||
records no separate read model or event store, so per the role definition neither is in scope.
|
||||
The `ToDetailDto(now)` status projection is a read-side _derivation_, not a materialised read
|
||||
model, and CQ-007 argues it should stay that way.
|
||||
|
||||
**OOM-D — BL-011 affects every acceptance criterion here.**
|
||||
Not a finding, a scheduling note: the FE suite is flaky under parallel load, so "CI green"
|
||||
alone does not verify CQ-001..005. Verify against §3a/§4a numbers, per **BL-009** (no
|
||||
coverage threshold is enforced anywhere, so nothing ratchets).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Module | Title | Extends | Baseline | Effort | One deploy? |
|
||||
| ------ | ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | -------------- | ------ | ----------------------- |
|
||||
| CQ-001 | ssp/registratie | `createDraftSync` is a command factory owning 3 query paths | the command-factory idiom (`submit-change-request.ts`) | §7, §4a, §9 | M | yes |
|
||||
| CQ-002 | ssp/registratie | 2 read stores write without the `runSubmit` fold; errors dropped | `runSubmit` + `createSubmitChangeRequest` | BL-007, §7 | S | yes |
|
||||
| CQ-003 | ssp/brief | `runSubmit` (write fold + idempotency mint) used for 3 reads | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes |
|
||||
| CQ-004 | libs/shared/application | `FeatureFlagStore.set` skips the fold, drops the error entirely | `runSubmit`/`SUBMIT_FAILED` (same folder) | BL-007, §7 | S | yes |
|
||||
| CQ-005 | libs/beheer | 2 stamdata reads run through `runSubmit`; ship with CQ-003 | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes (with CQ-003) |
|
||||
| CQ-006 | backend/Program.cs | read/write banner split abandoned in 5 of 7 feature sections | the `:441`/`:464` WP-65 banner pair, in-file | BL-003, §7,§3c | S | yes — land it alone |
|
||||
| CQ-007 | backend/Program.cs | `GET /brief` creates; `POST /brief/reset` already exists | direction split in `Contracts/Dtos.cs`; `POST /brief/reset` | BL-003, §7 | M | **no** — FE+BE together |
|
||||
|
||||
**Modules with no findings:** ssp/auth · ssp/herregistratie · ssp/showcase+shell+root ·
|
||||
bhp/auth · bhp/behandeling (the reference implementation) · bhp/shell+root ·
|
||||
libs/shared/{infrastructure, upload, domain, contracts, kernel, ui, layout, testing,
|
||||
environments} · backend/{Domain, Data, Zgw, Contracts, Stamdata}.
|
||||
|
||||
**Suggested sequencing.** CQ-003 + CQ-005 are one ticket (one shared-file split, five call
|
||||
sites) and should go first — they make the direction legible at every call site, which is
|
||||
what CQ-002 and CQ-004 then apply consistently. CQ-006 is independent and can run in
|
||||
parallel on the backend. CQ-001 is the only FE ticket with real design content. CQ-007 is
|
||||
the only one needing a coordinated deploy and the only one whose premise is arguable —
|
||||
schedule it last, or take its documentation-only alternative.
|
||||
|
||||
**Honest scale.** Six of seven findings are S/M and none is a correctness defect except
|
||||
CQ-004's dropped error and CQ-002's silent rollback. Baseline §6 (0 dependency violations,
|
||||
textbook instability gradient) and §3c (97.6% backend line coverage) are accurate: this is a
|
||||
well-structured codebase, and the CQRS-light work available is consistency work, not repair.
|
||||
@@ -0,0 +1,9 @@
|
||||
## Scope: [to be filled by agent]
|
||||
|
||||
## Status: not_started
|
||||
|
||||
## Last updated: -
|
||||
|
||||
## Depends on: [see agent prompt]
|
||||
|
||||
## ---
|
||||
@@ -0,0 +1,582 @@
|
||||
## Scope: ADR-0001…0006 (all six), checked against apps/ssp, apps/behandelportal, libs/shared, libs/beheer, backend/src/BigRegister.Api, backend/tests, e2e
|
||||
|
||||
## Status: complete
|
||||
|
||||
## Last updated: 2026-08-26
|
||||
|
||||
## Depends on: 00-baseline.md
|
||||
|
||||
## ---
|
||||
|
||||
# 06 — ADR conformance
|
||||
|
||||
Every claim below was verified by reading the code, not by trusting the ADR text or
|
||||
CLAUDE.md. Where the two disagree with each other **and** with reality, that is flagged
|
||||
as an ADR-fix, per CLAUDE.md's own "the docs win — update this file" rule.
|
||||
|
||||
**Headline.** The architecture is in good shape. All six ADRs are substantially
|
||||
conformed to in their **decisions**; there is no case where the codebase has quietly
|
||||
adopted a different architecture. What has decayed is the ADRs' **descriptive prose** —
|
||||
four of the six describe a repo layout, a set of files, or an out-of-scope list that
|
||||
WP-49…WP-75 has since overtaken. Five of the eleven findings are therefore ADR-fixes.
|
||||
|
||||
**On BL-002 (the auth duplication), my verdict up front, because the brief asked for an
|
||||
argument rather than an assertion:** ADR-0002's non-sharing rule **needs no amendment and
|
||||
should not be relaxed**. The prediction has not _failed_; it is _unexecuted_. See
|
||||
ADR-C-004/ADR-C-006 for the reasoning and for what should actually be filed instead.
|
||||
|
||||
---
|
||||
|
||||
## ADR-0001 — BFF-lite endpoints + decision DTOs (Accepted)
|
||||
|
||||
**Core decision: conformed.** The two policy shapes the ADR prescribes are both live and
|
||||
correctly applied.
|
||||
|
||||
- _Decision flag_ — verified server-computed and rendered, never recomputed:
|
||||
`apps/ssp/src/app/brief/application/brief.store.ts:103-108` derives `canEdit`/
|
||||
`canApprove`/`canReject`/`canSend`/`canRevealBigNummer` purely from
|
||||
`BriefState.loaded.decisions`, with an explicit comment at line 31 ("this store never
|
||||
computes them itself"). Same shape in
|
||||
`apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:87-97`
|
||||
(`decisions.canBesluiten`, rejected at the parse boundary if absent).
|
||||
- _Config value_ — `apps/ssp/src/app/herregistratie/domain/intake.machine.ts:52` takes
|
||||
`scholingThreshold` as a parameter; `SCHOLING_THRESHOLD_DEFAULT` (line 43) survives only
|
||||
as the offline fallback the ADR sanctions, wired through
|
||||
`application/intake-policy.store.ts:18-19`. No hardcoded `1000` is used as authority.
|
||||
- _Parse boundary_ — 30 `parse*` functions (baseline §7). ADR-0001's own "out of scope"
|
||||
item _"Runtime DTO validation on every endpoint (only the dashboard view has it)"_ is
|
||||
substantially discharged.
|
||||
- `libs/shared/src/application/remote-data.ts` + `<app-async>`, and `dep:check`'s
|
||||
`ui-not-infrastructure` rule at 0 violations (baseline §6), keep the "infrastructure is
|
||||
the only network layer" clause enforced — with one carve-out, ADR-C-002.
|
||||
|
||||
### ADR-C-001 — ADR-0001's worked example describes a POC that no longer exists
|
||||
|
||||
- **Type: ADR-fix**
|
||||
- **ADR cited:** ADR-0001, §"Worked example in this POC" — opening sentence _"This POC has
|
||||
no real backend (static mock JSON + fake submit timers), so the 'BFF output' is a static
|
||||
file"_; §"Out of scope here", bullets 1 and 4.
|
||||
- **Evidence (verified):**
|
||||
- There is a real backend: `backend/src/BigRegister.Api/Program.cs` (940 lines, 48
|
||||
endpoint mappings — baseline §2/BL-003).
|
||||
- Every artifact the worked example names by path is gone. `find` returns nothing for
|
||||
`public/mock/dashboard-view.json`, `public/mock/intake-policy.json`, or
|
||||
`src/app/herregistratie/contracts/intake-policy.dto.ts`. `apps/ssp/public/mock/` does
|
||||
not exist. The surviving contract file moved to
|
||||
`apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` (WP-67's `src/` →
|
||||
`apps/ssp/src/` move), so §A's three cited paths are all wrong.
|
||||
- Out-of-scope bullet 1 ("runtime DTO validation on **every** endpoint — only the
|
||||
dashboard view has it") is stale: 30 `parse*` boundaries exist. Out-of-scope bullet 4
|
||||
("Real OpenAPI/TypeSpec codegen toolchain") is stale: `npm run gen:api` generates
|
||||
`libs/shared/src/infrastructure/api-client.ts` (2372 lines, NSwag) and CI drift-checks
|
||||
it.
|
||||
- **Baseline citation:** baseline §2 (`Program.cs` 940 lines), §7 pattern inventory
|
||||
(`parse*` trust boundaries = 30; excluded-as-generated api-client 2372 lines), BL-003.
|
||||
- **Proposed resolution:** rewrite §"Worked example" against the shipped system and prune
|
||||
the discharged out-of-scope bullets. No code changes.
|
||||
- **What the amended ADR should say:** the worked example should read as _"implemented
|
||||
against `backend/src/BigRegister.Api`"_, cite
|
||||
`apps/ssp/src/app/registratie/{contracts/dashboard-view.dto.ts,infrastructure/dashboard-view.adapter.ts}`
|
||||
and `GET /api/v1/dashboard-view` / `GET /api/v1/intake/policy` as the endpoints, and
|
||||
reduce §"Out of scope" to the two items still genuinely open (the `BigProfileStore`
|
||||
optimistic-update race, and session persistence / multi-tab sync).
|
||||
- **Blocked code tickets:** none. This is purely descriptive drift; the decision is intact.
|
||||
- **Effort: S**
|
||||
|
||||
### ADR-C-002 — `libs/shared/src/upload/` does network outside `infrastructure/`
|
||||
|
||||
- **Type: code-violates-ADR**
|
||||
- **ADR cited:** ADR-0001, §Decision — the DTO/adapter seam; operationalised in CLAUDE.md
|
||||
§4 as _"`infrastructure/` is the **only** layer that touches the network — the
|
||||
anti-corruption boundary"_.
|
||||
- **Evidence (verified):** `libs/shared/src/upload/upload.adapter.ts` injects `ApiClient`
|
||||
(line 3 import, line 56 `inject(ApiClient)`) and opens a raw `XMLHttpRequest` at line
|
||||
118 — i.e. it is a genuine network adapter — yet sits in a top-level `upload/` folder,
|
||||
not under `libs/shared/src/infrastructure/`. Its sibling `upload.machine.ts` is an
|
||||
Elm-style reducer sitting outside any `domain/` folder (the only one of 9 machines to do
|
||||
so). The exception is **hard-coded into the enforcement itself**:
|
||||
`.dependency-cruiser.base.js:105` reads
|
||||
`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — the rule is written
|
||||
around the violation rather than the violation being fixed, which is why baseline §6
|
||||
reports 0 violations despite this.
|
||||
- **Baseline citation:** BL-010 (verbatim: the exception "is already encoded rather than
|
||||
resolved"); baseline §7 FE pattern inventory ("Infrastructure adapters (read side): 20
|
||||
**+1 outside an `infrastructure/` folder**"; "Elm-style machines: 9 — 8 under a `domain/`
|
||||
folder; outlier `libs/shared/src/upload/upload.machine.ts`").
|
||||
- **Proposed resolution:** move `upload.adapter.ts` → `libs/shared/src/infrastructure/`,
|
||||
`upload.machine.ts` (+ its spec) → `libs/shared/src/domain/`, and
|
||||
`upload-controller.ts`/`upload-shell.service.ts` → `libs/shared/src/application/`. Then
|
||||
**delete the `^libs/shared/src/upload/` carve-out** from
|
||||
`.dependency-cruiser.base.js:105` — that deletion is the acceptance criterion, since it
|
||||
is what proves the exception is resolved rather than relocated. Note the side benefit:
|
||||
`libs/shared/src/domain` currently has 0% spec reach across 3 files (baseline §3b), and
|
||||
this moves a well-specced machine into it.
|
||||
- **Effort: M** (mechanical move + import updates across 30 dependents — `libs/shared/upload`
|
||||
has Ca 30, baseline §6 — plus the depcruise rule edit)
|
||||
|
||||
### ADR-C-003 — `contracts/` vs the generated client: ADR-0001 and CLAUDE.md §4 no longer agree with the code
|
||||
|
||||
- **Type: ADR-fix**
|
||||
- **ADR cited:** ADR-0001, §"Why DTOs _decouple_ rather than couple" — _"Manage it with
|
||||
**one source of truth** (OpenAPI or TypeSpec) that **generates types for both sides**"_ —
|
||||
read against CLAUDE.md §4's flat rule _"DTO lives in `contracts/`"_.
|
||||
- **Evidence (verified):** only 4 hand-written `contracts/` DTO files exist
|
||||
(`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`,
|
||||
`libs/beheer/src/contracts/stamdata.dto.ts`), against 20 infrastructure adapters, 19 of
|
||||
which import types from `@shared/infrastructure/api-client` directly. Crucially, the
|
||||
surviving hand-written contract **documents its own obsolescence**:
|
||||
`dashboard-view.dto.ts:10-11` says _"In production these types are GENERATED from the
|
||||
OpenAPI/TypeSpec spec (one source of truth for both sides)"_. So the code has reached
|
||||
ADR-0001's stated target state, and the hand-written `contracts/` files are the
|
||||
pre-codegen scaffolding — but CLAUDE.md §4 still states the pre-codegen rule as the
|
||||
standing law, which will mislead the next feature author.
|
||||
- **Baseline citation:** baseline §7 FE pattern inventory — _"`contracts/` DTO files: 4 —
|
||||
most adapters consume NSwag-generated types directly instead"_; agent-brief note "Only 4
|
||||
`contracts/` DTO files exist".
|
||||
- **Proposed resolution:** amend ADR-0001, then correct CLAUDE.md §4 to match (CLAUDE.md's
|
||||
own precedence rule requires this direction).
|
||||
- **What the amended ADR should say:** add a short §"Where the contract lives, after
|
||||
codegen": the generated client (`libs/shared/src/infrastructure/api-client.ts`,
|
||||
regenerated by `npm run gen:api`, drift-checked in CI) **is** the wire contract and the
|
||||
single source of truth; a hand-written `contracts/*.dto.ts` is warranted only where
|
||||
codegen does not reach — a hand-rolled `fetch`/XHR endpoint, or a shape the generator
|
||||
types too loosely — and in either case the hand-written file must still import nothing.
|
||||
The `parse*` trust boundary in `infrastructure/` is **unchanged and still mandatory**
|
||||
regardless of where the type came from: a generated type is a compile-time claim about
|
||||
the wire, not a runtime guarantee. Then decide explicitly whether the 4 survivors stay
|
||||
(they are more precise than the generated shapes) or are retired — and record which.
|
||||
- **Blocked code tickets:** any ticket that would either (a) delete the 4 remaining
|
||||
`contracts/` files or (b) add new hand-written DTOs for already-generated endpoints must
|
||||
wait for this amendment; today CLAUDE.md §4 can be cited to justify both directions.
|
||||
- **Effort: S** (ADR + CLAUDE.md edit; the follow-on code decision is separately sized)
|
||||
|
||||
---
|
||||
|
||||
## ADR-0002 — User groups as actors, not bounded contexts (**Proposed**, amended WP-67)
|
||||
|
||||
This is the ADR with the most divergence, and the one the baseline pointed me at. Three
|
||||
findings. The core modelling decision — contexts drawn by capability, not by who logs in —
|
||||
**is** conformed to: there is no `zorgverlener/` or `behandelaar/` folder anywhere, the
|
||||
contexts are `registratie`/`herregistratie`/`brief`/`behandeling`/`beheer` (capability
|
||||
names), and both apps integrate through one backend aggregate via decision DTOs.
|
||||
|
||||
### ADR-C-004 — the `Principal` union never landed, although actor #2 did
|
||||
|
||||
- **Type: code-violates-ADR**
|
||||
- **ADR cited:** ADR-0002 §3 ("Separate identity from authorization") — _"Model the
|
||||
principal as a **discriminated union** … This replaces the flat `Session` the day a second
|
||||
actor arrives"_; §Consequences — _"The one concrete FE change when actor #2 lands is
|
||||
`Session → Principal` in the `auth` context"_; §"Out of scope" — deferred _"until a second
|
||||
actor is actually introduced"_. Actor #2 was introduced in WP-61 and consolidated in
|
||||
WP-67. **The deferral condition has been met and the change was not made.**
|
||||
- **Evidence (verified):**
|
||||
- `grep -rn "Principal" apps libs --include=*.ts` returns exactly **one** hit, and it is
|
||||
a comment: `libs/shared/src/infrastructure/role.ts:8`. The type does not exist in the
|
||||
frontend.
|
||||
- `apps/behandelportal/src/app/auth/domain/session.ts` is byte-identical to the SSP's and
|
||||
still reads `interface Session { readonly bsn: string; readonly naam: string }` — a
|
||||
Behandelaar carrying a BSN, which §3 names as the precise thing the union exists to make
|
||||
unrepresentable.
|
||||
- The backoffice login is literally the citizen login. `apps/behandelportal/src/app/auth/ui/login.page.ts:31`
|
||||
is `async login(bsn: string)`, renders `intro="Log in op uw persoonlijke BIG-register
|
||||
omgeving."`, and calls `SessionStore.login(bsn)` →
|
||||
`auth/infrastructure/digid.adapter.ts:14`, which returns
|
||||
`ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' })`. A behandelaar logs into the
|
||||
backoffice as a zorgverlener, by DigiD, with a citizen's name in the header.
|
||||
- The medewerker identity that _does_ exist bypasses the auth model entirely:
|
||||
`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts:14-19` stamps
|
||||
`X-Medewerker`/`X-Rollen` onto every `/api/v1/` request. It never touches `Session`.
|
||||
- **Baseline citation:** BL-002 (`ssp/auth` 211/211 significant lines duplicated;
|
||||
`session.store.ts` 39 windows, `login-form.component.ts` 35, `login.page.ts` 23) — the
|
||||
100% figure is the _measurement_ of this finding: the files are identical **because** the
|
||||
modelling change that would differentiate them was skipped.
|
||||
- **Proposed resolution:** land `Session → Principal` as ADR-0002 §3 specifies. In
|
||||
`apps/behandelportal`: replace `Session` with the `medewerker` variant, replace
|
||||
`DigidAdapter` with a `MedewerkerAdapter` that resolves `MEDEWERKER_ID` + `currentRollen()`
|
||||
(the values `medewerker.ts` already holds) into a `Principal`, and make `login.page.ts`
|
||||
an SSO-stand-in entry rather than a BSN form. In `apps/ssp`: the `zorgverlener` variant.
|
||||
This makes the two `auth` contexts genuinely differ — which is what ADR-0002 §3 asserted
|
||||
would happen and is the honest resolution of BL-002.
|
||||
- **Effort: M**
|
||||
|
||||
### ADR-C-005 — ADR-0002 is still `Proposed` after two apps shipped against it
|
||||
|
||||
- **Type: ADR-fix**
|
||||
- **ADR cited:** ADR-0002 header, `Status: Proposed · Date: 2026-07-01`.
|
||||
- **Evidence (verified):** `apps/behandelportal` exists with 29 source files and 3 contexts
|
||||
(baseline §2); the ADR has been amended once in-document (§"Amendment (WP-67,
|
||||
2026-08-01)"); its structural rulings are enforced in CI today —
|
||||
`.dependency-cruiser.<app>.js`'s `<app>-no-other-app` and `shared-no-beheer` rules run at
|
||||
`severity: error` with 0 violations. An architectural decision that CI enforces is not
|
||||
"Proposed". The other five ADRs are all `Accepted`, so this is an inconsistency in the
|
||||
ADR set itself, not a deliberate signal.
|
||||
- **Baseline citation:** baseline §7 "ADRs on record" — _"`0002` … (**Proposed**, amended
|
||||
WP-67)"_; baseline §6 (11 `severity: error` rules, 0 violations, 223 modules cruised);
|
||||
baseline §2 (apps/behandelportal: 29 src files, 1 309 lines).
|
||||
- **Proposed resolution:** promote to `Accepted`, dated to WP-67.
|
||||
- **What the amended ADR should say:** `Status: Accepted · Date: 2026-07-01 · Amended
|
||||
2026-08-01 (WP-67)`. Also update §"Out of scope here", which still lists _"Building the
|
||||
Behandeling backoffice application"_ and _"The backend aanvraag status lifecycle +
|
||||
authorization endpoints/DTOs"_ as unbuilt — both shipped (WP-61…67; `AanvraagStatusTag`,
|
||||
`GET /me` capabilities, `Domain/Authz.cs`). The one bullet that stays is real
|
||||
authentication. Leave the `Session → Principal` bullet in scope but re-word it from
|
||||
"deferred until a second actor is introduced" to a stated debt — it is ADR-C-004.
|
||||
- **Blocked code tickets:** none strictly, but agent 08 should surface this **before**
|
||||
ADR-C-004, because ADR-C-004's justification is "the ADR says to do this" and a `Proposed`
|
||||
ADR is weak grounds for a refactor ticket.
|
||||
- **Effort: S**
|
||||
|
||||
### ADR-C-006 — extract the actor-agnostic route guards to `libs/shared` (the part of BL-002 that will never diverge)
|
||||
|
||||
- **Type: code-violates-ADR** — but note carefully: it violates CLAUDE.md §2's
|
||||
"composition over duplication" and the DRY intent, **not** ADR-0002 §3. ADR-0002 §3 is
|
||||
about _identity and login flow_. A route guard is neither.
|
||||
- **ADR cited:** ADR-0002 §3 — the scope of the non-sharing decision is `Principal` and the
|
||||
login flow ("the two groups **authenticate differently**"); §Consequences names
|
||||
`auth.guard.ts` and `session.store.ts` only as the _seams that localise_ the change, not
|
||||
as things that must be duplicated. Read with CLAUDE.md §1's rule that a genuinely
|
||||
cross-app concern belongs in `libs/shared`.
|
||||
- **Evidence (verified):** `diff -ru apps/ssp/src/app/auth apps/behandelportal/src/app/auth`
|
||||
reports **no content differences at all** — 9 of 11 files are byte-identical; the only
|
||||
delta is two _additional_ files in behandelportal (`medewerker.ts`,
|
||||
`medewerker.interceptor.ts`). Within those 9, `auth.guard.ts` is entirely actor-agnostic:
|
||||
`authGuard` (lines 8-12) reads only `SessionStore.isAuthenticated()` and `Router`;
|
||||
`capabilityGuard` (lines 25-33) adds only `AccessStore.can(capability)` and
|
||||
`whenReady()` — and `AccessStore` already lives in `libs/shared/src/application`.
|
||||
Both apps' routes redirect to the same `/login` and `/dashboard` paths
|
||||
(`apps/behandelportal/src/app/app.routes.ts:10,17`). Both app configs already register
|
||||
`{ provide: SESSION_PORT, useExisting: SessionStore }`
|
||||
(`apps/behandelportal/src/app/app.config.ts:65`), so the seam for a shared guard exists
|
||||
today.
|
||||
- **Baseline citation:** BL-002 top clone pairs — `auth.guard.spec.ts` **36 windows** and
|
||||
`auth.guard.ts` **21 windows**, i.e. 57 of the 211 duplicated lines, the single largest
|
||||
block after `session.store.ts`.
|
||||
- **Proposed resolution:** move `authGuard`/`capabilityGuard` + `auth.guard.spec.ts` to
|
||||
`libs/shared/src/application/` (or a `libs/shared/src/routing/`), injecting `SESSION_PORT`
|
||||
instead of the app-local `SessionStore`. One small widening is needed:
|
||||
`libs/shared/src/application/session.port.ts:9-12` currently exposes only
|
||||
`session: Signal<{naam: string} | null>` and `logout()` — add
|
||||
`readonly isAuthenticated: Signal<boolean>` (or have the guard derive it from
|
||||
`session() !== null`, which both `SessionStore`s already do at
|
||||
`session.store.ts:40`). Each app keeps a two-line re-export at `@auth/auth.guard` so its
|
||||
`app.routes.ts` is untouched.
|
||||
- **Explicitly NOT proposed, and why.** No ticket to merge `session.store.ts`,
|
||||
`session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and **no
|
||||
ADR-fix relaxing ADR-0002 §3.** The brief invited me to treat BL-002 as a prediction the
|
||||
code failed to bear out. It is not. Those five files are identical because ADR-C-004 was
|
||||
never executed — the divergence the ADR predicted has in fact already arrived, it just
|
||||
arrived through an orthogonal side door (`medewerkerInterceptor`, a dev-only HTTP header
|
||||
stamp) instead of through the `Principal` seam the ADR designated. Merging them now would
|
||||
cement a citizen DigiD/BSN login as the backoffice's shared login, which is the one
|
||||
outcome ADR-0002 §3 was written to prevent. The correct sequencing is ADR-C-005 (accept
|
||||
the ADR) → ADR-C-004 (land `Principal`) → **re-measure**. My expectation is that
|
||||
post-ADR-C-004 the residual `ssp/auth` ↔ `bhp/auth` duplication drops from 211 lines to
|
||||
under 40 on its own. If ADR-C-004 is still unstarted at the **next backlog cycle**, that
|
||||
is the point at which the ADR-fix conversation becomes legitimate — not now.
|
||||
- **Effort: S**
|
||||
|
||||
### Observation for agent 07 (BIO2), not a ticket here
|
||||
|
||||
`medewerkerInterceptor` is registered **only** inside `isDevMode()`
|
||||
(`apps/behandelportal/src/app/app.config.ts:57-63`). In a production build the backoffice
|
||||
therefore sends no `X-Medewerker`/`X-Rollen` at all, and `StubIdentityProvider` falls
|
||||
through to its zorgverlener default. ADR-0002 §3's "authorization enforced at the backend
|
||||
boundary" holds structurally, but the behandelportal's _identity_ has no non-dev path. The
|
||||
ADR lists real employee SSO as out of scope, so this is not an ADR conformance defect —
|
||||
flagging it because it is the kind of thing a compliance pass should see stated, and
|
||||
because ADR-C-004 is the natural place to close it.
|
||||
|
||||
---
|
||||
|
||||
## ADR-0003 — CIBG Huisstijl (Bootstrap 5.2) as the design system (Accepted)
|
||||
|
||||
**Core decision: conformed.** All five decision points hold. The package is vendored at
|
||||
`public/cibg-huisstijl/` (`css`, `fonts`, `icons`, `images` present, licensed RO/Rijks text
|
||||
fonts absent per point 5); the token bridge is intact at `libs/shared/styles.scss`; atoms
|
||||
emit Bootstrap classes with their `input()` APIs preserved; `check:tokens`
|
||||
(`scripts/check-tokens.sh`) runs in `npm run ci`; the gap register exists at
|
||||
`libs/shared/docs/cibg-gaps.mdx` with 9 `// CIBG-GAP EXTENSION:` markers in code.
|
||||
|
||||
### ADR-C-007 — ADR-0003's file paths and its `app-alert` example are both stale
|
||||
|
||||
- **Type: ADR-fix**
|
||||
- **ADR cited:** ADR-0003 §Decision point 1 (`src/index.html`), point 2 (`src/styles.scss`),
|
||||
point 4 (_"CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert` is a small
|
||||
token-styled surface"_), §Consequences (`.storybook/`, `src/docs/cibg-gaps.mdx`).
|
||||
- **Evidence (verified):**
|
||||
- Every path moved in WP-67: `src/styles.scss` → `libs/shared/styles.scss`;
|
||||
`src/index.html` → `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html`
|
||||
(two now, not one); `.storybook/` → `.storybook-ssp/` and `.storybook-behandelportal/`;
|
||||
`src/docs/cibg-gaps.mdx` → `libs/shared/docs/cibg-gaps.mdx`.
|
||||
- Point 4's `app-alert` claim is **factually wrong about the current code**.
|
||||
`libs/shared/src/ui/alert/alert.component.ts:13-16` documents itself as a _"Thin wrapper
|
||||
over the vendored `.feedback feedback-*` classes: the design system owns surface + icon"_,
|
||||
and the template (lines 31-37) binds `.feedback-info`/`.feedback-success`/
|
||||
`.feedback-warning`/`.feedback-error`. Its only local CSS is a 3-line flex fix. It is not
|
||||
hand-rolled and carries no `CIBG-GAP` marker — correctly, since it is not a gap.
|
||||
- **CLAUDE.md §2 repeats the same stale claim** verbatim: _"(Where CIBG lacks a class —
|
||||
e.g. `alert` — the atom is a small hand-rolled surface built from the token bridge; see
|
||||
ADR-0003.)"_ Both documents must be corrected, ADR first.
|
||||
- **Baseline citation:** baseline §2 (the monorepo layout the paths must be rewritten
|
||||
against: apps/ssp, apps/behandelportal, libs/shared, libs/beheer); baseline §7 config-seam
|
||||
tokens (`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`/`DEBUG_PANEL`) — the WP-67 two-app split
|
||||
these paths belong to.
|
||||
- **Proposed resolution:** repoint all five paths; replace the `.alert` example in point 4.
|
||||
- **What the amended ADR should say:** point 4 should keep the principle ("hand-roll what
|
||||
CIBG's build drops, and mark it") but pick a live example — `skeleton` and `spinner` are
|
||||
the cleanest (both in the register, both genuinely absent from the vendored build) — and
|
||||
note that `.alert` was subsequently resolved onto the vendored `.feedback` classes, so
|
||||
it is no longer a gap. CLAUDE.md §2's parenthetical must be corrected in the same diff.
|
||||
- **Blocked code tickets:** none.
|
||||
- **Effort: S**
|
||||
|
||||
### ADR-C-008 — the CIBG gap register is one row behind the markers in code
|
||||
|
||||
- **Type: code-violates-ADR** (the artifact the ADR mandates is incomplete; the fix is a
|
||||
one-row doc edit, not a code change)
|
||||
- **ADR cited:** ADR-0003 §Consequences, final bullet — _"Hand-rolled components (point 4)
|
||||
are tracked in the **CIBG gap register** … every deviation from the design system carries
|
||||
a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently drifting."_
|
||||
- **Evidence (verified):** 9 files carry a `CIBG-GAP` marker
|
||||
(`debug-state`, `language-switcher`, `wizard-shell`, `application-link`,
|
||||
`placeholder-chip`, `rich-text-editor`, `skeleton`, `spinner`, `status-badge`); the
|
||||
register table at `libs/shared/docs/cibg-gaps.mdx:26-33` has **8** rows.
|
||||
`libs/shared/src/layout/language-switcher/language-switcher.component.ts:7-9` carries a
|
||||
full, well-formed marker (`"Taal instellen" … no vendored Huisstijl class ships for it …
|
||||
See cibg-gaps.mdx`) and has no corresponding row. The register's own §"Keeping this
|
||||
register honest" concedes there is no automated diff and instructs _"If markers and this
|
||||
table drift, trust the code and fix the table"_ — so this is exactly the drift it
|
||||
predicted, caught by review as designed.
|
||||
- **Baseline citation:** baseline §2 (libs/shared 86 src files / 5 194 lines — the surface
|
||||
the register must cover); baseline §6 (`libs/shared/src/layout` is a cruised module with
|
||||
Ca 22).
|
||||
- **Proposed resolution:** add the `language-switcher` row (CIBG concept: "Taal instellen";
|
||||
reason: no vendored class in this build). Optionally add the CI script the register
|
||||
declines — a ~10-line `grep -l CIBG-GAP | diff` in `scripts/` folded into `check:tokens`
|
||||
would make the drift impossible to reintroduce. I would file the row as the ticket and
|
||||
the script as an explicitly optional second step, matching the register's own
|
||||
proportionality argument.
|
||||
- **Effort: S**
|
||||
|
||||
---
|
||||
|
||||
## ADR-0004 — Stamdata as code (Accepted)
|
||||
|
||||
**Core decision: conformed.** `backend/src/BigRegister.Api/Stamdata/` holds 15 files —
|
||||
typed C# (`Beroep.cs`, `PolicyQuestions.cs`, `Professions.cs`, `StamdataCatalog.cs`,
|
||||
`StamdataTable.cs`, `StamdataFile.cs`) plus the checked-in JSON data-files
|
||||
(`beroepen.json`, `documentconfidentialiteit.json`, `opleidingen.json`,
|
||||
`professions.json`, `specialismen.json`) the WP-29 follow-on introduced. Coverage is 96.8%
|
||||
line (baseline §3c). The `beheer/stamdata` editor is read-only-plus-download, not a write
|
||||
path, exactly as the ADR's own WP-29 note states. There is **no** runtime write endpoint
|
||||
for any stamdata table.
|
||||
|
||||
### ADR-C-009 — feature flags are a second runtime-editable exception the ADR does not acknowledge
|
||||
|
||||
- **Type: ADR-fix**
|
||||
- **ADR cited:** ADR-0004 §Decision — _"Never a production database, never runtime-editable"_
|
||||
— and §"The deliberate exception: org-templates", which names **one** exception in the
|
||||
singular and justifies it narrowly ("specific to one sub-organization's identity").
|
||||
- **Evidence (verified):** `backend/src/BigRegister.Api/Data/FeatureFlagStore.cs` is a
|
||||
second admin-writable SQLite surface, added by WP-47 — after ADR-0004 (2026-07-20) — and
|
||||
**its own doc-comment states the equivalence the ADR does not**: _"Runtime feature-flag
|
||||
state (WP-47). SQLite-backed like `OrgTemplateStore`, same single-gate idiom."_ It exposes
|
||||
`Set(key, enabled)` writing `db.FeatureFlags`, surfaced through the admin page
|
||||
`/beheer/functies` (`apps/behandelportal/src/app/app.routes.ts:44-50`, gated by
|
||||
`capabilityGuard('flags:manage')`).
|
||||
- **Assessment — and why this is an ADR-fix, not a violation.** The design is genuinely
|
||||
ADR-0004-shaped, not a breach of it: the _catalog_ (which flags exist, their descriptions
|
||||
and defaults) is compiled-in code (`Domain/Features/FeatureFlags.Catalog`), only the
|
||||
boolean override persists, and `IsEnabled` fails closed for an unknown key
|
||||
(`FeatureFlagStore.cs:45-47`), so a bad DB row cannot invent a flag. That is the ADR's
|
||||
actual principle — schema and values gated at compile time — applied correctly. What is
|
||||
wrong is the ADR's _text_: it states the rule as a closed list of one exception, which
|
||||
means the next operational-config surface has no principle to test itself against and
|
||||
will either be waved through or blocked on a technicality.
|
||||
- **Baseline citation:** baseline §7 backend pattern inventory — the 7 static stores listed
|
||||
as "Not behind any port", which includes both `OrgTemplateStore` **and**
|
||||
`FeatureFlagStore`; baseline §3c (`backend/Stamdata` 96.8% line / 71.7% branch;
|
||||
`backend/Data` 99.0% / 75.5%).
|
||||
- **Proposed resolution:** amend ADR-0004. No code change; the code is right.
|
||||
- **What the amended ADR should say:** replace §"The deliberate exception: org-templates"
|
||||
with §"The deliberate exception: operational configuration", stating the **test** rather
|
||||
than a list — runtime-editable persistence is permitted only when (1) the catalog/schema
|
||||
of what may be set lives in code, (2) an unknown or unlisted key is rejected/fails closed,
|
||||
(3) the value is operational (per-organisation identity, an on/off rollout switch) and not
|
||||
a shared business rule whose wrong value breaks the register for everyone, and (4) writes
|
||||
are admin-capability-gated and audited. Then list the two surfaces that pass it today —
|
||||
`OrgTemplateStore` (WP-23/26) and `FeatureFlagStore` (WP-47) — and note that both are
|
||||
admin-gated. Add a matching sentence to CLAUDE.md §4, which currently repeats the
|
||||
singular framing ("Org-templates are the deliberate exception").
|
||||
- **Blocked code tickets:** any ticket proposing a third runtime-editable config surface
|
||||
should wait for this test to be written down, rather than arguing by analogy to
|
||||
org-templates.
|
||||
- **Effort: S**
|
||||
|
||||
---
|
||||
|
||||
## ADR-0005 — OpenZaak (ZGW APIs) behind the BFF (Accepted)
|
||||
|
||||
**Conformed, with no findings.** This is the cleanest ADR in the set and I am recording
|
||||
that plainly rather than manufacturing a ticket.
|
||||
|
||||
Verified point by point:
|
||||
|
||||
- The anti-corruption layer is in the .NET BFF and nowhere else. Every ZGW type lives under
|
||||
`backend/src/BigRegister.Api/Zgw/` (8 files: `OpenZaakZaakSource`,
|
||||
`OpenZaakDocumentSource`, `ZgwHttpClient`, `ZgwTokenProvider`, `ZgwZaakMapper`,
|
||||
`ZgwOptions`, `ZgwDiagnosticHandler`, `NotificatieDto`). `grep` for
|
||||
`OpenZaakZaakSource|OpenZaakDocumentSource` outside `Zgw/` returns only the two DI
|
||||
registrations in `Program.cs:67,69` and eight explanatory comments — no ZGW shape reaches
|
||||
a consumer.
|
||||
- The config-switched port pair is exactly as decided: `Program.cs:59-84` reads
|
||||
`Zgw:Enabled` and binds either `OpenZaakZaakSource`/`OpenZaakDocumentSource` (via
|
||||
`AddHttpClient`, with the WP-60 15s timeouts) or `LocalZaakSource`/`LocalDocumentSource`.
|
||||
Default is local, so the POC still runs fully offline as the ADR's second constraint
|
||||
requires.
|
||||
- The FE is untouched by the switch: consumers inject the interface only
|
||||
(`Program.cs:206,281,340,425,434,446,473`), and the same `ApplicationSummaryDto` is
|
||||
returned either way — zero DTO/api-client drift, matching the ADR's first `+`.
|
||||
- The ADR's stated **minus** is still accurate and honestly scoped: `Program.cs:465`
|
||||
confirms _"The local write runs against ApplicationStore directly (not the IZaakSource
|
||||
seam)"_, which is precisely the "only some endpoints have a source interface; each future
|
||||
slice introduces its own seam" consequence the ADR wrote down. An ADR that predicted its
|
||||
own remaining gap and the gap stayed where predicted is conformance, not drift.
|
||||
- `backend/Zgw` is the best-covered backend module at 98.1% line / 85.5% branch (baseline
|
||||
§3c) and the second-lowest duplication (1.8%, baseline §5) — the seam the ADR claimed
|
||||
would be "unit-testable without a live server" measurably is.
|
||||
|
||||
Note for agent 03: BL-006 (no NetArchTest/ArchUnitNET, one assembly) means nothing
|
||||
_enforces_ the "ZGW shapes never leave `Zgw/`" property that this ADR depends on. It holds
|
||||
today by convention. That is agent 03's ticket to size, not mine — I record only that
|
||||
ADR-0005's conformance is currently review-maintained, not CI-maintained.
|
||||
|
||||
---
|
||||
|
||||
## ADR-0006 — Test data through the production door (Accepted)
|
||||
|
||||
**Core decision: partially conformed.** Three of the five mechanisms are properly in place;
|
||||
the frontend replay idiom — the ADR's own flagship — is adopted in one spec out of nine.
|
||||
|
||||
Conformed:
|
||||
|
||||
- §1 backend type-state builder — `backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs`
|
||||
exists and is used in 5 test files (`Acceptance/BesluitLifecycleTests.cs`,
|
||||
`Acceptance/IntakeSubmissionTests.cs`, `ApplicationTests.cs`,
|
||||
`Domain/BeoordelingRuleTests.cs`, `OpenZaakZaakSourceTests.cs`) — the exact two files the
|
||||
ADR's Context named as the problem, plus three more.
|
||||
- §4 `RemoteData` named constructors — `libs/shared/src/testing/remote-data.ts` exists.
|
||||
- §5 e2e actors/seed-refs — `e2e/support/actors.ts` exists; no page-object layer was added.
|
||||
- No `'x' as BrandedType` cast appears in any spec (`grep` for
|
||||
`as Postcode|as Uren|as BigNummer|as Bsn` in `*.spec.ts`: zero hits) — the §3 illegal
|
||||
route is genuinely closed.
|
||||
|
||||
### ADR-C-010 — four machine specs hand-roll the exact state literal ADR-0006 §2 forbids
|
||||
|
||||
- **Type: code-violates-ADR**
|
||||
- **ADR cited:** ADR-0006 §2 (_"No object is built directly. A fixture is the result of
|
||||
running real `Msg`s through the real `reduce`"_) and the Decision table's "Pure reducer /
|
||||
state machine (frontend)" row: **Do not** — _"A literal returning `{ tag: 'Editing', ... }`
|
||||
by hand"_; plus §Consequences (_"a hardcoded `errors: {}` fixture literal can't drift from
|
||||
what validation actually produces"_).
|
||||
- **Evidence (verified) — each of these returns a state literal, and three of the four
|
||||
hardcode the `errors: {}` the ADR calls out by name:**
|
||||
- `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts:19-25` —
|
||||
`const answering = (...): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold })`.
|
||||
This is the worst instance: `apps/ssp/src/app/herregistratie/domain/intake.testing.ts`
|
||||
exists **in the same folder**, exports `givenIntake`, and is the ADR's own quoted example
|
||||
(§2's code block is `export const givenIntake = given(reduce, initial)`) — yet
|
||||
`givenIntake` is imported only by `intake.acceptance.spec.ts`, never by the machine spec
|
||||
it was written for.
|
||||
- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts:25-31` —
|
||||
`const invullen = (...): RegistratieState => ({ tag: 'Invullen', draft: {...}, cursor, errors: {}, upload: initialUpload })`.
|
||||
- `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts:5-9` —
|
||||
`const editingWith = (...): BesluitState => ({ tag: 'Editing', draft: {...}, errors: {} })`.
|
||||
- `apps/ssp/src/app/brief/domain/brief.machine.spec.ts:67-74` —
|
||||
`const loaded = (...): BriefState => ({ tag: 'loaded', brief, availablePassages, decisions })`.
|
||||
- For contrast, two specs **do** conform and should be the pattern to copy:
|
||||
`libs/beheer/src/domain/stamdata-editor.machine.spec.ts:17-23` (`seedLoaded()` =
|
||||
`reduce(initial, {tag:'Loaded', …})`) and
|
||||
`apps/ssp/src/app/brief/domain/org-template.machine.spec.ts:29-30`
|
||||
(`reduce({tag:'loading'}, {tag:'DraftLoaded', …})`). Only 1 of 9 machines has a
|
||||
`*.testing.ts`; `change-request.machine.spec.ts:6` honours the idiom but declares
|
||||
`given(reduce, initial)` inline in the spec rather than in a `*.testing.ts`.
|
||||
- **Baseline citation:** baseline §7 FE pattern inventory (Elm-style machines: 9);
|
||||
baseline §3a — `ssp/herregistratie` 70.9% line / 67.8% branch and `ssp/brief` 75.3% /
|
||||
68.8%, i.e. the two modules whose specs hand-roll states are also two of the three
|
||||
weakest-covered non-auth FE modules, which is consistent with fixtures asserting shapes
|
||||
the reducer may not actually produce.
|
||||
- **Proposed resolution:** add `*.testing.ts` next to each of the four machines
|
||||
(`registratie-wizard`, `brief`, `besluit`, plus adopt the existing `intake.testing.ts`),
|
||||
each a one-liner `export const givenX = given(reduce, initial)` per §2, and rewrite the
|
||||
four literal helpers as message replays. Where a state genuinely is not reachable by
|
||||
replay, that is a finding in its own right and should be recorded in the ticket rather
|
||||
than worked around — it means the reducer cannot produce a state the spec asserts.
|
||||
- **Effort: M** (four specs; mechanical but each needs the right message sequence worked out,
|
||||
and `brief.machine.spec.ts` is the largest)
|
||||
|
||||
### ADR-C-011 — `unwrapOk` has zero adopters; its one call site reimplements it inline
|
||||
|
||||
- **Type: code-violates-ADR**
|
||||
- **ADR cited:** ADR-0006 §3 (_"Value objects → `unwrapOk`, never a cast"_) and the Decision
|
||||
table's "Value object / parser" row.
|
||||
- **Evidence (verified):** `unwrapOk` is defined in
|
||||
`libs/shared/src/testing/value-object.ts` and referenced nowhere else in `apps/` or
|
||||
`libs/` except `libs/shared/docs/testing.mdx` — zero spec consumers. The one place that
|
||||
needs it hand-rolls the same three lines:
|
||||
`apps/ssp/src/app/registratie/application/submit-change-request.spec.ts:8-9` reads
|
||||
`const telefoon = parseTelefoonnummer('0612345678'); if (!telefoon.ok) throw new Error('fixture phone should parse');`
|
||||
— semantically identical to `unwrapOk(parseTelefoonnummer('0612345678'))`, which is
|
||||
exactly the duplication the ADR shipped the helper to remove.
|
||||
- **Baseline citation:** BL-004 (122 of 220 FE source files never loaded by any Vitest run
|
||||
— `libs/shared/src/testing` is one of the few modules at 100% reach, so a helper here that
|
||||
nothing imports is measurably dead weight, not merely unexercised); baseline §3a
|
||||
`libs/shared/testing` 3 files, 100% line.
|
||||
- **Proposed resolution:** one-line change in `submit-change-request.spec.ts` to call
|
||||
`unwrapOk`. Then judge honestly whether one call site justifies keeping the helper — if a
|
||||
future ticket finds it still has one consumer, deleting it and keeping the inline guard is
|
||||
the equally valid answer, and ADR-0006 §3's real requirement (never a cast) is satisfied
|
||||
either way. File it as "adopt or delete", not "adopt".
|
||||
- **Effort: S**
|
||||
|
||||
---
|
||||
|
||||
## ADR-fix tickets (require architect approval)
|
||||
|
||||
Agent 08 must surface these five for human sign-off. None of them are code changes; all
|
||||
five change what the repo's architecture documents _claim_, and two of them gate code
|
||||
tickets.
|
||||
|
||||
| ID | ADR | What the amendment does | Gates | Effort |
|
||||
| ------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the two discharged out-of-scope bullets | nothing | S |
|
||||
| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps; correct CLAUDE.md §4 | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint | S |
|
||||
| **ADR-C-005** | 0002 | `Proposed` → `Accepted`; refresh §"Out of scope" (backoffice + status lifecycle + authz DTOs all shipped) | **ADR-C-004** — a `Proposed` ADR is weak grounds for a refactor ticket, so this must land first | S |
|
||||
| **ADR-C-007** | 0003 | Repoint five WP-67-stale paths; replace the false `app-alert` hand-rolled example; correct CLAUDE.md §2 | nothing | S |
|
||||
| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces; correct CLAUDE.md §4 | any future third runtime-editable config surface | S |
|
||||
|
||||
**Ordering dependency, called out explicitly as the brief requires:** ADR-C-005 → ADR-C-004
|
||||
→ (re-measure BL-002) → possibly-nothing. ADR-C-006 is deliberately **not** in this chain:
|
||||
extracting the actor-agnostic route guards is compatible with ADR-0002 §3 as written and
|
||||
needs no approval. **No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** I
|
||||
considered it, as instructed, and rejected it: the rule's prediction has not been falsified,
|
||||
it has not been tested, because the change that would test it (ADR-C-004) was never made.
|
||||
Amending an ADR to match code that never executed its decision would ratify the omission
|
||||
rather than the evidence.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ADR | Verdict | Findings |
|
||||
| -------- | --------------------------------------------------- | --------------------------------------- |
|
||||
| **0001** | Decision conformed; prose stale | ADR-C-001 (fix), 002 (code), 003 (fix) |
|
||||
| **0002** | Decision conformed; §3 unexecuted; still `Proposed` | ADR-C-004 (code), 005 (fix), 006 (code) |
|
||||
| **0003** | Conformed; two documentation defects | ADR-C-007 (fix), 008 (code/doc) |
|
||||
| **0004** | Conformed; exception clause under-general | ADR-C-009 (fix) |
|
||||
| **0005** | **Fully conformed — no findings** | — |
|
||||
| **0006** | Partially conformed; §2 adopted 1 of 9 | ADR-C-010 (code), 011 (code) |
|
||||
|
||||
Six code tickets (1×M, 1×M, 1×M, 3×S) and five ADR-fixes (all S). No finding proposes
|
||||
introducing a pattern that does not already exist in the repo, and every one cites a
|
||||
baseline observation or metric row.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
## Scope: all findings from 00-baseline, 02-testability, 04-cqrs-light, 06-adr-conformance, 07-bio2-compliance — deduplicated, scored, CD-sequenced
|
||||
|
||||
## Status: complete
|
||||
|
||||
## Last updated: 2026-08-27
|
||||
|
||||
## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md, 07-bio2-compliance.md
|
||||
|
||||
## ---
|
||||
|
||||
# 99 — Consolidated refactoring backlog
|
||||
|
||||
**47 findings in, 33 open tickets + 5 ADR-fixes + 1 shipped set out.** Everything below
|
||||
traces to at least one `TE-`/`CQ-`/`ADR-C-`/`BIO-` finding and cites a baseline metric.
|
||||
|
||||
**HALT lifted 2026-08-27** — the operator approved the backlog and Phase 3 started.
|
||||
**CD batch 1 (RB-01..RB-06) is implemented**, one commit per ticket on branch
|
||||
`refactor/adr-c-006-shared-route-guards`, each with a note in `implementation/rb-0N.md`.
|
||||
Batches 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 (`<a href>` / `previewUrl`), carrying no identity
|
||||
header and passing through no interceptor, so `StubIdentityProvider` answers with the
|
||||
seeded citizen. The links keep working only because one citizen owns every document in
|
||||
the POC. That is BIO-002; RB-09 needs this endpoint to receive a real credential.
|
||||
- **RB-06 also deleted `SubmissionRules.RejectRegistratie`**, which the row did not ask for.
|
||||
It was reachable only from the deleted endpoint and contradicted by the live submit path.
|
||||
Recorded as the ticket's one judgement call in `implementation/rb-06.md`.
|
||||
|
||||
`Pii.MaskTail` now lives in `Domain/People/Pii.cs` (moved out of `Program.cs` by RB-03) —
|
||||
**RB-11 and any later redaction work should use it rather than hand-rolling a second copy.**
|
||||
|
||||
---
|
||||
|
||||
## Coverage of this backlog — read this before treating it as complete
|
||||
|
||||
Three of the seven Phase 1 agents were **deliberately skipped** by the operator
|
||||
(reasons recorded in `_status.md`). This backlog therefore contains **no findings of the
|
||||
following kinds**, and their absence is not evidence that none exist:
|
||||
|
||||
| Agent not run | Category of finding that is absent |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **01 — readability** | Function/file length, naming, nesting depth, comment quality, dead code, test readability. No ticket below is a "this is too long/unclear" ticket. |
|
||||
| **03 — DDD/hexagonal** | Backend layering, vertical-slice structure, port extraction, module boundaries. The backend's structure is untouched except where CQRS-light reached it. |
|
||||
| **05 — BDD** | Nothing material — the agent self-reduced to a structural note; `gen:behaviour-spec` already covers the intent. |
|
||||
|
||||
Concrete consequences, so nobody assumes these were considered and dismissed:
|
||||
|
||||
- **`createDraftSync` (143 lines, the longest function in the repo, §4a) is only partly
|
||||
addressed.** RB-21 splits its read half out on CQRS grounds. Whether the remainder is
|
||||
still too long was never assessed.
|
||||
- **The other named length/complexity candidates have no owner:**
|
||||
`api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11) —
|
||||
the only two CC>10 functions outside the mandated idioms per **BL-001**; the 293-line
|
||||
CC-20 test method in `OpenZaakZaakSourceTests.cs`; and the six files over 400 lines
|
||||
(§9). RB-19 reorders `Program.cs` but does not shorten it.
|
||||
- **Backend structure was assessed only through the CQRS-light lens.** **BL-003**'s
|
||||
invitation (940 lines → `Features/`) is filed as out-of-mandate **OOM-A**, not a ticket.
|
||||
**BL-010** (`libs/shared/upload/` outside the layer convention) is resolved only
|
||||
incidentally, by RB-24, which came from the ADR agent rather than the structure agent.
|
||||
- **Two baseline observations remain unowned by any agent:** **BL-005** (backend branch
|
||||
coverage 18 points behind line coverage; `Contracts` 65.0%, `Stamdata` 71.7%, `Data`
|
||||
75.5% — `backend/tests/` has no `Contracts/` folder at all) and **BL-009** (no coverage
|
||||
ratchet anywhere). Neither is a testability _blocker_, so agent 02 correctly declined
|
||||
both; they are coverage work with no seam to add, and no ticket below covers them.
|
||||
|
||||
---
|
||||
|
||||
## Already done — implemented and committed, do not re-file
|
||||
|
||||
Branch `refactor/adr-c-006-shared-route-guards`, five commits.
|
||||
|
||||
| Finding | Commit subject | Status | Residual |
|
||||
| ------------- | ----------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **ADR-C-005** | `docs(adr-0002): accept, and record the unbuilt Principal union as debt` | **implemented** | ADR-0002 is now `Accepted`, so **RB-13 (ADR-C-004) now stands on a correct ADR** — that was the whole point of the gate. |
|
||||
| **ADR-C-006** | `refactor(auth): share the actor-agnostic route guards (ADR-C-006)` | **implemented** | Auth duplication **211 → 151 lines**. §5's `ssp/auth 100% / bhp/auth 86.8%` rows and the `auth.guard*` clone pairs in the baseline are now **stale** — re-measure before citing them. Standing compliance criterion from agent 07: any future change to `authGuard`/`capabilityGuard` is an access-control change and must re-run the guard spec for both apps. |
|
||||
| **CQ-004** | `fix(flags): surface a failed admin toggle instead of swallowing it` | **implemented** | **Half of its compliance criterion is unmet.** Agent 07 required "fix the FE error **and** the BE audit row together". The FE error shipped; `PUT /admin/flags/{key}` still writes **no** audit row. That half is carried by **RB-07**, and it is why **ADR-C-009** must not be signed off before RB-07 lands. |
|
||||
| **TE-009** | `fix(stamdata): evaluate the profession validity window per call, not at type-load` | **implemented** | Also closed the latent dead-`ActiveOn`-branch bug. Not compliance-flagged. |
|
||||
| **BL-008** | `build: make coverageExclude actually exclude the generated API client` | **implemented** | The reported `libs/shared/infrastructure` figure should now read ≈94.7%, not 6.9%. §3a is stale on that row. |
|
||||
|
||||
**Correction to the hand-off.** The brief listed "CQ-002/004 (`FeatureFlagStore.set`)" as
|
||||
fixed. Only **CQ-004** was — `FeatureFlagStore.set` is the CQ-004 subject. **CQ-002**
|
||||
(`ApplicationsStore.cancel`, `AdminCasesStore.delete`) is **verified still open**: both
|
||||
still do `try { await this.adapter.x(id) } catch { this.state.set(before) }` with no
|
||||
`runSubmit`, no `Result`, and no error channel. It is filed below as **RB-20**.
|
||||
|
||||
---
|
||||
|
||||
# The backlog
|
||||
|
||||
**How to read the CD batch column.** A batch is a _suggested ordering wave_, not a release
|
||||
train. Every ticket in the table ships **alone**, on its own merge, without any other
|
||||
ticket in its batch. Where a ticket genuinely cannot ship alone it was split into a chain
|
||||
(RB-22/RB-23) — see "Tickets that were rejected and split". `Depends on` means _must be
|
||||
deployed first_, not _must ship together_.
|
||||
|
||||
**Compliance column.** `SIGN-OFF` = requires compliance sign-off before merge, per rule 4.
|
||||
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
|
||||
16-row "Compliance review required" list, carries it — regardless of priority.
|
||||
|
||||
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
|
||||
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
|
||||
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||
| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||
| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||
| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||
| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | 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 | — | — | **done** |
|
||||
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | **done** |
|
||||
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | **done** |
|
||||
|
||||
---
|
||||
|
||||
## Notes on the table
|
||||
|
||||
**Why P1 is not simply "everything".** Rule 2's P1 definition ("violates a correct ADR,
|
||||
blocks testability, or is a BIO2 compliance risk") would catch nearly every finding, which
|
||||
would make the score useless. It is applied as: **P1 = a control is broken, an accepted
|
||||
ADR's decision is unexecuted, or a security-relevant guard has no test today.** A ticket
|
||||
that is merely _flagged because it touches a control_ (TE-003/4/5/6/8, CQ-006, ADR-C-002)
|
||||
is **P2 with mandatory sign-off** — the compliance risk is one the ticket could introduce,
|
||||
not one that exists. That distinction is the whole reason rule 4 is orthogonal to rule 2.
|
||||
|
||||
**RB-01 and RB-02 sort above every structural ticket** regardless of effort. Both are live
|
||||
production-shaped defects, independently verified: a BSN concatenated into the persisted
|
||||
authz audit `Resource` (`Program.cs:674`) and an unauthorized document-content endpoint
|
||||
(`GET /uploads/{documentId}/content`). Four documents claim the audit trail holds no PII
|
||||
and the test cited as enforcing it (`AuthzAuditTests.cs:51-53`) asserts on **column
|
||||
names**, so the BSN travels in a column called `Resource` that the regex cannot see — the
|
||||
value-asserting test is part of RB-02's definition of done, not a follow-up.
|
||||
|
||||
**RB-11 ships the doc correction in the same diff as the code.** `?role=` and `?subject=`
|
||||
are _not_ stripped from production builds on three hand-written `fetch` adapters, while
|
||||
`docs/reference/roles-and-access.md:23` says "they do not exist in a production build".
|
||||
Correcting the doc without the code, or the code without the doc, both leave the repo
|
||||
lying about itself. `?subject=` additionally writes a **BSN into `sessionStorage`** in any
|
||||
build, which is the specific thing `SessionStore`'s G1 comment promises never happens.
|
||||
|
||||
**RB-12 before RB-19, deliberately.** Agent 07 flags CQ-006 as needing the authz suites as
|
||||
its safety net; agent 04 flags it as the prerequisite for OOM-A. RB-12's route-table test
|
||||
is the check that "each moved endpoint kept its gate" is verified by CI rather than by a
|
||||
reviewer's eye across a 900-line diff. RB-19 carries the only **High** risk in the table
|
||||
for exactly that reason and must land alone, never mixed with a behaviour change.
|
||||
|
||||
**RB-07 gates ADR-C-009, not the other way round.** Agent 06's proposed four-part test for
|
||||
runtime-editable config includes "writes are admin-capability-gated **and audited**".
|
||||
Today they are gated and not audited. Signing the ADR amendment first would ratify a
|
||||
control the code does not implement.
|
||||
|
||||
**RB-13's dependency on RB-09 is real, not stylistic.** Landing `Principal` on the
|
||||
frontend alone closes ADR-C-004 and leaves BIO-002 wide open: a production behandelportal
|
||||
build still resolves to the seeded **zorgverlener** — failing closed on backoffice
|
||||
capabilities (correctly) but **open on every citizen-scoped endpoint** and holding
|
||||
`CanRevealBigNummer`, because `drafter` is the no-header default. RB-09 makes "no
|
||||
identity" representable at the interface; RB-13 is the FE half.
|
||||
|
||||
---
|
||||
|
||||
## Merges — what was deduplicated, and how confident each merge is
|
||||
|
||||
| Merged ticket | Findings folded in | Confidence | Reasoning |
|
||||
| --------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RB-10** | TE-001 + BIO-017 | **Certain** | Agent 07 says outright: "this is TE-001 plus one assertion; it does not need its own ticket if TE-001 is scheduled". BIO-017's second half (`redactProfile` spec) is a five-line spec in the same PII-guard category, so it rides along. |
|
||||
| **RB-11** | BIO-012 + TE-002 + BIO-006(a) + BIO-006(b) | **Certain** | Agent 07 instructs: "Fix all three in one touch of the file, or the next reviewer will re-open it." All four land in the same three `fetch` adapters plus `role.ts`/`subject.ts` plus one doc line. BIO-006(b) is the same doc edit as BIO-012's. |
|
||||
| **RB-09** | BIO-001(a) + BIO-001(b) + BIO-002 | **Certain** | BIO-001's own remediation _is_ (a) fail-fast + (b) "give `Resolve` a way to say no identity (see BIO-002)". BIO-002's root cause is the same non-nullable `Resolve`. One change, one file pair. |
|
||||
| **RB-17** | CQ-003 + CQ-005 | **Certain** | Agent 04: "Fix them in one ticket; they are listed separately only because the module scope requires it." One shared-file split, five call sites. |
|
||||
| **RB-14/12** | BIO-016 split into (a) and (b) | **Certain** | Two unrelated CI changes of different size and different value; the rest of BIO-016's "Absent" list is genuinely a production gate and stays on the checklist. |
|
||||
| **RB-08** | BIO-003, sequenced behind RB-07 | High | Routing through `CasesAdmin` gives BIO-003's missing audit row for free **once** RB-07 has moved auditing to the allow path. Shipping BIO-003 first would mean writing the audit call twice. It can ship standalone if RB-07 slips. |
|
||||
| **RB-18** | BIO-018, sequenced behind RB-17 | High | Agent 07: "Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set." Not a merge, an ordering constraint. |
|
||||
| **RB-25/26/27** | TE-003/004/005, sequenced behind RB-24 | **Judgement call** | Agent 04 argued BL-010 must be resolved before anything is layered onto the upload folder, and RB-24 (ADR-C-002) is the ticket that resolves it. But the three seams are each independently shippable **today**, against the current paths. If RB-24 is deferred or rejected, unblock all three — the dependency is hygiene, not correctness. |
|
||||
|
||||
**Merges considered and rejected:**
|
||||
|
||||
- **BIO-008 / BIO-009 / BIO-010 kept as three tickets (RB-02/04/05).** They share a theme
|
||||
("no BSN in any audit row, log line or persisted error field") and a shared acceptance
|
||||
criterion (assert on **values**, e.g. no stored string matching `\d{9}`). They were not
|
||||
merged because they sit in three modules with three different test suites, and BIO-010
|
||||
is conditional on `Zgw:Enabled` (off by default) which gives it a different risk profile.
|
||||
Three one-line fixes that each ship alone beat one cross-module sweep. **If a reviewer
|
||||
prefers one ticket, merging them is defensible** — this is the least settled call here.
|
||||
- **CQ-002 not merged into BIO-007 (RB-07).** They are the two halves of the same
|
||||
admin-mutation-observability gap, but one is FE error surfacing and the other is BE
|
||||
auditing. Agent 07 asked only that they "ship aware of each other". Cross-referenced,
|
||||
not merged.
|
||||
- **`SessionStore` not merged across the TE-001 / residual-auth-duplication overlap.**
|
||||
Both touch `session.store.ts`, but agent 06 is explicit that merging the two apps'
|
||||
session stores now would cement a citizen DigiD/BSN login as the backoffice's login —
|
||||
the exact outcome ADR-0002 §3 exists to prevent. RB-10 lands the same seam **twice**, on
|
||||
purpose. The duplication question reopens only after RB-13, on re-measurement.
|
||||
- **ADR-C-004 not merged into BIO-002.** Split into RB-09 (BE, S) → RB-13 (FE, M) instead,
|
||||
because a single ticket spanning both would not be independently deployable.
|
||||
|
||||
---
|
||||
|
||||
## Tickets that were rejected and split (rule 3)
|
||||
|
||||
**CQ-007 → RB-22 then RB-23.** As filed, CQ-007 is the one finding agent 04 marked
|
||||
"**no** — FE+BE together": the FE must handle a 404 that the BE does not yet return.
|
||||
Shipping it as one ticket is a coordinated release. Split into the standard
|
||||
expand/contract pair:
|
||||
|
||||
1. **RB-22 (expand, FE).** `BriefStore.load()` tolerates a 404 by calling the existing
|
||||
`reset()` command once. Deploys against today's backend as a **no-op** — the BE never
|
||||
404s, so the branch is dead on arrival and provably safe.
|
||||
2. **RB-23 (contract, BE).** `GET /brief` returns 404 when no brief exists;
|
||||
`BriefStore.GetOrCreate` splits into `Get` + the already-existing `ResetAndCreate`.
|
||||
Deploys only once RB-22 is live.
|
||||
|
||||
Agent 07 rejected CQ-007's documentation-only alternative outright: "a non-idempotent GET
|
||||
must be visible in the code, not only in a ticket". That alternative is therefore **not**
|
||||
on the table.
|
||||
|
||||
**No other ticket failed the single-deploy test.** TE-001 lands in two apps but in one
|
||||
merge; RB-24 touches 30 dependents but is one atomic move; RB-19 is a 900-line diff but
|
||||
zero-semantic-change.
|
||||
|
||||
---
|
||||
|
||||
# ADR-fix tickets — architect approval required before any dependent code ticket
|
||||
|
||||
None of these five is a code change. All five change what the repo's architecture
|
||||
documents _claim_. **Three of them require a matching CLAUDE.md correction in the same
|
||||
diff** (CLAUDE.md's own precedence rule: "the docs win — update this file").
|
||||
|
||||
| ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status |
|
||||
| ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- |
|
||||
| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | **done** |
|
||||
| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | **done** |
|
||||
| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | **done** |
|
||||
| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | **done** |
|
||||
| **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** |
|
||||
|
||||
**No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it
|
||||
as instructed and rejected it with evidence: `grep -rn "Principal" apps libs` returns one
|
||||
comment and no type, so the rule was never _tested_, only _unexecuted_. Amending it now
|
||||
would ratify the omission rather than the evidence. The correct sequence is
|
||||
ADR-C-005 (done) → **RB-13** → **re-measure BL-002**; agent 06's expectation is that the
|
||||
residual duplication drops from 151 lines to under 40 on its own. If RB-13 is still
|
||||
unstarted at the next backlog cycle, _that_ is when the ADR-fix conversation becomes
|
||||
legitimate.
|
||||
|
||||
---
|
||||
|
||||
# Production gates — a release checklist, not tickets
|
||||
|
||||
These are **correct for a POC** and must be true before the system holds real BSNs. They
|
||||
are deliberately kept out of the ticket table: they are acceptance criteria for a release
|
||||
that does not exist yet (there is no production build artifact at all — **BIO-020**), not
|
||||
work that can be merged and deployed this week. Where a _part_ of a production-gate
|
||||
finding was shippable now, that part was pulled out as a ticket and is named below.
|
||||
|
||||
**Identity and access (9.1, 9.2, 9.4)**
|
||||
|
||||
- [ ] Replace `StubIdentityProvider` with verified DigiD / employee-SSO claims. `X-Role`,
|
||||
`X-Subject`, `X-Medewerker`, `X-Rollen`, `X-Admin` removed as **inputs**, not ignored. — BIO-001
|
||||
- [ ] Verify by building both apps `--configuration production` that the backoffice cannot
|
||||
act as a citizen. — BIO-002 _(the interface half is **RB-09**; the FE half is **RB-13**)_
|
||||
- [ ] Row-level scoping on every read returning person data; acceptance = a second seeded
|
||||
citizen cannot see the first's dashboard, notes, BRP address or diplomas. — BIO-013
|
||||
- [ ] The PII-reveal capability comes from the app overlay, not the coarse role, and is
|
||||
**not held by the default role**. — BIO-006 _(the `X-Step-Up` literal is in **RB-11**)_
|
||||
- [ ] Real step-up: a server-verified assurance/recency attribute no client can satisfy
|
||||
with a constant. — BIO-006
|
||||
|
||||
**Cryptography (8.24)**
|
||||
|
||||
- [ ] Encryption at rest with documented key custody and rotation. — BIO-014
|
||||
**Prerequisite: RB-02/04/05 first**, so the BSN is not in three places that do not
|
||||
need it before deciding what must be encrypted.
|
||||
- [ ] Document bytes move to encrypted object storage keyed by `DocumentId`. — BIO-014
|
||||
- [ ] TLS everywhere: `UseHttpsRedirection` + HSTS at the edge. — BIO-015
|
||||
- [ ] Security response headers (`nosniff`, CSP, `Referrer-Policy`) and a real
|
||||
`AllowedHosts`. — BIO-015 _(the Swagger gate is **RB-15**)_
|
||||
|
||||
**Logging, monitoring and retention (8.15, 8.16)**
|
||||
|
||||
- [ ] Audit retention, integrity and access defined — how long, append-only, and who may
|
||||
read `/beheer/audit` (it reuses `cases:manage`, which `Program.cs:565` already flags
|
||||
as a placeholder for a dedicated `audit:read`).
|
||||
- [ ] Log shipping and alerting — the audit trail is a SQLite table with no export path.
|
||||
- [ ] _(Covered by tickets: allow-path auditing = **RB-07**; no BSN in any audit row, log
|
||||
line or persisted error field = **RB-02/04/05**.)_
|
||||
|
||||
**Data protection (5.12, 5.13)**
|
||||
|
||||
- [ ] A DPIA covering BSN, uploaded identity documents and the register, with lawful basis
|
||||
and retention schedule. Nothing in the repo covers this.
|
||||
- [ ] Deletion / retention policy for uploaded documents and the audit trail.
|
||||
- [ ] _(Covered: data minimisation on list endpoints = **RB-03**.)_
|
||||
|
||||
**Secure development (8.25, 8.28, 8.29)**
|
||||
|
||||
- [ ] Secret scanning in CI (prevention — nothing is committed today, verified). — BIO-016
|
||||
- [ ] Backend architecture enforcement (NetArchTest/ArchUnitNET) so `Domain/` purity, ZGW
|
||||
containment (ADR-0005) and "authorization lives in `Authz`" are CI- rather than
|
||||
review-maintained. — BL-006
|
||||
- [ ] A coverage ratchet, so a security fix can be verified as not regressed by CI. — BL-009
|
||||
- [ ] Penetration test / DAST, with BIO-004's object-level authorization and BIO-005's
|
||||
document linking as named cases.
|
||||
- [ ] _(Covered: backend dependency scanning = **RB-14**; the authorization regression gate
|
||||
= **RB-12**.)_
|
||||
|
||||
**Change control (8.32)**
|
||||
|
||||
- [ ] A production build and deployment artifact exists, separate from the demo compose
|
||||
file, and its release checklist references this list. — BIO-020
|
||||
- [ ] Verify **by build, not by reading**: in a production bundle `?role=`, `?subject=`,
|
||||
`?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the
|
||||
three hand-written `fetch` paths. — BIO-012 _(the code fix is **RB-11**; this box is
|
||||
the build-time proof)_
|
||||
|
||||
---
|
||||
|
||||
# Verified clean — do not "fix"
|
||||
|
||||
Each of these was read and judged correct by the agent named. Re-checking them is wasted
|
||||
effort; "simplifying" them is a regression.
|
||||
|
||||
**Security and access control** (agent 07, verified endpoint by endpoint)
|
||||
|
||||
- `AccessStore.can()` deny-by-default + `whenReady()` — the pair exists so the guard cannot
|
||||
read `can()` mid-load and deny an entitled user.
|
||||
- `capabilityGuard`'s "UX pre-gate, the backend re-enforces" claim — verified true for all
|
||||
six admin surfaces; every capability the guard checks has a server-side twin.
|
||||
- `Authz.CanBeoordelen`'s caller-kind derivation — the one capability a forged `X-Role`
|
||||
cannot reach, and the reason BIO-002 fails _closed_ in that direction.
|
||||
- The four-eyes rule in `Authz.CanActOn`, Forbidden-before-Conflict ordering.
|
||||
- The `isDevMode()` gate on the debug panel and on the interceptor chain (the _interceptor_
|
||||
chain is correctly gated — RB-11 is about the three adapters that bypass it).
|
||||
- The ZGW client secret never reaching the browser; the notification webhook failing closed
|
||||
on an unset secret; `ZgwDiagnosticHandler` logging no bodies and being opt-in.
|
||||
- The upload content-type allow-list enforced **server-side** — which is also why
|
||||
`nosniff` is a checklist item and not a finding.
|
||||
- Stamdata having no runtime write endpoint at all.
|
||||
- `libs/shared/src/kernel/{bsn,pii}.ts` — the standard the rest should be measured against.
|
||||
- No secrets committed; no `.db` file tracked (both verified by `git check-ignore`/`ls-files`).
|
||||
|
||||
**Architecture and structure**
|
||||
|
||||
- **ADR-0005 is fully conformed — zero findings** (agent 06). The ZGW anti-corruption layer
|
||||
is the repo's worked example; the ADR even predicted its own remaining gap and the gap
|
||||
stayed where predicted.
|
||||
- **`bhp/behandeling` is the CQRS-light reference implementation** (agent 04). Query
|
||||
adapters, command adapter and command factory in separate files, write-free read stores.
|
||||
Do not "clean it up".
|
||||
- **The FE dependency structure is not a problem area** (baseline §6): 0 violations across
|
||||
11 `severity: error` rules, textbook instability gradient (`kernel` I=5%, contexts I≥83%).
|
||||
Do not spend tickets here.
|
||||
- `BigProfileStore` — the reference implementation of the read/write split (agent 04).
|
||||
- The `ToDetailDto(now)` / `ToDto(now)` status projection — a real read-model derivation;
|
||||
do not let a future ticket "simplify" it into a stored status column (agent 04).
|
||||
- The 7 static backend stores and `[assembly: DisableTestParallelization]` — deliberate,
|
||||
documented in `Data/Db.cs`, and explicitly _not_ challenged by agents 02, 04 or 07.
|
||||
RB-30 works **because** the rules never needed the DbContext, not by redesigning stores.
|
||||
|
||||
**Baseline rows closed as false gaps** (agent 02, verified — do not ticket them)
|
||||
|
||||
- `libs/shared/domain` 0% reach / 3 files, and `libs/beheer/contracts` 0% reach / 1 file.
|
||||
Both are pure type declarations with **zero executable statements**; 0% is correct and
|
||||
unimprovable. BL-004 named both as "genuine gaps"; that part of BL-004 is superseded.
|
||||
- 23 of the 25 CC>10 TS functions are reducers / `parse*` / `validate*` — mandated house
|
||||
idioms (**BL-001**). A bare CC number is not grounds for a ticket against any of them.
|
||||
- `createDraftSync` is **acquitted on testability** (explicit deps object, optional
|
||||
injection, `enabled()` escape hatch, has a spec). RB-21 is a CQRS split, not a fix.
|
||||
- `httpClientFetch`, `Contracts/Mappers.cs`, `submit-besluit.ts`, `breadcrumb-trail.ts`,
|
||||
`route-focus.ts`, `AccessStore.can()` — all "missing test, not blocked test", or a seam
|
||||
that costs more than it returns. Filing them would be volume, not quality.
|
||||
|
||||
---
|
||||
|
||||
# Out of mandate — recorded so a later phase does not read this file as a step toward them
|
||||
|
||||
- **OOM-A — `Program.cs` → `Features/` folders with handler types.** BL-003's most obvious
|
||||
invitation, and out of mandate because §7 is explicit that the backend has "no handler
|
||||
types, no mediator, no `Features/` folders" — there is no structure to extend, only one
|
||||
to introduce. **RB-19 is a strict prerequisite** if it is ever taken: you cannot cut a
|
||||
940-line file into vertical slices while five of its seven sections interleave
|
||||
directions. Agent 03, which would have owned this, did not run.
|
||||
- **OOM-B — read/write repository split in `backend/Data`.** Would introduce the pattern
|
||||
where §7 records it absent, and collides with the documented static/no-DI design.
|
||||
- **OOM-C — no read model, no event sourcing, and none proposed.**
|
||||
- **OOM-D — BL-011: the FE suite is flaky under parallel load, and BL-009 means nothing
|
||||
ratchets.** "CI green" alone does not verify any ticket in this backlog. Verify against
|
||||
`00-baseline.md`'s numbers — **and note that §3a, §3b and §5 are already partly stale**
|
||||
after the five shipped commits (auth duplication 211→151; `libs/shared/infrastructure`
|
||||
coverage no longer dragged down by the generated client). **Re-run the baseline before
|
||||
using it as the before-picture for any ticket below.**
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
| Source finding | Where it went |
|
||||
| ------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| TE-001…008 | RB-10, RB-11, RB-25, RB-26, RB-27, RB-28, RB-29, RB-30 |
|
||||
| TE-009 | **shipped** |
|
||||
| CQ-001, 002, 003+005, 006, 007 | RB-21, RB-20, RB-17, RB-19, RB-22+RB-23 |
|
||||
| CQ-004 | **shipped** (BE audit half outstanding → RB-07) |
|
||||
| ADR-C-001, 003, 007, 009 | ADR-fix table |
|
||||
| ADR-C-002, 004, 008, 010, 011 | RB-24, RB-13, RB-32, RB-31, RB-33 |
|
||||
| ADR-C-005, 006 | **shipped** |
|
||||
| BIO-001, 002 | RB-09 + checklist |
|
||||
| BIO-003, 004, 005, 007, 008, 009, 010, 011, 018, 019 | RB-08, RB-01, RB-06, RB-07, RB-02, RB-04, RB-05, RB-03, RB-18, RB-16 |
|
||||
| BIO-006 | RB-11 (a+b) + checklist (c) |
|
||||
| BIO-012, 017 | RB-11, RB-10 |
|
||||
| BIO-015, 016 | RB-15 + checklist; RB-14 + RB-12 + checklist |
|
||||
| BIO-013, 014, 020 | checklist only |
|
||||
| BL-008 | **shipped** |
|
||||
| BL-005, BL-009, BL-011 | **unowned** — see "Coverage of this backlog" and OOM-D |
|
||||
| BL-001, BL-002, BL-004 (partly), BL-006, BL-007, BL-010 | absorbed into the tickets/checklist above |
|
||||
@@ -0,0 +1,112 @@
|
||||
# Agent run status
|
||||
|
||||
| Agent | Status | Last module processed | Last updated | Notes |
|
||||
| --------------- | ------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. |
|
||||
| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. |
|
||||
| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. |
|
||||
| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. |
|
||||
| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs` → `Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". |
|
||||
| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. |
|
||||
| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. |
|
||||
| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). |
|
||||
| consolidation | complete (approved) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. **Approved 2026-08-27; HALT lifted.** |
|
||||
|
||||
## Phase 3 — implementation
|
||||
|
||||
**All six batches complete, 2026-08-28. All 33 code tickets (RB-01..RB-33) and the four gated ADR-fixes (ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009) merged to `refactor/adr-c-006-shared-route-guards`, one commit per ticket. `npm run ci` green after every merge, verified independently before trusting any agent's own report.** Every batch carrying a **SIGN-OFF** ticket shipped only after the architect approval recorded 2026-08-27 (HALT lifted). Three of the four ADR-fixes required a matching CLAUDE.md correction (§2 once, §4 twice); all three landed in the same diff as their ADR amendment, per CLAUDE.md's own precedence rule.
|
||||
|
||||
Across the six batches, several tickets turned out to be factually wrong, incomplete, or overstated relative to the actual code, and every one was reported rather than silently patched over — among them: two ADR-fixes (ADR-C-001's stale out-of-scope bullet, ADR-C-007 catching only half of its own finding), RB-18 (real scope one endpoint, not the several the stale line numbers implied), RB-23 (an unmentioned second `GetOrCreate` call site forced onto the same contract change), RB-25 (overstated which methods the missing token actually blocked), RB-27 (correctly declined to extract abort-vs-error into a signature that cannot express it, and declined an optional move outside its stated scope), RB-28 (TE-006's "cannot test the success case" claim was already false when written), and RB-31 (found a genuinely unreachable state — cursor 2 with no diploma chosen — that the old hand-rolled fixture had been asserting). None of these weakened a ticket; each was implemented correctly once the discrepancy was named.
|
||||
|
||||
| CD batch | Tickets | Status | Notes |
|
||||
| -------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
|
||||
| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
|
||||
| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
|
||||
| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. |
|
||||
| 5 | RB-24..RB-30 | **complete** | All seven merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Ran as three waves, not the two the backlog implied: RB-24 rewrites imports in `brief.store.ts` and `org-template.store.ts`, which are two of RB-28's three targets — a dependency the backlog's "25/26/27 depend on 24" note never mentioned. **A** = RB-24 alone (the move), then **A2** = RB-29 + RB-30 in parallel (backend, no file overlap with the move or each other), **B** = RB-25 + RB-26 + RB-28 in parallel once RB-24 landed, **C** = RB-27 alone last, since it depends on RB-25's transport token. **RB-24 expanded its own scope, correctly.** Deleting the dependency-cruiser carve-out — the ticket's own acceptance criterion — exposed a second, real `ui-not-infrastructure` violation the old path had hidden: three UI components injected `UploadAdapter` for nothing but a one-line wrapper over its own exported pure function. The dispatch prompt said to report a second violation, not fix it; the agent judged this one was on the critical path (`dep:check` cannot pass with the carve-out gone otherwise) and fixed it minimally, reusing the existing pure function. Reviewed before merging — sound. **Two more findings were shown to be stale or overstated, on top of the two ADR-fixes found wrong and RB-18/RB-23's incompleteness from batch 4 — nine total now.** RB-25 found TE-003 overstated its own blocker: of the four methods named, only `upload()` and `cancel()` were actually unfakeable through the missing token — `delete()`/`pollReturning()` already went through the exported `UploadAdapter`. RB-28 found TE-006 already false at the time it was written: `brief.store.spec.ts` already had a `previewLetter` success test via jsdom's spyable `URL`/`window` stubs, contradicting the finding's "cannot test the success case" claim — the overall three-site diagnosis still held and was shipped as instructed. **RB-26 made one real design call**, reviewed before merging: `planFileSelection` must return `UploadMsg[]` per its literal signature, but an accepted file's real `localId` needs `crypto.randomUUID()`, which the ticket itself keeps in the controller. It ships a placeholder `localId: ''` discriminated by `.type` alone and never dispatched — verified the index alignment holds for both the multiple-rejection short-circuit and the per-file path. **RB-27 left one thing unextracted, correctly**: TE-005 lumped abort-vs-error disambiguation into the same extraction as `uploadOutcome`, but abort fires on a different event with no `status`/`responseText` at all — it structurally cannot fit the proposed signature. Left in place as a one-line ternary. The optional `currentScenario()` move into `KeepaliveTransport` was also correctly declined — it would have crossed into `upload-shell.service.ts`, outside this ticket's stated single-file scope. **End state of `libs/shared/upload`** (now split across proper layers): every layer that can hold pure logic has one and is spec'd — `upload.machine.ts` (domain, `planFileSelection`), `upload-shell.service.ts` (application, the `UPLOAD_TRANSPORT` seam), `upload-controller.ts` (application), `upload.adapter.ts` (infrastructure, `uploadOutcome`). Only the XHR/DOM boundary itself stays untested by design — TE-005 was explicit that abstracting `XMLHttpRequest` away is not wanted, since the file documents why XHR (not `fetch`) is required. |
|
||||
| 6 | RB-31, RB-32, RB-33 | **complete** | All three merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Dispatched as one wave — no file overlap at all (four machine specs in three apps, one docs file, one testing helper plus its one call site). **RB-31 found a real ADR-0006 violation, not a false alarm.** Two `registratie-wizard` tests asserted a state — cursor 2, no diploma chosen — that the real reducer cannot produce, since advancing past `beroep` (cursor 1→2) requires `KiesDiploma`/`KiesHandmatig` to have already run. This is exactly what forcing fixtures through message replay is for: a hand-rolled literal let an impossible state sit in the suite undetected. Fixed by replaying to cursor 1 and applying the diploma choice there instead; `submit()` validates the whole draft regardless of cursor, so the assertions are byte-identical to before — reviewed the diff before merging to confirm the old and new test bodies check the same thing. `intake.machine.spec.ts` also had an existing, correct `intake.testing.ts` sitting unused in its own folder, imported only by the acceptance spec — now wired to both. **RB-32 added the missing `language-switcher` row and took the ticket's explicitly-optional second step**: a ~14-line drift guard in `check-tokens.sh` that diffs every `CIBG-GAP EXTENSION` marker's component directory against the register's rows and fails naming what's missing. Verified the regex before trusting it — two existing rows carry parenthetical suffixes (`wizard-shell (error summary only)`) and the extraction correctly captures only the backtick-quoted name. This closes ADR-0003's own predicted failure mode ("if markers and this table drift, trust the code and fix the table") permanently rather than fixing it once more. **RB-33 made the real adopt-or-delete call the finding asked for, and chose delete.** `unwrapOk` had zero consumers anywhere in the repo since it shipped; manufacturing a first caller purely to satisfy the ticket would have removed no actual duplication, since there was only one occurrence to begin with. Deleted the helper and its doc mention; left the one candidate call site's inline guard alone, since it already satisfies ADR-0006 §3's real requirement (never a cast). |
|
||||
| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. |
|
||||
|
||||
**Standing caveat for every batch:** `dotnet test` reports one failure,
|
||||
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||
which needs a live OpenZaak container. It fails identically on a stashed tree — it is not
|
||||
caused by any of these tickets. `npm run ci` does not run it.
|
||||
|
||||
## Gate integrity — read before trusting any "ci green" in this file
|
||||
|
||||
`scripts/ci-local.sh` chained six of its steps as `cmd1 && cmd2` under `set -e`. Bash exempts
|
||||
every command of an AND-OR list **except the last** from `errexit`, so a crash in `cmd1` was
|
||||
swallowed: the paired check never ran and the script still printed "local CI passed". Verified
|
||||
directly — `bash -c 'set -e; false && true; echo hi'` prints `hi` and exits 0.
|
||||
|
||||
This hid a **real** `gen:api` crash introduced by RB-09 (`dotnet swagger`'s design-time host
|
||||
defaults to Production, which RB-09 made throw). `.github/workflows/ci.yml` runs each step as
|
||||
its own `- run:` and would have caught it, so the local gate was strictly **weaker** than the
|
||||
remote one — the opposite of its purpose. The worst instance was
|
||||
`ng build ssp --localize && ng build behandelportal --localize`: a missing English translation
|
||||
in ssp could not fail the run.
|
||||
|
||||
Fixed in `build: stop ci-local.sh swallowing the first half of every paired step`. **Every
|
||||
"ci green" recorded for batch 1 and for RB-07/RB-10/RB-11 predates that fix** and is therefore
|
||||
weaker than it reads; the batch-2 completion run above is the first one made on the honest gate
|
||||
(13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been
|
||||
re-verified under the fixed gate either.
|
||||
|
||||
## Dispatching implementation agents — what actually goes wrong
|
||||
|
||||
Batches 2 and 3 ran tickets as parallel agents in git worktrees. Six of seven agent-runs hit at
|
||||
least one of these. Put all of it in the prompt.
|
||||
|
||||
1. **The worktree base is not reliable.** **Four of the six** agents were handed a worktree
|
||||
branched from a stale ancestor — batch 3 was **three for three**, all landing on `ae7781e`,
|
||||
an unrelated lineage missing every RB ticket _and_ this backlog directory. Make step zero:
|
||||
`git log --oneline -8`, confirm a **named expected commit**, `git merge` the target branch if
|
||||
absent, and report which it was. The one agent that was not told to do this found out by luck.
|
||||
2. **Agents park on background tasks.** Two agents in batch 2 ran `npm run ci` in the background,
|
||||
then ended their turn waiting for a notification that never usefully arrived; one finished its
|
||||
work twice and never committed it. Ban `run_in_background` and Monitor, **and say explicitly to
|
||||
pass the Bash tool's own `timeout: 600000`** — a batch-3 agent still auto-backgrounded because
|
||||
"run it in the foreground" alone does not defeat the 120s default.
|
||||
3. **`git checkout <file>` destroys the work.** Agents verify a test is red by undoing the fix;
|
||||
restoring it with `git checkout` reverts the whole file. Tell them to undo and redo with edits.
|
||||
4. **`behaviour-spec.mdx` conflicts on nearly every merge.** It is generated and every agent
|
||||
regenerates it. Resolve centrally with `npm run gen:behaviour-spec && git add`; tell agents to
|
||||
expect it and never hand-edit.
|
||||
5. **Concurrent `dotnet test` runs flake** — a burst of `SQLite Error 1: 'no such table: Documents'`.
|
||||
Partly host pressure, but RB-12 found a real cause: a bare `new WebApplicationFactory<Program>()`
|
||||
races on the static `Db.ConnectionString`. Use the house `TestWebApplicationFactory` +
|
||||
`IClassFixture` idiom. Do not trust a backend green while another agent is testing.
|
||||
6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by
|
||||
ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`.
|
||||
|
||||
7. **The stale base is now the rule, not the exception.** Batch 4 ran six agents; **five were
|
||||
handed a bad base**, three of them the same unrelated `ae7781e` lineage. Across batches 2-4 that
|
||||
is **11 of 13 agent-runs**. Every one self-corrected at step zero. Keep the named-expected-commit
|
||||
check as the first instruction in every prompt — it is the highest-value line in there. Give the
|
||||
_current_ HEAD, not the batch's starting commit, when tickets merge sequentially.
|
||||
8. **A spend limit can kill every agent mid-flight; resume, do not restart.** All four wave-A agents
|
||||
died on an org monthly-spend 429, three of them at the final CI re-run. Their worktrees kept the
|
||||
uncommitted work intact. Sending each agent a message resumed it from its own transcript and it
|
||||
finished from exactly where it stopped — nothing was redone. Check `git -C <worktree> status`
|
||||
before assuming work is lost.
|
||||
9. **The `99-backlog.md` conflict is mechanical and has a fixed recipe.** It fired on three of five
|
||||
merges. Cause: the central prettier pass reflows the table's column widths, so the whole table
|
||||
conflicts even though the two sides differ in only one or two status cells. Recipe: take HEAD's
|
||||
table, flip the incoming ticket's cell, re-run prettier. Verify by parsing both sides cell-by-cell
|
||||
and printing only the differing cells before discarding either side — do not eyeball a 33-row
|
||||
table. A ticket's cell reads `**done**` once merged (matching RB-01..RB-17), not `implemented`.
|
||||
10. **For a zero-semantic-change commit, ask for evidence CI cannot give.** RB-19's diff is 181 lines
|
||||
in `Program.cs` and no test can prove it changed nothing. The sorted-route-list diff, plus the
|
||||
route/gate/handler-triple comparison, is what actually made it reviewable. Ask for it in the
|
||||
prompt and re-run it centrally before merging.
|
||||
11. **`gen:api` is not always a no-op on a pure reorder.** OpenAPI operation order follows mapping
|
||||
order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to
|
||||
prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to
|
||||
commit the regenerated pair, or CI's drift job fails on a correct change.
|
||||
12. **The backlog's own "depends on" column is not exhaustive — check actual imports before parallelizing a wave.** Batch 5's table said only "25/26/27 depend on 24"; it never mentioned that RB-24 rewrites imports in two of RB-28's three target files. `grep -rln` for the moved module's import path against every other open ticket's target files, before deciding what runs in parallel — not after a conflict.
|
||||
|
||||
**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong
|
||||
about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and
|
||||
RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review
|
||||
of the diff alone.
|
||||
+46
@@ -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.
|
||||
+39
@@ -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.
|
||||
+38
@@ -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.
|
||||
+40
@@ -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.
|
||||
+39
@@ -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.
|
||||
+38
@@ -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.
|
||||
+40
@@ -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.
|
||||
+48
@@ -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.
|
||||
+59
@@ -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.
|
||||
+57
@@ -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.
|
||||
+69
@@ -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.
|
||||
+64
@@ -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.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# ADR-C-006 — extract the actor-agnostic route guards to `libs/shared`
|
||||
|
||||
Status: **implemented** · 2026-08-26 · Source finding: `06-adr-conformance.md` §ADR-C-006
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `libs/shared/src/application/auth.guard.ts` | **new** — `authGuard` + `capabilityGuard`, injecting `SESSION_PORT` instead of an app-local `SessionStore` |
|
||||
| `libs/shared/src/application/auth.guard.spec.ts` | **new** — the single spec, provides `SESSION_PORT`; one case added asserting the guard reads the port |
|
||||
| `libs/shared/src/application/session.port.ts` | widened by one member: `readonly isAuthenticated: Signal<boolean>` |
|
||||
| `apps/ssp/src/app/auth/auth.guard.ts` | now a re-export |
|
||||
| `apps/behandelportal/src/app/auth/auth.guard.ts` | now a re-export |
|
||||
| `apps/{ssp,behandelportal}/src/app/auth/auth.guard.spec.ts` | **deleted** — both were byte-identical to the new shared spec |
|
||||
|
||||
Neither `app.routes.ts` was touched: both still `import { authGuard, capabilityGuard }
|
||||
from '@auth/auth.guard'`. Routing asks the auth context for its guards, which is the
|
||||
direction the boundary should read.
|
||||
|
||||
## Why the port widening was free
|
||||
|
||||
Both `SessionStore`s already exposed `readonly isAuthenticated = computed(() =>
|
||||
this._session() !== null)` (`session.store.ts:40` in each app), and both apps already
|
||||
registered `{ provide: SESSION_PORT, useExisting: SessionStore }`
|
||||
(`app.config.ts:64` / `:65`). `SessionPort` is satisfied structurally, so adding the
|
||||
member required no change in either app — the seam existed, it was just narrower than
|
||||
what it already carried.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
Only the guards moved. Per the finding, **no** ticket to merge `session.store.ts`,
|
||||
`session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and no
|
||||
relaxation of ADR-0002 §3. Those five are identical because ADR-C-004 (`Session →
|
||||
Principal`) was never executed; merging them would cement a citizen DigiD/BSN login as
|
||||
the backoffice's shared login, which is the outcome §3 exists to prevent.
|
||||
|
||||
## Measured effect
|
||||
|
||||
Re-ran `tools/baseline-scan.mjs --dup` after the change:
|
||||
|
||||
| Metric | Before | After |
|
||||
| --------------------------- | -----: | --------: |
|
||||
| `ssp/auth` duplicated lines | 211 | **151** |
|
||||
| `bhp/auth` duplicated % | 86.8% | **82.5%** |
|
||||
| Repo-wide duplication | 7.1% | **6.6%** |
|
||||
|
||||
The `auth.guard.spec.ts` (36 windows) and `auth.guard.ts` (21 windows) clone pairs have
|
||||
dropped out of the top-clones list entirely. The remaining `ssp/auth` ↔ `bhp/auth`
|
||||
duplication is `session.store.ts` (39), `login-form.component.ts` (35) and `login.page.ts`
|
||||
(23) — exactly the three ADR-C-004 is expected to differentiate. Re-measure BL-002 after
|
||||
that lands; the finding's expectation is a drop to under 40 lines.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run lint` · `npm run typecheck` · `npm run dep:check` (0 violations, 224 modules)
|
||||
· `npx prettier --check apps libs` — all clean.
|
||||
Tests: shared 122, ssp 235, behandelportal 27, beheer 23 — **407 passed, 0 failed**.
|
||||
`ng build --localize` for both apps.
|
||||
+65
@@ -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.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# ADR-C-009 — state the runtime-editable-config exception as a test, not a list
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-009
|
||||
· Gated on: **RB-07** (satisfied — batch 2)
|
||||
|
||||
## What was wrong
|
||||
|
||||
ADR-0004 said "never runtime-editable" and then named **one** exception in the singular,
|
||||
justified narrowly ("specific to one sub-organization's identity"). WP-47 added a second
|
||||
runtime-editable SQLite surface, `FeatureFlagStore`, whose own doc-comment states the
|
||||
equivalence the ADR did not: _"SQLite-backed like `OrgTemplateStore`, same single-gate
|
||||
idiom."_
|
||||
|
||||
The code is right; the ADR's text was wrong. A closed list of one leaves the next
|
||||
operational-config surface with no principle to test itself against.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `docs/reference/architecture/0004-...md` | §"The deliberate exception: org-templates" → §"The deliberate exception: operational configuration" — a four-part test plus a table of the two passing surfaces |
|
||||
| same file, §Context + the table | `src/locale/*.xlf` → `apps/<app>/src/locale/*.xlf` (two apps since WP-67) |
|
||||
| `CLAUDE.md` §4 | the singular "Org-templates are the deliberate exception" replaced with the four-part test |
|
||||
|
||||
No code changed — the finding says so outright, and verification confirmed it.
|
||||
|
||||
## Why the RB-07 gate was real, verified clause by clause
|
||||
|
||||
Clause (4) of the test is "writes are admin-capability-gated **and** audited". Signing this
|
||||
ADR before RB-07 would have ratified a control the code did not implement. RB-07 has landed,
|
||||
so the clause is now true. Read at `backend/src/BigRegister.Api/Program.cs:863-923`: each of
|
||||
the five gates now computes `var ok = …`, calls `AuditAuthz(ctx, capability, resource, ok,
|
||||
principal)` with the **real** boolean, and only then branches. `FlagsAdmin`'s own comment
|
||||
names this ticket: _"this is the surface CQ-004/ADR-C-009 hinge on."_
|
||||
|
||||
All four clauses were checked against both surfaces rather than assumed:
|
||||
|
||||
| Clause | `OrgTemplateStore` | `FeatureFlagStore` |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| (1) catalog in code | the `OrgTemplateDto` shape + `OrgTemplateRules` validate before save (`OrgTemplateStore.cs:49`) | `FeatureFlags.Catalog` (`Domain/Features/FeatureFlags.cs:15`) |
|
||||
| (2) fails closed | unknown `subOrgId` → `null` → endpoint 404s (`:44-45,:55-56,:72-73,:94-96`) | `Set` returns false for an unlisted key (`:54`); `IsEnabled` returns false (`:42-43`) |
|
||||
| (3) operational | one sub-organisation's letterhead | an on/off rollout switch |
|
||||
| (4) gated + audited | `OrgAdmin` → `orgtemplate:edit` (`Program.cs:863`) | `FlagsAdmin` → `flags:manage` (`Program.cs:914`) |
|
||||
|
||||
`FeatureFlagStore`'s own comment states clause (1) and (2) explicitly: _"The CATALOG … is
|
||||
code … this store only holds the admin's on/off overrides. An unknown key is never
|
||||
writable/enabled — the code catalog is the authority."_
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Clause (2) is about the write/enable path, not every read.** `OrgTemplateStore` has a
|
||||
deliberate read-path fallback for briefs from before WP-23 (`:110-114`, its own `ponytail:`
|
||||
comment): an empty `SubOrgId` falls back to the first seeded sub-org rather than failing a
|
||||
whole screen. That is a preview convenience on a read; the four write entry points all
|
||||
return `null` for an unknown sub-org. The clause is worded "cannot invent a setting,
|
||||
enable a feature, or be written" so this read fallback is not caught by it. Recorded
|
||||
because a reader checking clause (2) against `OrgTemplateStore.cs` will meet that
|
||||
fallback first.
|
||||
- **Org-templates' publish/rollback versioning is mentioned but excluded from the test.** It
|
||||
is stronger than the test requires, and making it a fifth clause would block a legitimate
|
||||
flag-style surface that has nothing to version.
|
||||
- **The stale `src/locale/*.xlf` paths were fixed in the same diff**, though ADR-C-009 did
|
||||
not flag them. They are two occurrences of the same WP-67 drift ADR-C-001 and ADR-C-007
|
||||
exist to correct, in the section being edited, and leaving a known-false path in a document
|
||||
while amending it is the exact failure mode those two findings describe. Scope creep is
|
||||
two words wide here; the alternative is filing a third ticket for it.
|
||||
|
||||
## Gate released
|
||||
|
||||
ADR-C-009 blocked "any ticket proposing a third runtime-editable config surface". Such a
|
||||
ticket can now be judged against a written test rather than by analogy to org-templates.
|
||||
@@ -0,0 +1,57 @@
|
||||
# RB-01 — authorize `GET /uploads/{id}/content` and `/uploads/status`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-004 · `99-backlog.md` RB-01
|
||||
|
||||
## What was wrong
|
||||
|
||||
`GET /uploads/{documentId}/content` took `(string documentId)` — no `HttpContext`, so no
|
||||
authorization was possible at all. It streams diploma and identity scans; the only
|
||||
protection was the unguessability of the document GUID. `DELETE` on the same resource has
|
||||
been owner-scoped (`DocumentStore.DeleteOwned`) since it was written.
|
||||
|
||||
`GET /uploads/status?localIds=` had the same shape, and leaks less but still confirms
|
||||
whether a given client-chosen `localId` exists anywhere in the store, plus its documentId.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` |
|
||||
| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` |
|
||||
| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) |
|
||||
| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases |
|
||||
|
||||
The two actor kinds are matched, not branched on a boolean, because `ctx.Zorgverlener()`
|
||||
**throws** for a `MedewerkerCaller` — a behandelaar reading an aanvraag's linked documents
|
||||
(`beoordeling-documenten.component.ts`) is a legitimate caller here:
|
||||
|
||||
```csharp
|
||||
var allowed = ctx.Caller() switch
|
||||
{
|
||||
ZorgverlenerCaller z => doc?.Owner == z.Bsn,
|
||||
var caller => Authz.CanBeoordelen(caller),
|
||||
};
|
||||
```
|
||||
|
||||
**404, not 403**, per the ticket: a foreign document id must not be distinguishable from
|
||||
one that never existed. `doc is null || !allowed` collapses both to the same answer, and
|
||||
`/uploads/status` reports a foreign `localId` as `"unknown"` — the same word an id that
|
||||
never existed gets.
|
||||
|
||||
## Known residual — this endpoint is reached without identity headers
|
||||
|
||||
Both callers link to the URL directly (`<a href>` in `beoordeling-documenten.component.ts`,
|
||||
`previewUrl` in `libs/shared/src/upload/upload.adapter.ts`), so the request is a plain
|
||||
browser navigation that carries **no** `X-Medewerker` / `X-Subject` header and never passes
|
||||
through an Angular interceptor. `StubIdentityProvider` therefore resolves it to the seeded
|
||||
citizen, which owns every document in the POC, so both links keep working — by coincidence,
|
||||
not by authorization. That coincidence **is** BIO-002, and it is fixed by **RB-09** (making
|
||||
`IIdentityProvider` able to express "no identity"), not here. RB-09 will need this endpoint
|
||||
to receive a real credential — a signed URL or a cookie — rather than the ambient default.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet build` clean. `dotnet test`: **250 passed, 1 failed** — the failure is
|
||||
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||
which needs a live OpenZaak container and **fails identically on a stashed tree**, i.e. it
|
||||
pre-dates this change.
|
||||
@@ -0,0 +1,58 @@
|
||||
# RB-02 — stop concatenating the BSN into `AuthzAudit.Resource`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-008 · `99-backlog.md` RB-02
|
||||
|
||||
## What was wrong
|
||||
|
||||
`Program.cs` (was `:674`) built the reveal attempt's audit resource ref as
|
||||
`"brief/" + ctx.Zorgverlener().Bsn`. `AuditAuthz` persists that string to the
|
||||
`AuthzAudit.Resource` column in SQLite, and `GET /admin/audit` renders it on the admin
|
||||
audit page — so a BSN was written to durable storage and shown in a UI, on the one trail
|
||||
four documents describe as data-minimised and PII-free.
|
||||
|
||||
The endpoint is the _BIG-nummer reveal_, whose own comment says the audit carries
|
||||
"NO PII. Never the value that was (or wasn't) revealed" — and it did not carry the
|
||||
BIG-nummer. It carried the BSN instead, in the adjacent argument.
|
||||
|
||||
## Why the existing test did not catch it
|
||||
|
||||
`AuthzAuditTests.The_audit_schema_carries_no_pii` asserts on **column names**:
|
||||
|
||||
```csharp
|
||||
Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …));
|
||||
```
|
||||
|
||||
A BSN inside a column called `Resource` is invisible to a regex over the word `Resource`.
|
||||
The test was structurally incapable of failing on this defect, which is why the
|
||||
value-asserting test is part of this ticket's definition of done rather than a follow-up.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `Program.cs` | resource ref is `"brief"`; a comment records why the id added nothing |
|
||||
| `AuthzAuditTests.cs` | **new** `No_audit_row_carries_a_subjects_bsn` — asserts on stored **values**, every string field |
|
||||
|
||||
No identifier was lost. `BriefStore` keys one brief per owner, so `brief/<bsn>` named the
|
||||
same thing the row's acting principal already implies; there is no second brief the ref
|
||||
could have disambiguated.
|
||||
|
||||
The new test drives a denied reveal as a **non-default** subject (`X-Subject: 999999990`),
|
||||
then scans every string field of every audit row for that BSN and for
|
||||
`DocumentStore.DemoOwner`. Asserting against the two BSNs actually in play, rather than a
|
||||
`\d{9}` shape, keeps it deterministic — a hex correlation id can hold nine consecutive
|
||||
digits by chance.
|
||||
|
||||
**Confirmed it fails without the fix**: reverting only the `Program.cs` line turns
|
||||
`No_audit_row_carries_a_subjects_bsn` red, and restoring it turns it green.
|
||||
|
||||
## Not in scope
|
||||
|
||||
`AuditEntry.Actor` on document audit rows also holds a raw BSN. That is a different store
|
||||
(`DocumentStore.Audit`) and is **RB-04**, which is where the masking decision for it lives.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the
|
||||
failure is `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live OpenZaak container
|
||||
and fails identically on a stashed tree.
|
||||
@@ -0,0 +1,45 @@
|
||||
# RB-03 — mask the owner BSN on the cross-owner case lists
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-03
|
||||
|
||||
## What was wrong
|
||||
|
||||
`Mappers.ToAdminSummaryDto` set `Owner = a.Owner` — the raw BSN. Two endpoints consume it,
|
||||
both cross-owner lists read by someone who is **not** the subject:
|
||||
|
||||
- `GET /admin/cases` (`cases:manage`)
|
||||
- `GET /werkvoorraad` (`aanvraag:beoordelen`)
|
||||
|
||||
`GET /beoordeling/{id}` — the _detail_ view of the same data — already masked. So the
|
||||
detail screen showed `******782` while the list one click earlier showed the whole BSN.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ---------------------- | ---------------------------------------------------------------- |
|
||||
| `Domain/People/Pii.cs` | **new** — `Pii.MaskTail`, moved out of `Program.cs` |
|
||||
| `Contracts/Mappers.cs` | `Owner = Pii.MaskTail(a.Owner, 3)` |
|
||||
| `Program.cs` | local `MaskTail` deleted; two call sites point at `Pii.MaskTail` |
|
||||
| `AdminCasesTests.cs` | asserts the masked value and that `DemoOwner` does not appear |
|
||||
| `WerkvoorraadTests.cs` | same assertion, replacing the `IsNullOrEmpty` one |
|
||||
|
||||
**Masked in the mapper, not at the endpoints.** The point of the ticket is that both
|
||||
lists _inherit_ it, so a third cross-owner list cannot be added that forgets to mask.
|
||||
|
||||
**`MaskTail` moved to `Domain/People/Pii.cs`** because it now has three callers across
|
||||
three folders (`Contracts`, `Program.cs`, and `Data` once **RB-04** lands), and a second
|
||||
hand-rolled copy is how one of them drifts into leaking. It is documented as idempotent,
|
||||
which is what lets `/beoordeling/{id}` keep its own call: `IZaakSource` has a second
|
||||
implementation (`OpenZaakZaakSource` → `ZgwZaakMapper`, which maps `Owner` from the zaak
|
||||
`identificatie`), so that endpoint's guarantee should not depend on which source answered.
|
||||
|
||||
## Blast radius on the frontend — none
|
||||
|
||||
Both consumers use the value for display only (`admin-cases.page.ts:101`,
|
||||
`beoordeling-view.ts:40`, `werkvoorraad-item-view.ts:28`); the `parse*` boundaries require
|
||||
a non-empty string, which a masked BSN still is. Nothing keys, filters or looks up by owner.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the
|
||||
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
|
||||
@@ -0,0 +1,37 @@
|
||||
# RB-04 — mask the BSN recorded as `AuditEntry.Actor`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-005 · `99-backlog.md` RB-04
|
||||
|
||||
## What was wrong
|
||||
|
||||
`DocumentStore` writes one audit row per upload and per user delete, with the acting
|
||||
citizen's raw BSN as `AuditEntry.Actor`, persisted to SQLite. The class's own doc comment
|
||||
says "The audit log holds metadata only (never file content **or other PII**)" — a BSN in
|
||||
every row is precisely other PII. Same failure shape as RB-02, in a second store.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------- | --------------------------------------------------------- |
|
||||
| `Data/DocumentStore.cs` `Add` | `Audit("upload", …, Pii.MaskTail(owner, 3))` |
|
||||
| `Data/DocumentStore.cs` `DeleteOwned` | `Audit("delete-user", …, Pii.MaskTail(owner, 3))` |
|
||||
| `Data/DocumentStore.cs` `Audit` | doc comment: actors arrive **already redacted** |
|
||||
| `UploadAccessTests.cs` | **new** `The_document_audit_trail_records_a_masked_actor` |
|
||||
|
||||
**Masked at the two call sites, not inside `Audit`** — unlike RB-03, where masking in the
|
||||
mapper was the point. `Audit`'s third actor is the literal `"admin"` (from `AdminDelete`),
|
||||
and `MaskTail("admin", 3)` is `"**min"`: masking centrally would mean guessing which
|
||||
actors are BSNs and which are role names. The contract is stated on `Audit` instead.
|
||||
|
||||
**`StoredDocument.Owner` is untouched**, per the ticket. It is the authorization key —
|
||||
`DeleteOwned`, `ForeignIds` and now the RB-01 content check all compare against it — so it
|
||||
has to stay whole. The BSN remains where it is load-bearing and leaves the trail where it
|
||||
was only decoration.
|
||||
|
||||
Nothing reads `DocumentStore.AuditLog` today (no endpoint exposes it), so this is a
|
||||
data-at-rest fix with no response-shape change.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet format --verify-no-changes` clean. `dotnet test`: **252 passed, 1 failed** — the
|
||||
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
|
||||
@@ -0,0 +1,50 @@
|
||||
# RB-05 — drop the BSN-bearing query and body snippet from the ZGW failure message
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-009 · `99-backlog.md` RB-05
|
||||
|
||||
## What was wrong
|
||||
|
||||
`ZgwHttpClient.SendWithRetryAsync` built its failure message as
|
||||
|
||||
```csharp
|
||||
$"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"
|
||||
```
|
||||
|
||||
with `snippet` being up to 500 characters of the **response body**. That message is not
|
||||
transient: `Program.cs`'s submit endpoint catches it and stores it as `Aanvraag.ZgwError`
|
||||
in SQLite, and logs it.
|
||||
|
||||
Two BSN paths into it:
|
||||
|
||||
- **the query string.** ZGW filters travel as query parameters, and the citizen-scoped zaken
|
||||
list filters on `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<BSN>`.
|
||||
- **the body snippet.** OpenZaak's error responses echo the offending request, so a rejected
|
||||
`POST /rollen` (whose body carries `BetrokkeneIdentificatie(aanvraag.Owner)`) comes back
|
||||
with the BSN in it.
|
||||
|
||||
The two `"returned null body"` throws in `GetAsync`/`PostAsync` interpolated the same url.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ----------------------- | ---------------------------------------------------------------------------- |
|
||||
| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` |
|
||||
| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` |
|
||||
|
||||
Status + path is enough to route a failure to the right endpoint. The diagnostic detail
|
||||
that was lost already has a deliberate home: `ZGW_DEBUG_HTTP=1` wires
|
||||
`ZgwDiagnosticHandler`, which logs the full url and request bytes — opt-in, dev-only, and
|
||||
not persisted.
|
||||
|
||||
## The test
|
||||
|
||||
Fails the `statustypen` GET (the only call in that fixture whose url carries a query
|
||||
string) after the zaak POST succeeds, then asserts on the persisted `ZgwError`:
|
||||
no `"stub failure"` (the body snippet), no `"?"` (the query string), but still the path and
|
||||
the `503`. **Confirmed it fails without the fix** — restoring the old interpolation turns it
|
||||
red on both counts.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet format --verify-no-changes` clean. `dotnet test`: **253 passed, 1 failed** — the
|
||||
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
|
||||
@@ -0,0 +1,69 @@
|
||||
# RB-06 — delete the dead `POST /registrations`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-010 · `99-backlog.md` RB-06
|
||||
|
||||
## What was wrong
|
||||
|
||||
`POST /registrations` took a `Documents` list and passed it straight to `Submit`, which
|
||||
calls `DocumentStore.Link(...)` on every digital `documentId` in it. Linking a document
|
||||
**blocks its owner from deleting it** (`DeleteOwned` → 409 `Linked`).
|
||||
|
||||
There was no `ForeignIds` ownership check on that path. The real submit endpoint,
|
||||
`POST /applications/{id}/submit`, has had one since it was written:
|
||||
|
||||
```csharp
|
||||
if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds)
|
||||
return Results.Problem(detail: $"Onbekend of niet-eigen document(en): …", statusCode: 400);
|
||||
```
|
||||
|
||||
So any authenticated citizen could post another citizen's document id and permanently
|
||||
block them from deleting their own diploma scan.
|
||||
|
||||
## Deleted rather than guarded
|
||||
|
||||
The ticket allowed either. Deleted, because the endpoint is dead: no frontend caller (the
|
||||
generated client's `registrations` method was unreferenced), and the whole registratie flow
|
||||
goes through `POST /applications/{id}/submit`.
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------- | ------------------------------------------------- |
|
||||
| `Program.cs` | endpoint deleted |
|
||||
| `Contracts/Dtos.cs` | `RegistratieRequest` deleted (no other reference) |
|
||||
| `Domain/Submissions/SubmissionRules.cs` | `RejectRegistratie` deleted — see below |
|
||||
| `backend/swagger.json`, `api-client.ts` | regenerated (`npm run gen:api`) |
|
||||
| `EndpointTests.cs`, `SubmissionRuleTests.cs` | retargeted, see below |
|
||||
|
||||
### Why `RejectRegistratie` went with it
|
||||
|
||||
It was reachable only from this endpoint, and the live path deliberately **contradicts**
|
||||
it. `RejectRegistratie("handmatig")` returned a 422 rejection; the modern submit does
|
||||
|
||||
```csharp
|
||||
"registratie" => (null, req.DiplomaHerkomst == "duo"),
|
||||
```
|
||||
|
||||
— a manual diploma is not rejected, it simply does not auto-approve and goes to a
|
||||
behandelaar. Its own message even said so ("doorgestuurd voor handmatige beoordeling")
|
||||
while being returned as a rejection. Leaving it behind would have left an obsolete rule
|
||||
with a passing spec, which is exactly how it gets reintroduced.
|
||||
|
||||
**This is the one judgement call in this ticket** — the backlog row says "delete the dead
|
||||
endpoint", not "delete the rule". Reverting just the `SubmissionRules`/`SubmissionRuleTests`
|
||||
hunks restores it without touching anything else.
|
||||
|
||||
### Test coverage that moved rather than vanished
|
||||
|
||||
- `Registration_with_manual_diploma_is_rejected_with_problem_details` was the only test
|
||||
asserting the `Submit` helper's `application/problem+json` rejection shape. That assertion
|
||||
moved into `Change_request_with_bad_phone_is_rejected_with_problem_details` —
|
||||
`/change-requests` is the other endpoint on the same helper.
|
||||
- `User_delete_blocked_with_409_once_linked_to_submission` covered `DocumentStore.Link`
|
||||
blocking a delete. Retargeted to `POST /applications/{id}/submit`, i.e. the path that is
|
||||
actually in use. `POST /registrations` was the only other caller of `Link`.
|
||||
- `Registration_with_duo_diploma_succeeds` was deleted outright — `Change_request_with_valid_phone_succeeds`
|
||||
is the same assertion on the same helper.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci`. `dotnet test`: **249 passed, 1 failed** — the pre-existing
|
||||
`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
|
||||
@@ -0,0 +1,63 @@
|
||||
# RB-07 — audit the allow path, not just the denial
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-007 (+ the outstanding half of CQ-004) · `99-backlog.md` RB-07
|
||||
|
||||
## What was wrong
|
||||
|
||||
All five authorization gates called `AuditAuthz(..., allowed: false, ...)` only on the deny
|
||||
branch; the allow branch called `action()` and returned. So `/beheer/audit` — the queryable
|
||||
trail the product ships as its audit surface — could answer "who was turned away" but never
|
||||
"who changed this".
|
||||
|
||||
Nothing recorded: `PUT /admin/flags/{key}`, `PUT /admin/org-template/{subOrgId}`,
|
||||
`POST /admin/org-template/{subOrgId}/rollback/{version}`, `DELETE /admin/cases/{id}`,
|
||||
`DELETE /admin/uploads/{documentId}`, `POST /brief/approve|reject|send`, and
|
||||
`POST /beoordeling/{id}/besluit`. The comment above `OrgAdmin` claimed the endpoints logged
|
||||
their own effect instead; publish and admin case delete do, the other six did not log at all.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `Program.cs` × 5 gates | `var ok = Authz.CanX(p); AuditAuthz(ctx, …, ok, p); if (ok) return action();` |
|
||||
| `Program.cs` `FlagsAdmin` | takes a per-call `resource` (see below) |
|
||||
| `Program.cs` `LogBrief` | takes `HttpContext`, writes the audit row alongside the log line |
|
||||
| `Program.cs` besluit | one `aanvraag:besluit` row recording **what** was decided |
|
||||
| `AuthzAuditTests.cs` | allow-path row; the flag key + value; a refused brief transition |
|
||||
| `BriefEndpointTests.cs` | the allow side of `brief:submit` |
|
||||
| `BeoordelingTests.cs` | the `aanvraag:besluit` row |
|
||||
|
||||
**The row is written by the gate, not the endpoint.** That is the point: a new admin
|
||||
endpoint cannot be added that forgets to audit itself. Same reasoning for the brief — every
|
||||
transition already funnelled through `LogBrief` for its log line, so the audit call went
|
||||
there too, which covers `submit`/`approve`/`reject`/`send` in one place and any fifth
|
||||
transition automatically. The decision recorded is the transition's own outcome, so a 403 or
|
||||
a 409 is as visible as a success.
|
||||
|
||||
**`FlagsAdmin` gained a `resource` parameter** — the one deviation from BIO-007's minimal
|
||||
remediation, and the reason is in the finding itself: the toggle endpoint writes no log line
|
||||
of its own, so a constant `"feature-flags"` row would record that a flag changed without
|
||||
recording _which_. It now writes `feature-flags/<key>=<value>`. One call site.
|
||||
`OrgAdmin`/`CasesAdmin` keep their coarse refs because those endpoints do log the specific
|
||||
object; **that asymmetry is deliberate, not an oversight.**
|
||||
|
||||
**The besluit gets a second row.** The `Beoordelen` gate records that a behandelaar was
|
||||
_allowed to act_; `aanvraag:besluit` records _what they decided_
|
||||
(`aanvraag/<id>/Goedkeuren`). Only the first would leave "who rejected this aanvraag"
|
||||
unanswerable, which is the question the trail exists for.
|
||||
|
||||
## Consequences worth knowing
|
||||
|
||||
- **Row volume goes up.** `StamdataAdmin` gates read endpoints, so every admin page load now
|
||||
writes rows. That is what "audit the allow path" means and BIO-007 asks for it explicitly;
|
||||
if `AuthzAuditStore` ever needs retention or sampling, this is the change that made it
|
||||
necessary.
|
||||
- **This unblocks ADR-C-009.** Clause (4) of agent 06's four-part test is "writes are
|
||||
admin-capability-gated **and** audited". Both surfaces now are, so the amendment can be
|
||||
signed without ratifying a control the code does not implement.
|
||||
- **CQ-004's outstanding half is closed.** `PUT /admin/flags/{key}` writes an audit row.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet test`: **252 passed, 1 failed** — the pre-existing
|
||||
`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.
|
||||
@@ -0,0 +1,75 @@
|
||||
# RB-08 — route `DELETE /admin/uploads/{documentId}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-08
|
||||
|
||||
## What was wrong
|
||||
|
||||
`Program.cs:790` (pre-change) had `static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";`
|
||||
and `DELETE /admin/uploads/{documentId}` (`:274`) was gated by `IsAdmin` alone — outside
|
||||
`Authz`, outside every wrapper the four sibling admin surfaces use, and writing no
|
||||
`AuthzAuditStore` row at all. `DocumentStore.AdminDelete` bypasses ownership and deletes the
|
||||
row and its bytes; the only record left behind was a `DocumentStore.Audit("delete-admin", …)`
|
||||
metadata row, which never surfaces on `/beheer/audit`.
|
||||
|
||||
`grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e` (re-verified before deleting the
|
||||
gate, as the ticket asked) confirmed the finding: the only sender was
|
||||
`backend/tests/BigRegister.Tests/EndpointTests.cs:231`. No frontend or e2e path uses this
|
||||
header — it was an orphaned gate, not a live seam.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Program.cs` `DELETE /admin/uploads/{id}` | now `CasesAdmin(ctx, () => DocumentStore.AdminDelete(...) ? NoContent : NotFound)` — same wrapper the other four admin-cases endpoints use; response doc changed `Produces(403)` → `ProducesProblem(403)` to match `CasesAdmin`'s `Results.Problem` |
|
||||
| `Program.cs` `IsAdmin` | **deleted** |
|
||||
| `Program.cs` two stale comments | the endpoint's own comment rewritten to describe the new gate; the brief-section banner comment ("mirrors the X-Admin seam") no longer references a gate that doesn't exist |
|
||||
| `tests/BigRegister.Tests/EndpointTests.cs` | `Admin_delete_requires_admin_role` sends `X-Role: admin` instead of `X-Admin: true` |
|
||||
| `tests/BigRegister.Tests/AuthzAuditTests.cs` | **new** `An_admin_upload_delete_is_recorded` — asserts the `cases:manage`/`allow`/`Admin` row count increases by exactly one after the delete |
|
||||
|
||||
No new `Authz` capability was added — `CasesAdmin`/`Authz.CanManageCases` is the wrapper the
|
||||
ticket named as the expected outcome, and nothing about this endpoint needed a narrower
|
||||
capability than "manage cases" already provides.
|
||||
|
||||
**RB-07 already moved `AuditAuthz` onto the allow path for every `*Admin` wrapper**, so
|
||||
routing through `CasesAdmin` gives BIO-003's missing audit row for free. No second
|
||||
`AuditAuthz` call was added — confirmed by reading `CasesAdmin`'s body (`Program.cs`): it
|
||||
calls `AuditAuthz(ctx, "cases:manage", "cases", ok, principal)` unconditionally before
|
||||
branching on `ok`.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Test asserts a count delta, not mere presence.** `CasesAdmin` audits under a fixed
|
||||
`"cases"` resource literal shared by every `cases:manage` call (`GET /admin/cases`,
|
||||
`DELETE /admin/cases/{id}`, `GET /admin/audit` itself, and now this endpoint), so
|
||||
`Assert.Contains(rows, cases:manage/allow/Admin)` would already be satisfied by this test
|
||||
class's _other_ tests even without the fix. The new test counts matching rows before and
|
||||
after the delete and asserts the count grew by exactly one. It reads `AuthzAuditStore.List()`
|
||||
in-process rather than through `GET /admin/audit` — that endpoint is itself a `CasesAdmin`
|
||||
read, so calling it to take the "before" measurement would have written its own
|
||||
`cases:manage`/`allow` row and silently inflated the count by one every time it was called
|
||||
(caught this by running the test once against the fix with an HTTP-based baseline: it
|
||||
failed with an off-by-one before switching to the in-process read).
|
||||
- **Two comments referencing the old gate were also updated**, not just the endpoint mapping
|
||||
itself — one directly above the endpoint, one in the brief-section banner comment
|
||||
("dev-only stand-in via X-Role, mirrors the X-Admin seam") that would otherwise describe a
|
||||
gate that no longer exists.
|
||||
|
||||
## Known residual
|
||||
|
||||
None new. RB-01's implementation note already records that `GET /uploads/{id}/content` is
|
||||
reached with no identity header via plain browser navigation — that residual is RB-09's
|
||||
territory, not this ticket's, and is untouched here.
|
||||
|
||||
## Verification
|
||||
|
||||
- Reverted `Program.cs`'s endpoint change only (`git stash push` on that one file, tests
|
||||
left in place) and re-ran `dotnet test --filter "AuthzAuditTests|EndpointTests"`: **both**
|
||||
`EndpointTests.Admin_delete_requires_admin_role` and
|
||||
`AuthzAuditTests.An_admin_upload_delete_is_recorded` failed red (403 Forbidden — the old
|
||||
gate rejects `X-Role: admin`, and the count-delta test throws on `EnsureSuccessStatusCode`
|
||||
before it can assert). Restored the fix (`git stash pop`) and re-ran: both green.
|
||||
- `dotnet build`: clean.
|
||||
- `dotnet test` (full suite): **253 passed, 1 failed** — the pre-existing
|
||||
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||
which needs a live OpenZaak container and fails identically on a clean tree; not touched by
|
||||
this ticket.
|
||||
@@ -0,0 +1,168 @@
|
||||
# RB-09 — make "no identity" representable; stub Development-only; fail fast in Production
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-002 (folds in BIO-001(a)/(b)) · `99-backlog.md` RB-09
|
||||
|
||||
## What was wrong
|
||||
|
||||
`IIdentityProvider.Resolve` returned a non-nullable `CallerIdentity`
|
||||
(`IIdentityProvider.cs:12`, pre-change), so the interface could not express "no identity" — any
|
||||
implementation, stub or real, was forced to invent one for an unauthenticated request.
|
||||
`StubIdentityProvider` was registered unconditionally, for every environment.
|
||||
|
||||
Consequence, traced end to end: `apps/behandelportal/src/app/app.config.ts:57-63` puts
|
||||
`medewerkerInterceptor` inside the `isDevMode()` provider array, so a production
|
||||
behandelportal build sends **no** `X-Medewerker`/`X-Rollen` header. With neither header,
|
||||
`StubIdentityProvider.Resolve` fell through to
|
||||
`new ZorgverlenerCaller(DocumentStore.DemoOwner, ..., PrincipalRole.Drafter)` — the single
|
||||
seeded citizen, role `drafter`. That:
|
||||
|
||||
- **Fails closed, correctly, on backoffice capabilities** — `Authz.CanBeoordelen` is
|
||||
`caller is MedewerkerCaller`, so a zorgverlener caller is always `false` regardless of role.
|
||||
This part of the design was right and is untouched.
|
||||
- **Fails open on every citizen-scoped endpoint** — `GET/PUT/DELETE /applications*`,
|
||||
`POST /applications/{id}/submit`, `DELETE /uploads/{id}`, `GET|PUT /brief`,
|
||||
`POST /brief/submit|send|reset` all resolve `ctx.Zorgverlener().Bsn` to the seeded citizen's
|
||||
BSN. An employee with no employee identity was granted a citizen's own read/write rights.
|
||||
- **Holds `CanRevealBigNummer`** — that capability is `Role == PrincipalRole.Drafter`, and
|
||||
`drafter` is exactly the no-header default.
|
||||
|
||||
`CallerIdentityHttpContextExtensions.Caller()` (`CallerIdentity.cs:44-50`) already throws
|
||||
loudly when the identity middleware didn't run — the codebase reached for fail-loud one layer
|
||||
up and then defaulted one layer down, which is the shape of the bug.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Domain/Authorization/IIdentityProvider.cs` | `Resolve` returns `CallerIdentity?`; doc comment states what null means and where it's turned into a response |
|
||||
| `Domain/Authorization/StubIdentityProvider.cs` | **implementation signature unchanged** (`CallerIdentity`, non-nullable) — a valid, narrower override of the nullable interface method (return-type covariance; the compiler accepts it with zero warnings); doc comment records it is Development-only and never itself returns null |
|
||||
| `Program.cs` — registration | `StubIdentityProvider` registered only under `builder.Environment.IsDevelopment()`; an `else if (builder.Environment.IsProduction())` branch throws `InvalidOperationException` immediately, before `builder.Build()` — the earliest possible failure point |
|
||||
| `Program.cs` — identity middleware | resolves the identity once; if `null`, sets `401` and returns without calling `next()`, instead of passing a null (or invented) caller downstream |
|
||||
| `tests/BigRegister.Tests/StubIdentityProviderTests.cs` | **new** `Never_returns_null_even_with_no_headers_at_all`; **new** `ProductionIdentityProviderTests.Production_environment_with_no_real_identity_provider_fails_at_startup` |
|
||||
|
||||
No consumer beyond the middleware itself calls `IIdentityProvider.Resolve` (`grep -rn
|
||||
"IIdentityProvider\|identityProvider\."` over `backend/src` confirms exactly one call site) —
|
||||
`Authz.ResolvePrincipal`, `ZgwTokenProvider.Mint`, and every endpoint read `ctx.Caller()` /
|
||||
`ctx.Zorgverlener()`, which already throw on a missing identity and are untouched. The 401
|
||||
now happens _before_ those are ever reached for a request the middleware rejects.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **`StubIdentityProvider`'s own method signature stays `CallerIdentity`, not
|
||||
`CallerIdentity?`.** The interface needed the nullable shape to make "no identity"
|
||||
representable in general; the stub itself never has that case (it is a developer
|
||||
convenience that always invents a caller by design) and returning a narrower,
|
||||
non-nullable type from an override of a nullable-returning interface method is valid C#
|
||||
nullable-reference-type covariance — verified with a clean `dotnet build` (0 warnings).
|
||||
This kept every existing `StubIdentityProviderTests` call site (`private static
|
||||
CallerIdentity Resolve(...)`) compiling with zero changes, rather than sprinkling
|
||||
null-forgiving operators through a file whose entire point is "the stub always resolves."
|
||||
- **The Production-only check is `IsProduction()`, not `!IsDevelopment()`.** The ticket and
|
||||
BIO-002 both say "Production must fail at startup" specifically. A third environment (e.g.
|
||||
a hypothetical `Staging`) falls through neither branch, registers no `IIdentityProvider` at
|
||||
all, and would still fail — one line later, when `app.Services.GetRequiredService<
|
||||
IIdentityProvider>()` throws .NET's own "no service for type" exception — just with a less
|
||||
specific message than the one this ticket adds for Production. That fallback is a safety
|
||||
net, not the intended fail-fast message; if a real non-Production, non-Development
|
||||
environment is added later, giving it the same explicit message is a one-line follow-up,
|
||||
not a design gap today.
|
||||
- **The throw sits before `builder.Build()`**, not after (where `GetRequiredService` already
|
||||
runs today). Both satisfy "throw during service registration / app build so a misconfigured
|
||||
deploy never serves a request" — throwing earlier was free and gives a message naming the
|
||||
actual cause (no real identity provider) rather than a generic DI resolution failure.
|
||||
- **The 401 short-circuits before `ctx.SetCaller`, not after.** `next(ctx)` is never called,
|
||||
so no downstream middleware or endpoint runs for a request with no identity — a citizen or
|
||||
behandelaar endpoint reached this way now gets a clean 401 instead of ever executing.
|
||||
- **`TestWebApplicationFactory` needed no change.** `WebApplicationFactory<T>` defaults its
|
||||
test host to the `Development` environment when nothing overrides it (confirmed
|
||||
empirically: `dotnet test` — every non-Production test, all 253 of them pre-existing plus
|
||||
2 new, passed unchanged), so the entire existing test suite continues to exercise the
|
||||
Development path exactly as before. The Production test builds its own
|
||||
`WebApplicationFactory<Program>().WithWebHostBuilder(b => b.UseEnvironment("Production"))`
|
||||
rather than touching the shared fixture.
|
||||
|
||||
## Known residual — explicitly out of scope, confirmed and written up per the ticket
|
||||
|
||||
**RB-01's residual is this ticket's territory but is explicitly out of scope for this
|
||||
ticket**, per the task: `GET /uploads/{documentId}/content` is reached by a plain browser
|
||||
navigation (`<a href>` in `beoordeling-documenten.component.ts`, `previewUrl` in
|
||||
`libs/shared/src/upload/upload.adapter.ts`) that sends no identity header and never passes
|
||||
through an Angular interceptor.
|
||||
|
||||
- **In Development, this is unchanged** — verified by reading the endpoint
|
||||
(`Program.cs:253-266`) and confirming `StubIdentityProvider` is still registered and still
|
||||
resolves the same non-null seeded-citizen default it always did when no headers are
|
||||
present. `dotnet test`'s full pass (255/255, excluding the pre-existing OpenZaak failure)
|
||||
including `UploadAccessTests` — which exercises exactly this endpoint — confirms it
|
||||
byte-for-byte.
|
||||
- **The Production consequence, for the next ticket:** today Production cannot start at
|
||||
all (this ticket's fail-fast), so the question is moot until a real `IIdentityProvider`
|
||||
exists. Once one does, this endpoint's plain-navigation callers carry no credential a real
|
||||
provider could resolve — the identity middleware would treat that as "no identity" and
|
||||
return 401 before the endpoint ever runs, breaking both preview links outright. Making the
|
||||
stub Development-only does not itself break anything (nothing in Production exists yet to
|
||||
break), but it does mean **whoever builds the real provider must also solve this endpoint's
|
||||
credential-carrying problem in the same change**, or ship it broken. This is not a
|
||||
signed-URL or cookie scheme, and no such scheme was designed here, per the ticket's explicit
|
||||
instruction — it is recorded so the next ticket (RB-13, or whichever lands the real
|
||||
provider) picks it up deliberately rather than discovering it in a production incident.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Reverted the registration change only** (kept `AddSingleton<IIdentityProvider,
|
||||
StubIdentityProvider>()` unconditional, left the new tests in place) and ran
|
||||
`ProductionIdentityProviderTests`: it failed red — `Assert.ThrowsAny() Failure: No exception
|
||||
was thrown` (the stub gets registered in every environment, so the host builds fine and
|
||||
`factory.CreateClient()` never throws). Restored the fix and re-ran: green.
|
||||
- `dotnet build`: clean, **0 warnings** (confirms the nullable-covariance judgement call
|
||||
above compiles cleanly).
|
||||
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
|
||||
- `dotnet test` (full suite): **255 passed, 1 failed** — the pre-existing
|
||||
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`
|
||||
(needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter,
|
||||
`dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**.
|
||||
|
||||
## A regression found by actually running `npm run ci`, and its fix
|
||||
|
||||
`npm run gen:api` (`dotnet swagger tofile`) loads `BigRegister.Api.dll` through .NET's
|
||||
design-time `HostFactoryResolver` — the same mechanism `dotnet ef` migrations use — which
|
||||
executes this file's top-level statements, including the identity middleware's
|
||||
pre-existing, unconditional `app.Services.GetRequiredService<IIdentityProvider>()`, without
|
||||
ever setting `ASPNETCORE_ENVIRONMENT`. Unset defaults to `Production`. Before this ticket
|
||||
that was harmless (`StubIdentityProvider` was registered unconditionally); after it, nothing
|
||||
is registered for that default environment, so the tool crashed
|
||||
(`dotnet swagger tofile` exited **134**, confirmed by running it directly, both before and
|
||||
after the fix below).
|
||||
|
||||
This is real breakage of a real workflow, not a false alarm from `ci-local.sh` — verified by
|
||||
reading `.github/workflows/ci.yml`'s `api-client-drift` job: `npm run gen:api` and
|
||||
`git diff --exit-code ...` are **two separate `- run:` steps** there, so the crash would fail
|
||||
actual CI. `ci-local.sh` chains them as `npm run gen:api && git diff --exit-code ...` on one
|
||||
line, and its first full run of `npm run ci` after this ticket's change **printed the crash
|
||||
but still reported `✔ local CI passed`** — a bash `set -e` gotcha, not a false negative
|
||||
specific to this fix: a failing command that is not the last element of an `&&`/`||` list is
|
||||
exempt from triggering `errexit`, so `cmd1 && cmd2` silently "passes" whenever `cmd1` alone
|
||||
fails. That is a pre-existing fragility in `ci-local.sh`'s three `step "X"; gen && git diff`
|
||||
lines (snippets/behaviour-spec/api-client drift), unrelated to RB-09 and out of this
|
||||
ticket's scope — flagged here rather than fixed, since fixing a local convenience script's
|
||||
error handling is a different, standalone change. Running the failing command directly
|
||||
(rather than trusting the local script) is what caught this.
|
||||
|
||||
**Fix:** `package.json`'s `gen:api` script now sets `ASPNETCORE_ENVIRONMENT=Development`
|
||||
on the `dotnet swagger tofile` invocation specifically — the same value
|
||||
`backend/src/BigRegister.Api/Properties/launchSettings.json` already sets for `dotnet run`,
|
||||
and the same value `docker-compose.yml` already sets for local Docker (confirmed by reading
|
||||
both: `docker-compose.prod.yml` sets `Production` explicitly, `docker-compose.yml` sets
|
||||
`Development` explicitly — the bare CLI tool invocation was the **one** place with no
|
||||
environment variable set at all). Verified: `npm run gen:api` now exits 0 and produces the
|
||||
regenerated `backend/swagger.json` / `libs/shared/src/infrastructure/api-client.ts` cleanly.
|
||||
|
||||
This also surfaced a second, unrelated gap: RB-08 changed
|
||||
`DELETE /admin/uploads/{documentId}`'s 403 mapping from `.Produces` to `.ProducesProblem`
|
||||
but the generated OpenAPI doc/client were never regenerated for it (RB-08's own `npm run ci`
|
||||
was run before this fix existed, so `gen:api` was already broken by the time RB-09 landed
|
||||
and the drift went unnoticed). Regenerated and committed separately — see the two follow-up
|
||||
commits **fix(api): regenerate client for RB-08's 403 response shape** and
|
||||
**fix(tooling): keep gen:api working under RB-09's Development-only stub**. No frontend
|
||||
consumes the admin-uploads-delete endpoint (confirmed by grep), so the client regeneration
|
||||
has no consumer impact.
|
||||
@@ -0,0 +1,87 @@
|
||||
# RB-10 — extract `parseStoredSession` (both apps) and spec `redactProfile`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-001 (ssp/auth and bhp/auth) · `07-bio2-compliance.md` BIO-017 · `99-backlog.md` RB-10
|
||||
|
||||
## What was wrong
|
||||
|
||||
`SessionStore.restore()` — identical in `apps/ssp/src/app/auth/application/session.store.ts`
|
||||
and `apps/behandelportal/src/app/auth/application/session.store.ts` — called
|
||||
`localStorage.getItem(STORAGE_KEY)` itself and did the parse + shape validation in the same
|
||||
module-private function. It was invoked from a field initializer
|
||||
(`private _session = signal<Session | null>(restore())`), so the storage read happened the
|
||||
instant the singleton was constructed; a spec could not feed it a raw string without
|
||||
stubbing the `localStorage` global before the injector built the store.
|
||||
|
||||
The logic behind that guard is a trust boundary, not incidental validation — the comment
|
||||
above it names two guarantees: **G1** (never persist the BSN) and **G2** (validate the shape
|
||||
before trusting it). CLAUDE.md §5 mandates a spec for boundary `parse*` adapters, and none
|
||||
existed. Baseline evidence: `02-testability.md` §3a cites `ssp/auth` and `bhp/auth` at
|
||||
42.9% line / 46.2% branch — jointly the worst line coverage in the frontend table — with
|
||||
this file's own lcov at LH 2/LF 20 (10.0% line), BRH 3/BRF 13 (23.1% branch).
|
||||
|
||||
BIO-017 read the same code and confirmed G1 holds on every path by inspection (`restore()`
|
||||
returns `{ bsn: '', naam }`, the persistence `effect()` writes only `naam`, `login()`/
|
||||
`logout()` never touch storage with a BSN) — but "correct, unverified by a test" is exactly
|
||||
the gap TE-001 already targeted, so BIO-017 folds into it and adds one required assertion:
|
||||
a stored `{"bsn":"…","naam":"…"}` must yield a session whose `bsn` is `''`.
|
||||
|
||||
Separately, `apps/ssp/src/app/shell/debug-state/mask.ts` — confirmed at the path the finding
|
||||
cites — has `redactProfile`, a pure, exported, directly callable PII-redaction function with
|
||||
no spec. It redacts name, birthdate and address and masks the BIG-nummer; BIO-017 verified it
|
||||
correct by reading, same "no regression net" gap.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/ssp/src/app/auth/domain/session.ts` | added `export function parseStoredSession(raw: string \| null): Session \| null` — the exact parse+validate body `restore()` used to hold |
|
||||
| `apps/ssp/src/app/auth/application/session.store.ts` | `restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))` |
|
||||
| `apps/ssp/src/app/auth/domain/session.spec.ts` | 4 new cases: absent, non-JSON, wrong shape, and the G1 assertion |
|
||||
| `apps/behandelportal/src/app/auth/domain/session.ts` | identical extraction, second app |
|
||||
| `apps/behandelportal/src/app/auth/application/session.store.ts` | identical collapse, second app |
|
||||
| `apps/behandelportal/src/app/auth/domain/session.spec.ts` | identical 4 cases, second app |
|
||||
| `apps/ssp/src/app/shell/debug-state/mask.spec.ts` | new file — spec for `redactProfile`: masks the BIG-nummer, redacts name/geboortedatum/adres on both `registration` and `person`, leaves `beroep`/`registratiedatum`/`status` untouched |
|
||||
|
||||
The extracted function's body is a byte-for-byte move — same `try`/`catch`, same
|
||||
`JSON.parse` cast, same `typeof parsed?.naam === 'string'` guard, same `{ bsn: '', naam }`
|
||||
construction. Only its location and the doc comment (rewritten to explain the _why_ of G1/G2
|
||||
for a function now read on its own, rather than inline next to the `effect()` it used to sit
|
||||
beside) changed.
|
||||
|
||||
## The seam lands twice, on purpose
|
||||
|
||||
`auth` is deliberately unshared per ADR-0002 / CLAUDE.md §1: Zorgverlener and Medewerker are
|
||||
different `Principal` variants with different login flows, and the two `session.ts` files are
|
||||
expected to diverge. TE-001 says this outright, and BL-002 flags any extract-to-`libs/shared`
|
||||
here as contradicting an accepted ADR. `parseStoredSession` was therefore written twice, once
|
||||
per app's own `domain/session.ts` — not factored into a shared helper, and not resisted only
|
||||
in this note; the two functions are word-for-word identical today and that is expected to
|
||||
change the moment `RB-13` (`Session → Principal`) lands.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **`redactProfile`'s spec asserts on the concrete shape**, not just "not equal to the input" —
|
||||
it pins `bigNummer` to `'********901'`, checks `REDACTED` on each PII field individually, and
|
||||
separately asserts the non-PII fields (`beroep`, `registratiedatum`, `status`) survive
|
||||
unchanged. A looser "no PII substring appears" assertion would have been weaker at catching
|
||||
the regression this ticket exists to prevent (e.g. a future field added to `redactProfile`'s
|
||||
output that is left unmasked by accident).
|
||||
- **The G1 spec case uses `toEqual`, not `toBe`**, since the parser constructs a new object;
|
||||
this matches the existing `isAuthenticated` spec's style in the same file.
|
||||
- No production code beyond the `restore()` one-liner in each `session.store.ts` changed —
|
||||
`login()`, `logout()`, and the persistence `effect()` were already correct and are
|
||||
unaffected.
|
||||
|
||||
## Verification
|
||||
|
||||
Confirmed both new specs are red without the fix:
|
||||
|
||||
- Temporarily changed `parseStoredSession` to keep a stored `bsn` (`bsn: parsed.bsn ?? ''`
|
||||
instead of `bsn: ''`) — the new G1 test failed with
|
||||
`expected { bsn: '19012345601', naam: 'Test' } to deeply equal { bsn: '', naam: 'Test' }`,
|
||||
all 243 other tests stayed green. Reverted; `git diff` on the file is empty afterward.
|
||||
- Temporarily changed `redactProfile` to pass `naam` through unmasked — the new "redacts the
|
||||
name" test failed with `expected 'J. Jansen' to be '‹redacted›'`. Reverted; `git diff` on the
|
||||
file is empty afterward.
|
||||
|
||||
`npm run ci`: **green** (see PR/commit for the run this doc ships with).
|
||||
@@ -0,0 +1,179 @@
|
||||
# RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-012,
|
||||
TE-002, BIO-006(a)+(b) · `99-backlog.md` RB-11
|
||||
|
||||
## What was wrong
|
||||
|
||||
**BIO-012 — the dev hatches were not actually dev-only.** The `roleInterceptor` /
|
||||
`subjectInterceptor` chain is correctly registered only under `isDevMode()`
|
||||
(`app.config.ts`), but three adapters bypass `HttpClient` entirely and set headers
|
||||
themselves with no guard at all:
|
||||
|
||||
- `reveal-bignummer.adapter.ts:26` — `'X-Role': currentRole(), 'X-Step-Up': 'true'`
|
||||
- `letter-preview.adapter.ts:46` — `'X-Role': currentRole()`, plus `'X-Subject'` when present
|
||||
- `org-template.adapter.ts:79` — `'X-Role': currentRole()`
|
||||
|
||||
The readers underneath were ungated too: `role.ts:24` and `subject.ts:24` both read the
|
||||
`?role=`/`?subject=` query param and **wrote it into `sessionStorage`** on any
|
||||
navigation, in any build. For `?subject=` that value is a BSN — `subject.ts`'s own doc
|
||||
comment argued at length that the BSN must never leave `SessionStore` and then routed it
|
||||
through `sessionStorage` anyway. `docs/reference/roles-and-access.md:23` claimed "Both
|
||||
are wired only under `isDevMode()` — they do not exist in a production build", which was
|
||||
false for exactly these three call sites.
|
||||
|
||||
**TE-002 — the reveal's trust boundary was not callable.** The response-shape validation
|
||||
in `reveal-bignummer.adapter.ts` (the code's own comment called it a "Trust boundary")
|
||||
lived inline inside `async reveal()`, after `await fetch(...)` on the global `fetch`. A
|
||||
spec could not reach it without stubbing `globalThis.fetch`. The same shape recurred,
|
||||
un-exported, in `letter-preview.adapter.ts`'s `errorMessage` and — contrary to the
|
||||
finding's text, see "Judgement calls" below — as an inline `try/catch` (not yet a
|
||||
function) in `org-template.adapter.ts`'s `proefbrief()`.
|
||||
|
||||
**BIO-006(a) — the step-up stub was a constant.** `reveal-bignummer.adapter.ts` sent
|
||||
`'X-Step-Up': 'true'` unconditionally, as a literal, so the backend's
|
||||
`canReveal && X-Step-Up == "true"` precondition was satisfied by every call that reached
|
||||
the endpoint and constrained nothing.
|
||||
|
||||
**BIO-006(b) — the default role holds the PII-reveal capability, undocumented.**
|
||||
`StubIdentityProvider`'s `_ =>` role-switch arm resolves any request with no (or an
|
||||
unrecognised) `X-Role` header to `drafter` — the one role `Authz.CanRevealBigNummer`
|
||||
grants. `roles-and-access.md` documented `drafter` as "the only role that may reveal a
|
||||
BSN" without noting that it is also the fallback identity, so the least-privilege
|
||||
consequence was invisible.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `libs/shared/src/infrastructure/role.ts` | `currentRole()` returns `'drafter'` immediately when `!isDevMode()` — no query-param read, no `sessionStorage` write |
|
||||
| `libs/shared/src/infrastructure/subject.ts` | `currentSubject()` returns `undefined` immediately when `!isDevMode()` — same treatment for the BSN |
|
||||
| `reveal-bignummer.adapter.ts` | `reveal(stepUp: boolean)`; `X-Role` sent only under `isDevMode()`, `X-Step-Up` sent only when `stepUp`; inline shape check moved to exported `parseRevealed(body)`; `REVEAL_FAILED` exported |
|
||||
| `letter-preview.adapter.ts` | headers wrapped in `isDevMode() ? {...} : {}`; `errorMessage` exported |
|
||||
| `org-template.adapter.ts` | `X-Role` sent only under `isDevMode()`; the inline `proefbrief()` error `try/catch` extracted to an exported `proefbriefErrorMessage`; `PROEFBRIEF_FAILED` exported |
|
||||
| `apps/ssp/src/app/brief/application/brief.store.ts` | `revealBigNummer()` calls `this.revealAdapter.reveal(true)` — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter |
|
||||
| `docs/reference/roles-and-access.md` | records which two places the `isDevMode()` gate now lives (interceptor registration **and** the reader functions) and why; adds the BIO-006(b) note that `drafter` is also the backend's fallback identity |
|
||||
| 6 new/extended `*.spec.ts` | see "Verification" below |
|
||||
|
||||
**The fix is two layers, not one, because the finding named both.** Gating only
|
||||
`currentRole()`/`currentSubject()` would already stop the query param and the
|
||||
`sessionStorage` write from working outside `isDevMode()` — the three adapters would
|
||||
then send the safe default (`'X-Role': 'drafter'`, no `X-Subject`) even in production.
|
||||
The adapters are _also_ wrapped in `isDevMode()` so a production request from any of the
|
||||
three hand-written `fetch` calls carries no `X-Role`/`X-Subject` header at all, exactly
|
||||
matching what a `HttpClient` request already does once `roleInterceptor` is not
|
||||
registered — the two paths now agree on production behaviour instead of merely agreeing
|
||||
on the resulting header value.
|
||||
|
||||
**`X-Step-Up` is deliberately not folded into the same `isDevMode()` gate.** It is not a
|
||||
`?role=`/`?subject=`-style dev override; it is the stub for a control BIO-006 says must
|
||||
survive into production (in stubbed form) until a real step-up exists. Nesting it inside
|
||||
`isDevMode()` would make the reveal endpoint permanently unreachable in a production
|
||||
build. Instead it is gated on the `stepUp` parameter alone, which is `true` only when
|
||||
`BriefStore.revealBigNummer()` — reachable only via `behandel-scherm.component.ts`'s
|
||||
`onReveal()` confirm — calls it.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **`org-template.adapter.ts`'s proefbrief error mapping was not "already a separate
|
||||
function".** The finding's remediation text says "the proefbrief error mapping in
|
||||
`org-template.adapter.ts` — both are already separate functions and only need
|
||||
`export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading
|
||||
the file: the other two adapters do have a standalone `errorMessage`/similar function,
|
||||
but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the
|
||||
`try { … } catch { … }` block, not a named function. This is a minor factual
|
||||
imprecision in the finding, not a blocker — I extracted the same inline logic into a
|
||||
named `proefbriefErrorMessage`, exported it, and added the same spec shape as its two
|
||||
siblings. The result matches the finding's intent (a callable, spec'd trust boundary)
|
||||
even though the starting shape needed one extra step the finding didn't mention.
|
||||
- **The BIO-006(a) literal moved to `BriefStore.revealBigNummer()`, not to the UI.**
|
||||
`behandel-scherm.component.ts`'s `onReveal()` already gates the _only_ path that can
|
||||
reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own
|
||||
docstring says the step-up gesture "is the UI's concern". Threading a boolean through
|
||||
the component's `output<void>()` and the page's template binding would touch three more
|
||||
files for no behavioural change, since the call graph already guarantees confirmation
|
||||
happened first. I moved the literal one layer up instead — out of the adapter (the
|
||||
transport) and into the store (the command that is exclusively reachable via the
|
||||
confirmed gesture) — which is the smallest change consistent with "not from the
|
||||
adapter's literal" and with this repo's ui → application → infrastructure layering (ui
|
||||
cannot call infrastructure directly to pass the flag down any other way).
|
||||
- **Redundant-looking `isDevMode()` guards, kept anyway.** After gating
|
||||
`currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around
|
||||
the headers object is not strictly load-bearing for `X-Subject` (already `undefined`
|
||||
outside dev) and only changes the _value sent_ for `X-Role` (a hardcoded `'drafter'`
|
||||
vs. no header) rather than any security outcome (the backend treats both identically).
|
||||
I kept the adapter-level gate anyway so the security posture is visible by inspection
|
||||
at the fetch call site — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern
|
||||
— rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it.
|
||||
- **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via
|
||||
`inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient`
|
||||
purely to reach a method that doesn't use either. I judged that disproportionate to the
|
||||
marginal coverage gained, since the identical `isDevMode()` pattern is already
|
||||
exercised end-to-end (via `fetch` stubbing) on the other two adapters
|
||||
(`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the
|
||||
underlying reader-level fix is covered directly in `role.spec.ts`. Noted here as a
|
||||
residual rather than silently skipped.
|
||||
- **`setRole()` (the dev-switcher writer) was left ungated.** BIO-012's evidence names
|
||||
the two _readers_ (`currentRole`/`currentSubject`); `setRole()` is only ever invoked
|
||||
from `debug-state.component.ts`, which is itself rendered only under
|
||||
`shell.component.ts`'s `@if (isDev && debugPanel)`. Gating it too would be harmless but
|
||||
wasn't asked for and has no reachable production call site to protect — left alone to
|
||||
keep the diff to what the finding actually named.
|
||||
|
||||
## Consequences worth knowing
|
||||
|
||||
- **Doc correction, same diff.** `roles-and-access.md`'s "Both are wired only under
|
||||
`isDevMode()`" line is accurate as of this commit — the gate now lives in the
|
||||
interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves.
|
||||
Before this commit, the sentence was false for the three hand-written `fetch` paths; the
|
||||
doc has been extended, not merely left as-is, to say _where_ the gate lives so a future
|
||||
reader doesn't have to rediscover why the interceptor site alone wasn't sufficient.
|
||||
- **CLAUDE.md needed no correction.** Its "Scenario toggle (dev-only, not wired in prod
|
||||
builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three
|
||||
hand-written `fetch` adapters specifically (the accompanying sentence already says they
|
||||
"bypass the interceptor", which stays true — they still don't go through
|
||||
`HttpClient`). Those claims were already compatible with a fix landing here; they made
|
||||
no false statement that needed walking back.
|
||||
- **`?subject=` is still undocumented by name in `roles-and-access.md`.** The BIO-012
|
||||
evidence and this ticket's brief both discuss it, but the doc file never named
|
||||
`?subject=`/`X-Subject` before this change and still doesn't get a dedicated section —
|
||||
only the new paragraph under "How to switch role" mentions it in passing. A full
|
||||
`?subject=` write-up (its own e2e-only purpose, `X-Medewerker`/`X-Rollen` parallel) is
|
||||
arguably worth a follow-up doc pass, but out of scope for a security-focused ticket
|
||||
about production leakage.
|
||||
- **Behaviour spec regenerated.** `libs/shared/docs/behaviour-spec.mdx` is generated from
|
||||
the suite (`npm run gen:behaviour-spec`) and is included in this diff — the CI gate's
|
||||
drift check would otherwise fail on the 6 new `describe` blocks this ticket adds.
|
||||
|
||||
## Verification
|
||||
|
||||
Every fix below was confirmed **red without it** by temporarily reverting the source
|
||||
change (tests unchanged) and re-running the affected suite, then restoring the fix:
|
||||
|
||||
- `libs/shared/src/infrastructure/role.spec.ts` — removing the `if (!isDevMode())` guard
|
||||
from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured,
|
||||
still written to `sessionStorage`).
|
||||
- `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()`
|
||||
turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written
|
||||
to `sessionStorage`).
|
||||
- `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting
|
||||
`reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }`
|
||||
turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the
|
||||
`stepUp` argument; `X-Role` sent regardless of `isDevMode()`).
|
||||
|
||||
New specs, all pure/exported-boundary tests per house convention (no `TestBed`, no
|
||||
`globalThis.fetch` stub needed for the pure halves):
|
||||
|
||||
| File | Covers |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `reveal-bignummer.adapter.spec.ts` | `parseRevealed` (incl. the finding's own `{ bigNummer: 42 }` rejection case); `reveal()`'s `X-Step-Up`/`X-Role` gating via a stubbed `fetch` |
|
||||
| `letter-preview.adapter.spec.ts` | `errorMessage`; `preview()`'s header gating via a stubbed `fetch` |
|
||||
| `org-template.adapter.spec.ts` (extended) | `proefbriefErrorMessage` |
|
||||
| `role.spec.ts` (new) | `currentRole()` dev behaviour + the `isDevMode()`-gated production behaviour |
|
||||
| `subject.spec.ts` (new) | `currentSubject()` dev behaviour + the `isDevMode()`-gated production behaviour |
|
||||
|
||||
`npm run ci` (lint, typecheck, `dep:check`, `format:check`, `check:tokens`, `check:seam`,
|
||||
full test suite with coverage, `ng build --localize` for both apps, `npm audit`, backend
|
||||
`dotnet format` + `dotnet test`, showcase-snippets/behaviour-spec/api-client drift
|
||||
checks): **green**, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and
|
||||
`dotnet test` (241 passed).
|
||||
@@ -0,0 +1,115 @@
|
||||
# RB-12 — a route-table test: every route hits an authz wrapper or an explicit allow-list
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016, `00-baseline.md` BL-006 · `99-backlog.md` RB-12
|
||||
|
||||
## What was wrong
|
||||
|
||||
BL-006, verbatim: "the backend has zero automated architecture enforcement … `Domain/`
|
||||
purity currently holds by convention." BIO-016 names the specific consequence for
|
||||
authorization: nothing asserted the **set** of gated endpoints, so an endpoint added
|
||||
without a gate (BIO-003's `X-Admin` gate outside `Authz`, BIO-004's two endpoints with
|
||||
no gate at all) failed no test. Both were caught by a human reading `Program.cs`, not by
|
||||
CI.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Program.cs` — 16 endpoint mappings | each chains a new `.Gate("XAdmin")` call, naming the admin wrapper (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`) already used inside its handler |
|
||||
| `Program.cs` — new types, end of file | `public sealed record AuthzGateMetadata(string Wrapper)` + a `Gate(...)` extension method on `IEndpointConventionBuilder` that attaches it via `.WithMetadata(...)` |
|
||||
| `tests/BigRegister.Tests/RouteInventoryTests.cs` | **new** — walks the real app's `EndpointDataSource`, asserts every route carries either an `AuthzGateMetadata` naming a known wrapper, or an entry in a written-down allow-list; a second test asserts every `.Gate(...)` name is one of the five known wrappers |
|
||||
|
||||
## Design: metadata at mapping time, not reflection over the compiled lambda
|
||||
|
||||
The ticket left the detection mechanism open, noting the wrappers are local functions
|
||||
in `Program.cs`. Reflecting over a compiled minimal-API lambda to determine which local
|
||||
function its closure calls is fragile-to-impossible (the call is inside IL a test would
|
||||
have to disassemble, and a local function's identity isn't easily recoverable from the
|
||||
delegate's `MethodInfo`). Endpoint **metadata**, attached at the same call site where the
|
||||
route is mapped, is exactly what `EndpointDataSource` hands back to a test host and
|
||||
doesn't depend on inspecting compiled code at all — so a `.Gate("XAdmin")` extension
|
||||
method was added and chained onto each of the 16 mappings that call one of the five
|
||||
wrappers.
|
||||
|
||||
This is a **declaration**, not a **derivation**: the test does not verify that
|
||||
`.Gate("CasesAdmin")` and an actual `CasesAdmin(ctx, …)` call inside the handler agree —
|
||||
it only verifies that a marker is present. A handler that swapped its `CasesAdmin(ctx,
|
||||
…)` call for a no-op without updating `.Gate(...)` would go undetected here. What _is_
|
||||
caught, reliably, is the actual BIO-003/BIO-004 failure mode: a new endpoint mapped with
|
||||
**no** marker and **no** allow-list entry — verified below by adding one and watching the
|
||||
test go red.
|
||||
|
||||
## Judgement call: the allow-list is not "public routes"
|
||||
|
||||
The ticket's literal framing — every route "goes through one of the authz wrappers …
|
||||
or appears in an explicit, named allow-list of deliberately-public routes" — doesn't fit
|
||||
this codebase as read. Only 16 of the app's 47 routes go through one of the five admin
|
||||
wrappers. The other 31 are not uniformly public:
|
||||
|
||||
- **10 are genuinely public** — orchestrator health probes and static/reference demo
|
||||
data (`SeedData`, the DUO/BRP fixtures, the scholing-threshold config value, the
|
||||
feature-flag catalog, `/me`'s reflection of the caller's own capabilities) that reads
|
||||
the same for every caller in this one-seeded-citizen POC.
|
||||
- **19 are ownership-scoped inline**, not public and not wrapper-gated: `GET
|
||||
/applications/{id}`, the upload endpoints, every brief transition, etc. all key off
|
||||
`ctx.Zorgverlener().Bsn` / `ctx.Caller()` — an authenticated citizen (or, for the
|
||||
uploads-content endpoint, a behandelaar) reading or writing only their own resource.
|
||||
Calling these "public" in an allow-list would misrepresent exactly the property
|
||||
BIO-004 was about — object-level authorization existing at all.
|
||||
- **1 (`POST /zgw/notificaties`) uses a different mechanism entirely** — a fixed-time
|
||||
shared-secret comparison for a non-Principal external caller (OpenZaak's
|
||||
notifications), audited the same way but never going through `Authz`.
|
||||
- **1 (`POST /brief/reset`) is deliberately, literally unguarded** — the endpoint's own
|
||||
pre-existing comment says so ("No guards — showcase affordance only").
|
||||
|
||||
The allow-list (`RouteInventoryTests.AllowList`) keeps all 31 as one array for the test's
|
||||
sake, but every entry carries its own reason string rather than a blanket "public" label —
|
||||
preserving BIO-016's actual intent ("makes 'this endpoint is public' a decision someone
|
||||
wrote down rather than an omission") generalised to "this endpoint's access boundary is
|
||||
_X_, deliberately," which is true of all 31 and false of "public" for 20 of them. This is
|
||||
recorded here rather than silently reinterpreted, per this task's brief: implementing the
|
||||
literal "public" framing would have been actively misleading about which endpoints have no
|
||||
access control at all.
|
||||
|
||||
## Other judgement calls
|
||||
|
||||
- **`AuthzGateMetadata` and its extension method are `public`, not `internal`.** The test
|
||||
project has no `InternalsVisibleTo` wired up for `BigRegister.Api` (checked — none
|
||||
exists anywhere in `backend/`), and adding one for a single marker type was more
|
||||
machinery than the alternative. Both types carry a comment stating why.
|
||||
- **A second test (`Every_gate_marker_names_a_known_admin_wrapper`) guards against a typo
|
||||
in a `.Gate(...)` call.** Without it, a call like `.Gate("CasesAdmn")` would just fall
|
||||
through to "unaccounted for" in the main test with a less specific failure message —
|
||||
fine, but a dedicated assertion names the actual mistake.
|
||||
- **The main test also asserts the reverse direction: no stale allow-list entries.** An
|
||||
allow-list entry for a route that was renamed or removed is exactly the kind of drift
|
||||
a "decision someone wrote down" ledger needs to catch, not just silently keep. Verified
|
||||
this fires: temporarily added one extra `AllowList` entry for a route that doesn't
|
||||
exist (via Edit, not committed) — every real route was still covered, so only the
|
||||
stale-entry assertion tripped, naming exactly that bogus entry. Reverted the same way.
|
||||
- **`RouteInventoryTests` uses the house `TestWebApplicationFactory` + `IClassFixture`
|
||||
idiom**, not a bare `new WebApplicationFactory<Program>()` per test. The first draft did
|
||||
the latter and immediately hit `SQLite Error 1: 'table "Applications" already exists'`
|
||||
— `Db.ConnectionString` (`Data/Db.cs`) is a shared mutable **static** field, and a bare
|
||||
factory doesn't override `ConnectionStrings:AppDb`, so two such factories in the same
|
||||
class end up pointed at the same file, and the second one's `Migrate()` collides with
|
||||
the first's already-created tables (the first factory's default `Dispose()` doesn't
|
||||
delete that file — only `TestWebApplicationFactory`'s override does, to its own
|
||||
per-instance temp path). This is exactly the hazard `TestWebApplicationFactory`'s own
|
||||
doc comment describes; switching to it (as every other endpoint-test class in this
|
||||
suite already does) fixed it outright — no product code involved, purely a test-fixture
|
||||
choice.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Proved the test is hard to fool**, per the ticket's explicit ask: added a throwaway
|
||||
`api.MapDelete("/rb12-throwaway-unguarded/{id}", …)` with no `.Gate(...)` and no
|
||||
allow-list entry (via Edit, not `git checkout`) — `Every_mapped_route_is_authz_gated_or_
|
||||
on_the_named_allow_list` failed red, naming exactly that route. Reverted the same way;
|
||||
re-ran green. Repeated once more after switching to `TestWebApplicationFactory` to
|
||||
confirm the fixture change didn't weaken the check — same red, same green.
|
||||
- `dotnet build` (both `src/BigRegister.Api` and `tests/BigRegister.Tests`): clean, 0
|
||||
warnings.
|
||||
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
|
||||
- `dotnet test --filter "Category!=Integration"`: **257 passed, 0 failed** (255
|
||||
pre-existing + 2 new).
|
||||
@@ -0,0 +1,186 @@
|
||||
# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13
|
||||
|
||||
## What was wrong
|
||||
|
||||
ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated
|
||||
`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker';
|
||||
medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands."
|
||||
Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow.
|
||||
|
||||
Verified before this ticket:
|
||||
|
||||
- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in
|
||||
`libs/shared/src/infrastructure/role.ts:8`. No such type existed.
|
||||
- `apps/ssp/src/app/auth/domain/session.ts` and
|
||||
`apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical:
|
||||
`interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar
|
||||
carrying a `bsn`, which §3 names as precisely the state the union exists to make
|
||||
unrepresentable.
|
||||
- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw
|
||||
persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` →
|
||||
`DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de
|
||||
Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD,
|
||||
under a citizen's name.
|
||||
- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already
|
||||
stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of
|
||||
`SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door
|
||||
instead of the `Principal` union, which is why the two `auth` contexts still measured
|
||||
as identical.
|
||||
- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after
|
||||
ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%),
|
||||
`bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's
|
||||
"auth stays duplicated because it's expected to diverge" claim had never actually been
|
||||
tested, only asserted.
|
||||
|
||||
RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider`
|
||||
able to say "no identity" and fail closed; this ticket is its stated FE half — without
|
||||
it, a production behandelportal falls through to the seeded zorgverlener by default,
|
||||
open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket
|
||||
does not touch that backend behaviour — it makes the FE identity model honest about
|
||||
who is actually authenticating.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/ssp/src/app/auth/domain/session.ts` → `principal.ts` | `Session` → `Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession` → `parseStoredPrincipal` (G1/G2 unchanged) |
|
||||
| `apps/ssp/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape |
|
||||
| `apps/ssp/src/app/auth/application/session.store.ts` | `Session` → `Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) |
|
||||
| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` |
|
||||
| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session` → `Principal` (the one other consumer of the domain type) |
|
||||
| `apps/behandelportal/src/app/auth/domain/session.ts` → `principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) |
|
||||
| `apps/behandelportal/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) |
|
||||
| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts` → `medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) |
|
||||
| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it |
|
||||
| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output<void>()` |
|
||||
| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) |
|
||||
| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) |
|
||||
| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) |
|
||||
| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure |
|
||||
| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases |
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Each app's `Principal` holds only the one variant it has an actor for**, not the
|
||||
full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own
|
||||
proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`:
|
||||
replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener`
|
||||
variant"), and it matches how the codebase already splits `auth` per app. `kind` stays
|
||||
on both single-member types anyway — it is what makes the two types genuinely
|
||||
different rather than a same-shaped coincidence, and it is where a third actor (§4 —
|
||||
admin/auditor/institution-rep) would add a member.
|
||||
- **`MedewerkerAdapter.authenticate()` returns `Promise<Principal>`, not
|
||||
`Promise<Result<string, Principal>>`.** The first draft mirrored `DigidAdapter`'s
|
||||
`Result`-returning shape for symmetry, but that `Result`'s error variant could never
|
||||
actually be produced — there is no credential to check, so wrapping the return in a
|
||||
type that claims to have a failure mode was itself a small instance of the thing
|
||||
CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a
|
||||
direct `Promise<Principal>` and dropped the now-dead error-handling branch from
|
||||
`login.page.ts` (`error` signal, the `<app-alert type="error">`, the `AlertComponent`
|
||||
import) — a real SSO integration is where that branch would come back, not before.
|
||||
This was also the change that did the most to bring the duplication figure down (see
|
||||
below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two
|
||||
pages' control flow, not just their copy, actually differed.
|
||||
- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen`
|
||||
lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an
|
||||
unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed
|
||||
`Rol[]` is pure string logic with no Angular dependency, so it belongs in
|
||||
`domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`)
|
||||
stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and
|
||||
hands them to a pure function. `parseRollen` deliberately mirrors the backend's own
|
||||
`StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so
|
||||
`?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule
|
||||
(ADR-0001's boundary is about _authorization decisions_, which still come only from
|
||||
`GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the
|
||||
same header value it is about to send, for display, the same way `DigidAdapter`
|
||||
already fabricates its own fake identity.
|
||||
- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a
|
||||
stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN")
|
||||
doesn't apply here — a `medewerker` principal has no national identifier — so there is
|
||||
nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and
|
||||
restores it as-is. This was a deliberate choice against an alternative: reconstructing
|
||||
`medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every
|
||||
restore, which would have made `domain/principal.ts` depend on
|
||||
`infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency
|
||||
rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing
|
||||
`?rollen=` mid-session does not retroactively change an already-restored `Principal`
|
||||
until the next `login()`/`logout()` — the same way changing the DigiD demo BSN
|
||||
requires a fresh login in the SSP. The backend's own authorization is unaffected
|
||||
either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP
|
||||
request regardless of what `SessionStore` holds.
|
||||
- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left
|
||||
unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside
|
||||
`auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change,
|
||||
not as something the change renames. `libs/shared/src/application/session.port.ts`'s
|
||||
`SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and
|
||||
`isAuthenticated`, neither of which is `kind`-dependent.
|
||||
- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments
|
||||
updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why
|
||||
`?subject=` exists instead of reading the store directly; renaming the type these
|
||||
comments describe without updating the comment would have left them citing a type
|
||||
that no longer exists.
|
||||
- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is
|
||||
explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it
|
||||
reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for
|
||||
this ticket to change there.
|
||||
- **No backend change.** RB-09 already made `IIdentityProvider` nullable and
|
||||
Production-fail-fast; this ticket is purely the frontend counterpart it named. The
|
||||
residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation
|
||||
callers carrying no identity header once a real, non-stub `IIdentityProvider` exists)
|
||||
is unaffected by anything here — it is about a _future_ real provider replacing the
|
||||
Development-only stub, which this ticket does not touch.
|
||||
|
||||
## Duplication, measured (`tools/baseline-scan.mjs --dup`)
|
||||
|
||||
| When | `ssp/auth` dup lines | `bhp/auth` dup lines |
|
||||
| ----------------------------------- | -------------------: | -------------------: |
|
||||
| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — |
|
||||
| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) |
|
||||
| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** |
|
||||
|
||||
Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under
|
||||
target. The full clone-pair listing (the script's own output truncates to the top 15
|
||||
pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was
|
||||
hiding below that cut) resolves to exactly four remaining pairs:
|
||||
|
||||
- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before
|
||||
trusting a stored shape" concept with a parallel `describe`/`it` structure (including
|
||||
the shared `import { describe, it, expect } from 'vitest';` line); the assertions
|
||||
themselves differ (BSN-stripping vs. kind/rollen validation).
|
||||
- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default`
|
||||
scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject.
|
||||
- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is
|
||||
meant to stay identical.
|
||||
- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small
|
||||
shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root
|
||||
singleton store in this codebase shares).
|
||||
|
||||
None of what remains is re-converged identity or login-flow logic — the domain type,
|
||||
the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction
|
||||
("the two groups authenticate differently") has been tested for the first time by this
|
||||
ticket, not just asserted, and it held.
|
||||
|
||||
## Verification
|
||||
|
||||
Confirmed each non-trivial change is red without its fix (edited in place, verified red,
|
||||
edited back — never `git checkout`):
|
||||
|
||||
- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''` →
|
||||
`G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …}
|
||||
to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected.
|
||||
- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'`
|
||||
clause → `returns null when kind is not medewerker` failed, returning the parsed
|
||||
zorgverlener-shaped object instead of `null`. Reverted.
|
||||
- **bhp `parseRollen`:** dropped `.filter(isRol)` → `drops unrecognized tokens` and
|
||||
`returns an empty list for an empty string` both failed (`['geen']`/`['']` returned
|
||||
instead of `[]`). Reverted.
|
||||
|
||||
`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) +
|
||||
133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs.
|
||||
`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp
|
||||
--localize` and `ng build behandelportal --localize`: both succeed (the new
|
||||
`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro`
|
||||
sources all resolve to an English `<target>`). `npm run ci`: green (see the commit this
|
||||
doc ships with).
|
||||
@@ -0,0 +1,57 @@
|
||||
# RB-14 — scan the .NET dependency tree for known advisories
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016 · `99-backlog.md` RB-14
|
||||
|
||||
## What was wrong
|
||||
|
||||
`npm audit --omit=dev` gates the shipped frontend bundle. Nothing equivalent existed for the
|
||||
backend, so **the entire .NET dependency tree — direct and transitive — was unscanned.** BIO-016
|
||||
lists it first under "Absent".
|
||||
|
||||
## The trap the ticket walked into
|
||||
|
||||
The backlog row says: `dotnet list package --vulnerable --include-transitive` **as a failing
|
||||
step**. Implemented literally, that step cannot fail. `dotnet list package --vulnerable` is a
|
||||
_reporting_ command: it prints the advisory table and exits 0 regardless.
|
||||
|
||||
Verified rather than assumed — a throwaway project with `System.Net.Http 4.3.0`:
|
||||
|
||||
```
|
||||
Project `vulntest` has the following vulnerable packages
|
||||
> System.Net.Http 4.3.0 4.3.0 High https://github.com/advisories/GHSA-7jgj-8wvc-jh57
|
||||
EXITCODE=0
|
||||
```
|
||||
|
||||
A **High** severity advisory, exit code **0**. A bare `- run: dotnet list package --vulnerable`
|
||||
would have added a line to `ci.yml` that reads like coverage in a compliance review and enforces
|
||||
nothing — which is worse than leaving the gap visible.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------- | -------------------------------------------------------------------------- |
|
||||
| `scripts/dotnet-audit.sh` | **new** — runs the scan, matches its output, exits 1 on a hit |
|
||||
| `.github/workflows/ci.yml` | new backend step calling the script (same `changes.outputs.backend` guard) |
|
||||
| `scripts/ci-local.sh` | new `backend dependency audit` step calling the same script |
|
||||
|
||||
**One script, two callers**, rather than the same four lines pasted into a workflow and a shell
|
||||
script that would then drift. The guard matches `has the following vulnerable packages` — the
|
||||
exact sentence `dotnet list` prints per project on a hit; the clean case prints
|
||||
`has no vulnerable packages given the current sources` instead.
|
||||
|
||||
## Verification
|
||||
|
||||
- Against the real solution: passes, both projects clean (exit 0).
|
||||
- Against the marker sentence `dotnet list` actually emits: the guard fires and exits 1.
|
||||
- The exit-0-on-High behaviour that motivates the whole script is reproduced above.
|
||||
|
||||
## Residual
|
||||
|
||||
`--include-transitive` means a vulnerable package pulled in by a dependency turns CI red with no
|
||||
direct upgrade available. The fix in that case is a direct `PackageReference` pinning a patched
|
||||
version; the script's failure message says so. There is deliberately **no severity threshold and
|
||||
no suppression list** — adding one before a real advisory forces the question would be guessing
|
||||
at a policy nobody has needed yet.
|
||||
|
||||
Secret scanning (gitleaks/trufflehog), BIO-016's other named absence, is **not** in this ticket
|
||||
and remains on the pre-production checklist.
|
||||
@@ -0,0 +1,93 @@
|
||||
# RB-15 — Swagger and the OpenAPI document behind `IsDevelopment()`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-015 · `99-backlog.md` RB-15
|
||||
|
||||
## What was wrong
|
||||
|
||||
`Program.cs:145-146` (pre-change) ran `app.UseSwagger(); app.UseSwaggerUI();`
|
||||
unconditionally — the full OpenAPI document (every route, every request/response shape)
|
||||
and SwaggerUI's interactive "Try it out" were reachable in every environment, including a
|
||||
real deployment, with no `app.Environment.IsDevelopment()` guard. BIO-015's own evidence
|
||||
notes this is one line of genuine attack-surface reduction with no POC cost.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `Program.cs` | `app.UseSwagger(); app.UseSwaggerUI();` now run only inside `if (app.Environment.IsDevelopment()) { … }` |
|
||||
| `tests/BigRegister.Tests/SwaggerGateTests.cs` | **new** — asserts `/swagger/v1/swagger.json` is served in Development and 404s outside it |
|
||||
|
||||
`builder.Services.AddSwaggerGen(...)` and `AddEndpointsApiExplorer()` were left
|
||||
unconditional — they only register DI services (the swagger-generation machinery),
|
||||
expose nothing over HTTP by themselves, and (see below) are exactly what `npm run
|
||||
gen:api` depends on staying registered in every environment it might run against.
|
||||
|
||||
## The hazard, checked rather than assumed
|
||||
|
||||
RB-09 made a non-Development environment throw during `builder.Build()` (no
|
||||
`IIdentityProvider` registered for a bare/unset environment, which defaults to
|
||||
Production), which crashed `dotnet swagger tofile` until `package.json`'s `gen:api`
|
||||
script was pinned to `ASPNETCORE_ENVIRONMENT=Development` for that one invocation
|
||||
(`docs/.../implementation/rb-09.md`). This ticket's change sits in exactly the same
|
||||
pipeline, so it needed the same empirical check, not an assumption.
|
||||
|
||||
**Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:**
|
||||
`dotnet swagger tofile` (`Swashbuckle.AspNetCore.Cli`) loads the built DLL through
|
||||
.NET's design-time `HostFactoryResolver`, builds the host, and then resolves
|
||||
`ISwaggerProvider` **directly out of the DI container** to produce `swagger.json` — it
|
||||
never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's
|
||||
`if (app.Environment.IsDevelopment())` guard lives in. Gating `UseSwagger()`/
|
||||
`UseSwaggerUI()` therefore cannot affect it, in any environment, by construction — those
|
||||
are pipeline middleware; the CLI tool bypasses the pipeline entirely.
|
||||
|
||||
**Verified, not assumed:** ran `npm run gen:api` for real. It exited 0, printed "Swagger
|
||||
JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag
|
||||
client. `git status`/`git diff` on both `backend/swagger.json` and
|
||||
`libs/shared/src/infrastructure/api-client.ts` showed **zero changes** — the regenerated
|
||||
files are byte-identical to what's already committed, confirming the gate has no effect
|
||||
on the generated contract at all.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **The guard wraps both `UseSwagger()` and `UseSwaggerUI()` together**, not just one —
|
||||
the ticket's own wording lists both, and gating only the document while leaving the UI
|
||||
reachable (or vice versa) would be a strange half-measure: SwaggerUI without the
|
||||
document 404s on load anyway, and the document without the UI still leaks the same
|
||||
route/shape enumeration BIO-015 is about.
|
||||
- **`AddSwaggerGen`/`AddEndpointsApiExplorer` were left unconditional.** They're
|
||||
DI-registration-time calls with no HTTP surface, and — now confirmed rather than
|
||||
assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever
|
||||
environment it runs the host under (pinned to Development by `gen:api`'s own script,
|
||||
but nothing stops a future non-Development invocation), so conditioning those
|
||||
registrations on `IsDevelopment()` would risk breaking the CLI tool for no
|
||||
attack-surface benefit — nobody can reach a DI-registered-but-never-routed service
|
||||
over HTTP.
|
||||
- **New tests build the "non-Development" case on a third environment name
|
||||
("Staging"), not `"Production"`.** RB-09 already made Production fail at startup
|
||||
entirely (no real `IIdentityProvider` exists yet) — a stronger guarantee than "no
|
||||
Swagger in Production," but one that means a `UseEnvironment("Production")` host
|
||||
never reaches this middleware to prove the gate itself works; it only proves RB-09's
|
||||
unrelated startup throw, which already has its own test. A `"Staging"` environment
|
||||
satisfies neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no
|
||||
`IIdentityProvider` for it — the test supplies one via `ConfigureTestServices`
|
||||
(`StubIdentityProvider`, the same one Development uses) so the host actually boots,
|
||||
and the test exercises this ticket's real gate rather than a different ticket's.
|
||||
- **The Staging host is built via `factory.WithWebHostBuilder(...)`** (layering on the
|
||||
shared `TestWebApplicationFactory` fixture), not a bare `new
|
||||
WebApplicationFactory<Program>()` — RB-12's implementation note already records the
|
||||
"table already exists" collision a bare factory hits by sharing the mutable static
|
||||
`Db.ConnectionString` instead of the fixture's own per-class isolated temp path;
|
||||
layering avoids repeating that mistake here.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Reverted the guard only** (unwrapped `UseSwagger()`/`UseSwaggerUI()` back to
|
||||
unconditional, via Edit, tests left in place) and ran `SwaggerGateTests`:
|
||||
`Swagger_document_is_not_served_outside_development` failed red (`Expected: NotFound,
|
||||
Actual: OK`). Restored the fix (via Edit) and re-ran: both green.
|
||||
- **`npm run gen:api`, run for real**: exit 0; `backend/swagger.json` and
|
||||
`libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean on
|
||||
both) — see "The hazard" above.
|
||||
- `dotnet build` (both projects): clean, 0 warnings.
|
||||
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
|
||||
- `dotnet test --filter "Category!=Integration"`: **259 passed, 0 failed** (257 + 2 new).
|
||||
@@ -0,0 +1,82 @@
|
||||
# RB-16 — `DateOnly.TryParse` on `?peildatum=`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-019 · `99-backlog.md` RB-16
|
||||
|
||||
## What was wrong
|
||||
|
||||
`Program.cs:215` (pre-change) —
|
||||
`var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`.
|
||||
`DateOnly.Parse` throws `FormatException` on anything unparseable; there was no
|
||||
`TryParse`, no 400 path, and `.Produces` on the endpoint declared only 200/403/404 — so
|
||||
an unparseable `?peildatum=` value 500'd, and in Development the exception detail was
|
||||
returned to the caller. §3c's baseline named `backend/Stamdata` 96.8% line but **71.7%
|
||||
branch** (BL-005) — this was one of the unentered branches.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Program.cs` — `GET /stamdata/{table}` | `DateOnly.Parse` replaced with `DateOnly.TryParse`; an unparseable value now returns `Results.Problem(detail: …, statusCode: 400)` instead of throwing; endpoint doc gained `.ProducesProblem(StatusCodes.Status400BadRequest)` |
|
||||
| `tests/BigRegister.Tests/StamdataEndpointTests.cs` | **new** `Unparseable_peildatum_is_400_not_500` |
|
||||
| `backend/swagger.json`, `libs/shared/src/infrastructure/api-client.ts` | regenerated (`npm run gen:api`) — the new 400 response is now part of the documented contract |
|
||||
|
||||
## What the fix looks like
|
||||
|
||||
```csharp
|
||||
DateOnly? peildatumWaarde = null;
|
||||
if (peildatum is { Length: > 0 } p)
|
||||
{
|
||||
if (!DateOnly.TryParse(p, out var parsed))
|
||||
return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest);
|
||||
peildatumWaarde = parsed;
|
||||
}
|
||||
var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows();
|
||||
```
|
||||
|
||||
Matches the shape every other bad-input check in this file already uses (e.g. the
|
||||
upload endpoint's `Results.Problem(detail: …, statusCode: 400)` for a malformed
|
||||
multipart request) — a `Results.Problem` with a Dutch detail message, not a bespoke
|
||||
response shape.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **No FE change needed, and none made.** `libs/beheer/src/infrastructure/
|
||||
stamdata.adapter.ts`'s `load()` already routes every call through `runSubmit`
|
||||
(`libs/shared/src/application/submit.ts`), which try/catches any thrown
|
||||
`ApiException` — including the client's new 400 branch — into a generic `Result`
|
||||
error string via `problemDetail`. There is no status-code-specific branching to
|
||||
extend; ADR-0001's "the FE renders the decision, it does not recompute the rule"
|
||||
already covers "the server rejected this input" as a case the generic error path
|
||||
handles, same as the existing 403.
|
||||
- **Regenerated the API client and committed it in this ticket's diff**, rather than
|
||||
leaving it to drift. RB-09's implementation note records a real prior incident where
|
||||
a response-shape change (RB-08's 403 → `ProducesProblem`) landed without a
|
||||
regeneration and the drift went unnoticed until the next ticket's `gen:api` run. This
|
||||
ticket's `.ProducesProblem(400)` is exactly that same category of change, so
|
||||
`npm run gen:api` was run immediately as part of implementing it, not deferred.
|
||||
- **The Dutch detail message follows the file's own convention** (`$"Ongeldige
|
||||
peildatum '{p}'."`) rather than English — every other `Results.Problem(detail: …)`
|
||||
call in `Program.cs` (change-request rejection, upload validation, submit rejection)
|
||||
is Dutch; this is server-internal wire text, not `$localize`-wrapped UI copy (the FE
|
||||
never renders it verbatim — CLAUDE.md's `$localize` rule is about user-facing copy
|
||||
the FE owns, not backend `ProblemDetails.detail` strings), so no locale entry was
|
||||
needed.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Reverted the fix only** (`DateOnly.Parse` restored, ternary un-nested, via Edit —
|
||||
test left in place) and ran `StamdataEndpointTests`:
|
||||
`Unparseable_peildatum_is_400_not_500` failed red (`Expected: BadRequest, Actual:
|
||||
InternalServerError` — confirming the endpoint really did 500, not some other status).
|
||||
Restored the fix (via Edit) and re-ran: all 6 tests in the class green.
|
||||
- `dotnet build`: clean, 0 warnings.
|
||||
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
|
||||
- `dotnet test --filter "Category!=Integration"`: **260 passed, 0 failed** (259 + 1
|
||||
new).
|
||||
- `npm run gen:api`: exit 0; `backend/swagger.json` gained the 400 response shape for
|
||||
this one endpoint; `libs/shared/src/infrastructure/api-client.ts` gained the
|
||||
matching `status === 400` branch. Both regenerated files committed alongside the
|
||||
code change.
|
||||
- `npm test` (all four Vitest projects — ssp/behandelportal/shared/beheer): **445
|
||||
passed, 0 failed**, confirming the regenerated client doesn't break any existing FE
|
||||
consumer of `stamdataTable(...)`.
|
||||
@@ -0,0 +1,130 @@
|
||||
# RB-17 — split `runResult` (fold) from `runSubmit` (fold + idempotency mint)
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-003 + CQ-005 ·
|
||||
`00-baseline.md` BL-007 (+ its §10 amendment) · `99-backlog.md` RB-17
|
||||
|
||||
## What was wrong
|
||||
|
||||
`runSubmit` (`libs/shared/src/application/submit.ts`) did two things in one function: fold a
|
||||
call into a `Result`, and mint an Idempotency-Key for it
|
||||
(`withIdempotencyKey(crypto.randomUUID(), fn)`). Its own docstring called that mint "the one
|
||||
place a logical submit's Idempotency-Key is minted". Five call sites are reads and had no
|
||||
business minting one:
|
||||
|
||||
| Adapter | Method | Wire call |
|
||||
| ---------------------------- | -------------------- | --------------------- |
|
||||
| `brief.adapter.ts:56` | `load()` | `briefGET()` |
|
||||
| `org-template.adapter.ts:42` | `list()` | `orgTemplates()` |
|
||||
| `org-template.adapter.ts:54` | `load(subOrgId)` | `orgTemplateGET(...)` |
|
||||
| `stamdata.adapter.ts:27` | `list()` | `stamdataTables()` |
|
||||
| `stamdata.adapter.ts:42` | `load(tableId, ...)` | `stamdataTable(...)` |
|
||||
|
||||
That is **exactly five** — verified by grepping every `runSubmit` call site in
|
||||
`libs/shared/src/application` plus the `brief` and `beheer` scopes (13 call sites total) and
|
||||
reading each one's wire call for a request body / non-GET verb. The other 8 are genuine
|
||||
writes (`brief.adapter.ts` save/submit/approve/reject/send/reset,
|
||||
`org-template.adapter.ts` save/publish/rollback) and stay on `runSubmit` unchanged.
|
||||
|
||||
`stamdata.adapter.ts`'s own module docstring already said "Both endpoints are reads … There
|
||||
is no write method" while both called `runSubmit` — the sharpest instance of the mismatch,
|
||||
and the one BL-007's original "~13 mutations" count mis-classified because the count was
|
||||
derived from the helper's name, not from what the call actually does.
|
||||
|
||||
**Not this ticket, seen while auditing:** `ApplicationsStore.cancel`, `AdminCasesStore.delete`
|
||||
(RB-20) and `FeatureFlagStore.set` reach `ApiClient` more directly; the baseline's §10
|
||||
amendment flags these as writes the original "~13" count missed. Grepping confirms
|
||||
`FeatureFlagStore.set` (`libs/shared/src/application/feature-flags.store.ts:63`) already
|
||||
calls `runSubmit` correctly and returns a `Result` — it is not broken, just outside this
|
||||
ticket's five. `ApplicationsStore.cancel`/`AdminCasesStore.delete` were not touched; they are
|
||||
RB-20's.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `libs/shared/src/application/submit.ts` | Split: `runResult` = the try/catch + `problemDetail` fold, no mint. `runSubmit` = `runResult` wrapping `withIdempotencyKey`. |
|
||||
| `libs/shared/src/application/submit.spec.ts` | Specs for both, including one that would catch a read minting a key again (see below). |
|
||||
| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()` → `runResult`; docstring updated to name both halves. |
|
||||
| `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts` | `list()`, `load()` → `runResult`. |
|
||||
| `libs/beheer/src/infrastructure/stamdata.adapter.ts` | `list()`, `load()` → `runResult`; import trimmed to `runResult` only (no writes in this file). |
|
||||
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `runResult` describe block and the extra `runSubmit` case. |
|
||||
|
||||
`runSubmit`'s new body is exactly the minimal composition the ticket asked for:
|
||||
|
||||
```ts
|
||||
export function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
|
||||
return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback);
|
||||
}
|
||||
```
|
||||
|
||||
Zero behaviour change for the 8 write call sites — same fold, same mint, same timing (the key
|
||||
is still minted before `fn` runs and cleared in `withIdempotencyKey`'s `.finally`). The five
|
||||
reads now run the fold with no `pendingIdempotencyKey` touched at all.
|
||||
|
||||
## The spec that would catch a regression
|
||||
|
||||
`currentIdempotencyKey()` (`api-client.provider.ts`) returns the pending key while one is
|
||||
"in flight" for the duration of a `withIdempotencyKey` call, and a fresh `crypto.randomUUID()`
|
||||
on every call otherwise. That gives a real, mock-free way to assert "no key was minted": call
|
||||
`currentIdempotencyKey()` twice inside the function passed to `runResult`/`runSubmit` — two
|
||||
different reads means no pending key existed (each fell back to its own random UUID); two
|
||||
equal reads means one pending key was minted and reused.
|
||||
|
||||
```ts
|
||||
it('mints no Idempotency-Key — the read fold', async () => {
|
||||
let first = '',
|
||||
second = '';
|
||||
await runResult(async () => {
|
||||
first = currentIdempotencyKey();
|
||||
second = currentIdempotencyKey();
|
||||
return 'x';
|
||||
}, 'fallback');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
```
|
||||
|
||||
This mirrors the house convention of not mocking relative imports under this repo's
|
||||
Angular/vitest setup (see `role.interceptor.spec.ts`'s comment) — it asserts on real,
|
||||
exported behaviour instead of a spy.
|
||||
|
||||
**Verified red without the fix**: temporarily changed `runResult` to also call
|
||||
`withIdempotencyKey` (i.e. reintroduced the bug it exists to prevent) and reran `ng test
|
||||
shared`. Result: `runResult > mints no Idempotency-Key — the read fold` failed
|
||||
(`expected 'd185d827-...' not to be 'd185d827-...'`), all 137 other tests stayed green. Then
|
||||
reverted the temporary edit back to the real fix (an `Edit` undo, not `git checkout`, so the
|
||||
rest of the change stayed in place) and reran — 138/138 green.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Docstring on `brief.adapter.ts`** was rewritten (it previously said only "Mutations go
|
||||
through `runSubmit`") to name `load`'s `runResult` path explicitly, since the file mixes
|
||||
both now and a future reader needs the split spelled out at the top, not just per-method.
|
||||
`org-template.adapter.ts` and `stamdata.adapter.ts`'s docstrings needed no change — neither
|
||||
named `runSubmit` specifically (`stamdata.adapter.ts`'s already correctly said "no write
|
||||
method").
|
||||
- **No new concept, per the ticket's "minimal" framing** — `runSubmit` stays exported with
|
||||
the same signature and the same call sites for the 8 real mutations; only its body changed
|
||||
to delegate.
|
||||
- **Left `runResult`'s JSDoc pointing at `runSubmit`** ("never route a read through that
|
||||
one") rather than duplicating the Idempotency-Key explanation, so the two docs stay
|
||||
synchronized by cross-reference instead of by copy.
|
||||
|
||||
## Residuals (not this ticket)
|
||||
|
||||
- RB-18 (key the `IdempotencyStore` on `{SubjectId}:{idemKey}`) is sequenced behind this one
|
||||
per `99-backlog.md` and is unaffected by this split beyond it now landing on a correctly
|
||||
write-only call set.
|
||||
- RB-20 (`ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`) is
|
||||
untouched, as scoped.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci` (foreground): **green** — lint, typecheck, `dep:check` (341 + 226 modules, 0
|
||||
violations), `format:check`, `check:tokens`, `check:seam`, tests (ssp 258/258, behandelportal
|
||||
31/31, shared 138/138, beheer 23/23 — 450 total), `ng build --localize` (both apps), `npm
|
||||
audit` (0 vulnerabilities), backend `dotnet test` (255/255 — the known
|
||||
`OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not reproduce on
|
||||
this run), `gen:snippets` drift clean, `gen:behaviour-spec` drift clean once the regenerated
|
||||
file is committed alongside the code (the local gate compares the working tree to `HEAD`, so
|
||||
it necessarily shows a diff pre-commit — this is the documented "will conflict at merge time"
|
||||
behaviour, not a defect).
|
||||
@@ -0,0 +1,115 @@
|
||||
# RB-18 — key `IdempotencyStore` on `{SubjectId}:{idemKey}`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-018 ·
|
||||
`00-baseline.md` §7 (`IdempotencyStore` listed among the 7 stores "Not behind any port"),
|
||||
agent 02's `backend/Data` note ("no `Reset()` and no TTL") · `99-backlog.md` RB-18
|
||||
|
||||
## What was wrong
|
||||
|
||||
`Data/IdempotencyStore.cs` is a process-global `Dictionary<string, IResult>` keyed only on
|
||||
the raw `Idempotency-Key` header value. `Program.cs`'s `Submit` helper read and wrote it
|
||||
with that raw value, never composed with the caller's identity:
|
||||
|
||||
```csharp
|
||||
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||
? k.ToString()
|
||||
: null;
|
||||
```
|
||||
|
||||
The client picks the header value. Two different callers who happen to send the same
|
||||
value shared one cache slot: the second caller's request short-circuited to the first
|
||||
caller's cached `IResult` instead of running its own submission. BIO-018 rates this
|
||||
**severity low** — the cached value is only a `ReferentieResponse` (a reference number) or
|
||||
a `ProblemDetails`, never personal data — but flags it as a defect in an access-control
|
||||
path with a trivial fix.
|
||||
|
||||
**Location check against the finding.** BIO-018 cites `Program.cs:901-909` for the read/
|
||||
write and `Data/IdempotencyStore.cs:11-27` for the store. RB-17 (landed the day before,
|
||||
same file, unrelated change) shifted line numbers; the real call sites are
|
||||
`Program.cs:994` (read) and `:1028` (write), inside the local `Submit` helper starting at
|
||||
`:991`. The store file itself is untouched by RB-17 and matches the finding's shape
|
||||
exactly. `Submit` has exactly one call site (`POST /change-requests`, `:239`) — the
|
||||
`ChangeRequestRequest` → `telefoonwijziging` endpoint — so the scoping change lands on a
|
||||
single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to
|
||||
produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used
|
||||
this helper in the first place, so its ordering benefit does not change what this ticket
|
||||
touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped
|
||||
its residual to "this ticket is unaffected by this split beyond it now landing on a
|
||||
correctly write-only call set" — true, and the call set was already this one endpoint
|
||||
before and after RB-17.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/BigRegister.Api/Program.cs` | `Submit`'s `idemKey` is now `$"{ctx.Caller().SubjectId}:{k}"` instead of the raw header value `k.ToString()`; doc comment above `Submit` states the scoping and cites RB-18/BIO-018 |
|
||||
| `tests/BigRegister.Tests/IdempotencyTests.cs` | **new** `A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result` |
|
||||
|
||||
`ctx.Caller()` (`Domain/Authorization/CallerIdentity.cs`) is already in scope in
|
||||
`Program.cs` — `ctx.Zorgverlener()` is used elsewhere in the same file — and it throws if
|
||||
the identity middleware did not run, so this composition cannot silently fall back to an
|
||||
unscoped key. `SubjectId` is the BSN for a `ZorgverlenerCaller` and the medewerkerId for a
|
||||
`MedewerkerCaller`; either way it is stable per caller and never empty.
|
||||
|
||||
This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound,
|
||||
no `Reset()`, no port/interface extraction. `IdempotencyStore`'s own `ponytail:` comment
|
||||
("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory
|
||||
leak at scale") is untouched — the store is still unbounded and still keyed on a
|
||||
client-supplied string, only now composed with a server-resolved one first. The comment
|
||||
stays accurate; this ticket did not touch the part it would need to correct.
|
||||
|
||||
## The test
|
||||
|
||||
`IdempotencyTests.cs` already existed (RB-17's predecessor work, not this ticket) with
|
||||
three cases exercising same-caller replay/independence. Added a fourth:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
|
||||
{
|
||||
var sharedKey = Guid.NewGuid().ToString();
|
||||
|
||||
var callerARequest = ChangeRequestWithKey(sharedKey);
|
||||
callerARequest.Headers.Add("X-Subject", "111222333");
|
||||
var callerA = await _client.SendAsync(callerARequest);
|
||||
callerA.EnsureSuccessStatusCode();
|
||||
var callerABody = await callerA.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
var callerBRequest = ChangeRequestWithKey(sharedKey);
|
||||
callerBRequest.Headers.Add("X-Subject", "999888777");
|
||||
var callerB = await _client.SendAsync(callerBRequest);
|
||||
callerB.EnsureSuccessStatusCode();
|
||||
var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
|
||||
}
|
||||
```
|
||||
|
||||
`X-Subject` is `StubIdentityProvider`'s existing header for setting the caller's BSN in a
|
||||
test (the same idiom `ApplicationTests.cs` and `UploadAccessTests.cs` use), so caller A and
|
||||
caller B are two different `ZorgverlenerCaller`s sending the identical `Idempotency-Key`.
|
||||
|
||||
**Verified red without the fix.** Reverted `Program.cs`'s `idemKey` line to
|
||||
`k.ToString()` with an `Edit` (not `git checkout`, so the rest of the working tree stayed
|
||||
intact), reran `dotnet test --filter "FullyQualifiedName~IdempotencyTests"`:
|
||||
|
||||
```
|
||||
Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms]
|
||||
Error Message:
|
||||
Assert.NotEqual() Failure: Strings are equal
|
||||
Expected: Not "BIG-2026-476969"
|
||||
Actual: "BIG-2026-476969"
|
||||
Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4
|
||||
```
|
||||
|
||||
Caller B received caller A's cached reference. Then reapplied the fix with a second
|
||||
`Edit` and reran: `Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4`.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet test` (full suite): **261 passed, 1 failed** — the failure is
|
||||
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||
which needs a live OpenZaak container and fails identically on a stashed tree; it predates
|
||||
this change and is not run by `npm run ci`.
|
||||
|
||||
`npm run ci` (foreground): green — see the commit's own record for the full step list.
|
||||
@@ -0,0 +1,220 @@
|
||||
# RB-19 — reorder `Program.cs`: reads before writes per section, regroup admin-cases + org-template preview
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-006 ·
|
||||
`99-backlog.md` RB-19 · Depends on `implementation/rb-12.md` (the route-table test this
|
||||
ticket leans on as its regression net)
|
||||
|
||||
This is a **pure reorder**. No route, signature, DTO, or handler-body text changed. The
|
||||
sorted list of `HTTP METHOD + path` mapping calls is byte-identical before and after (see
|
||||
"Verification" below) — that identity is the strongest evidence this ticket did what it
|
||||
says and nothing else.
|
||||
|
||||
## What was wrong
|
||||
|
||||
CQ-006, verbatim: `Program.cs` opens by declaring direction as its organising principle
|
||||
(a "GET: screen-shaped reads" banner, then a "POST: submits" banner), then from the
|
||||
Document-upload section onward switches to feature grouping without saying so, and every
|
||||
subsequent section interleaves reads and writes. One feature (Beoordeling/Besluit, WP-65)
|
||||
already got the fix — a `:441`/`:464`-style banner pair splitting its query endpoint from
|
||||
its command endpoint — and CQ-006 asks for the same treatment on the five sections that
|
||||
predate that pattern: Document upload, Applications, Admin cases, Brief, and Organization
|
||||
templates. Separately, `DELETE /admin/cases/{id}` sat 129 lines away from `GET
|
||||
/admin/cases`, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in
|
||||
between; and `GET /admin/org-template/{subOrgId}/preview` was filed under the Brief
|
||||
section's banner instead of the Org-templates section it actually belongs to.
|
||||
|
||||
## What changed
|
||||
|
||||
| Section (banner) | Before | After |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Document upload | categories, **POST /uploads**, content, status, DELETE, admin-DELETE | categories, content, status, `--- reads ---`/`--- writes ---` sub-banners, **POST /uploads** moved after the reads, DELETE, admin-DELETE |
|
||||
| Applications | already reads-first (2 GETs, then POST/PUT/DELETE/POST-submit) | unchanged order; sub-banners inserted only |
|
||||
| Admin cases | GET /admin/cases, _(werkvoorraad/beoordeling/besluit/zgw-notificaties in between)_, **DELETE /admin/cases/{id}**, **GET /admin/audit** | GET /admin/cases, **GET /admin/audit** (moved up), `--- writes ---`, **DELETE /admin/cases/{id}** (moved up) — all three now contiguous; werkvoorraad/beoordeling/besluit/zgw-notificaties follow, unmoved and unchanged |
|
||||
| Brief | GET /brief, PUT, submit, approve, reject, send, reveal-bignummer, **GET /brief/preview**, _(org-template preview)_, POST /reset | GET /brief, **GET /brief/preview** (moved up beside GET /brief), `--- writes ---`, PUT, submit, approve, reject, send, reveal-bignummer, POST /reset — org-template preview removed from this section |
|
||||
| Organization templates | list, detail, PUT, publish, rollback | list, detail, **GET /admin/org-template/{subOrgId}/preview** (moved in from Brief), `--- writes ---`, PUT, publish, rollback |
|
||||
|
||||
Every section above got a `// --- reads ---` / `// --- writes ---` sub-banner pair
|
||||
(matching the short, bare form already used at the file's top-level `:170`/`:236`
|
||||
banners) inserted at the reads→writes boundary. Werkvoorraad, Beoordeling and Besluit —
|
||||
not named by CQ-006 as mixed, and already correctly split (Beoordeling is the read,
|
||||
Besluit is the write, each with its own WP-65 banner) — were left exactly as they were,
|
||||
including their absolute position relative to each other; only the block ahead of them
|
||||
(admin-cases) grew, pushing their line numbers down without touching their content.
|
||||
|
||||
`backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` were
|
||||
regenerated (`npm run gen:api`) and are part of this commit — see "The regenerated pair"
|
||||
below.
|
||||
|
||||
## Design: line-range slicing, not manual retyping
|
||||
|
||||
Every moved block was cut with a Python script operating on exact 1-indexed line ranges
|
||||
against the file as it stood after merging in `refactor/adr-c-006-shared-route-guards`
|
||||
(this branch's actual base — see "Base commit" below), then reassembled in the new order.
|
||||
No handler body was retyped by hand. This is the same guarantee the ticket's "cut/paste,
|
||||
not retype" instruction asks for, made structural rather than a promise to be careful:
|
||||
a line-range slice cannot silently change a character inside a block it does not touch.
|
||||
The script is not part of this commit (a one-shot tool, not project code); the diff it
|
||||
produced is what is being reviewed.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Sub-banner wording is bare `// --- reads ---` / `// --- writes ---`, not prose
|
||||
matching WP-65's descriptive style.** The ticket asks for ":441/:464-style" banners;
|
||||
WP-65's actual banners are long, feature-specific paragraphs ("read side only
|
||||
(recording a decision is WP-65's second half)…"). Inventing five more paragraphs like
|
||||
that would mean writing new explanatory prose about code this ticket is not meant to
|
||||
re-explain — CQ-006 is explicit that this is "a structure finding, not a correctness
|
||||
one," and the ticket itself forbids "no fixed comments beyond the banners this ticket
|
||||
adds." The file's own top-level banners (`:170` "GET: screen-shaped reads", `:236`
|
||||
"POST: submits") already establish a bare, label-only banner as a legitimate style in
|
||||
this exact file — the sub-banners here are that same style, nested one level deeper.
|
||||
- **Werkvoorraad/Beoordeling/Besluit end up sandwiched between Admin-cases and
|
||||
zgw/notificaties, in that order, unmoved.** Moving `GET /admin/audit` and `DELETE
|
||||
/admin/cases/{id}` up next to `GET /admin/cases` (as instructed) necessarily pushes
|
||||
everything that used to sit between them — werkvoorraad, beoordeling, besluit,
|
||||
zgw/notificaties — down, but does not reorder those four relative to each other. They
|
||||
were not named as mixed by CQ-006 and were not touched beyond their line numbers
|
||||
changing.
|
||||
- **`GET /brief/preview` and `POST /uploads` are both `.ExcludeFromDescription()`-marked
|
||||
(hand-written FE `fetch`/XHR calls, never through the generated client) — moving them
|
||||
produced zero diff in `swagger.json`.** This is not a coincidence being reported as
|
||||
one: an excluded endpoint has no OpenAPI operation to reorder in the first place, so
|
||||
the regenerated pair's diff below is smaller than "every moved route" might suggest —
|
||||
it only shows the two endpoints that are both documented and reordered relative to
|
||||
each other (`GET /admin/audit`, `DELETE /admin/cases/{id}`).
|
||||
- **No handler types, no `Features/` folder, no mediator** — out of mandate per CQ-006's
|
||||
own text (filed separately as OOM-A) and the ticket's explicit "out of scope" section.
|
||||
Nothing beyond comments and mapping order changed.
|
||||
|
||||
## Base commit
|
||||
|
||||
Step zero's warning matched this worktree's actual starting state: `git log --oneline -8`
|
||||
showed `ae7781e` at HEAD, not `edd20c0`, and `edd20c0 docs(backlog): mark RB-23 done after
|
||||
merge` was absent from the log entirely — the bad-base lineage named in the ticket. `git
|
||||
merge refactor/adr-c-006-shared-route-guards` was run, after which `edd20c0` appeared as
|
||||
`HEAD~0`'s direct ancestor and every RB-01..RB-23 commit was present. All work in this
|
||||
ticket happened after that merge.
|
||||
|
||||
## Verification
|
||||
|
||||
**The sorted-route-list diff (the key evidence).** Extracted every `.Map(Get|Post|Put|
|
||||
Delete)("...")` call from `Program.cs` before and after, sorted each list, and diffed
|
||||
them:
|
||||
|
||||
```
|
||||
$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs.before-reorder | sort > before.txt
|
||||
$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs | sort > after.txt
|
||||
$ diff before.txt after.txt
|
||||
$ echo "exit=$?"
|
||||
exit=0
|
||||
$ wc -l before.txt after.txt
|
||||
47 before.txt
|
||||
47 after.txt
|
||||
```
|
||||
|
||||
Empty diff, same count (47 `api.Map*` calls — the two `app.MapGet` health probes are
|
||||
outside the `/api/v1` group and were never in scope for this reorder; they were untouched
|
||||
either way). The set of routes is provably unchanged.
|
||||
|
||||
**`.Gate(...)` count, before/after, by wrapper name:**
|
||||
|
||||
```
|
||||
3 .Gate("Beoordelen")
|
||||
4 .Gate("CasesAdmin")
|
||||
1 .Gate("FlagsAdmin")
|
||||
6 .Gate("OrgAdmin")
|
||||
2 .Gate("StamdataAdmin")
|
||||
```
|
||||
|
||||
Identical in both directions — no gate call was added, removed, or renamed.
|
||||
|
||||
**Per-route eyeball check of every route that changed position, per RB-12's stated
|
||||
limitation** (the route-table test only proves a `.Gate(...)` marker is present, not that
|
||||
it still names the wrapper the handler body actually calls):
|
||||
|
||||
| Route | Moved | `.Gate(...)` after | Wrapper actually called inside the handler | Match |
|
||||
| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------- | ------------------------------------------ | ----- |
|
||||
| `GET /admin/audit` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => ...)` | yes |
|
||||
| `DELETE /admin/cases/{id}` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => { ... })` | yes |
|
||||
| `GET /admin/org-template/{subOrgId}/preview` | Brief section → Org-templates section | `OrgAdmin` | `OrgAdmin(ctx, () => { ... })` | yes |
|
||||
| `POST /uploads` | within Document-upload, past the three reads | _(none — allow-listed, ownership-scoped)_ | — | n/a |
|
||||
| `GET /brief/preview` | within Brief, up beside `GET /brief` | _(none — allow-listed, ownership-scoped)_ | — | n/a |
|
||||
| `GET /uploads/{documentId}/content` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a |
|
||||
| `GET /uploads/status` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a |
|
||||
|
||||
Five routes were deliberately relocated by this ticket; two more shifted position only as
|
||||
a byproduct of `POST /uploads` moving past them (their own order relative to each other
|
||||
is unchanged). All three gated routes among these were checked by eye against the
|
||||
handler body they wrap, not just against `RouteInventoryTests`' marker check — all three
|
||||
match.
|
||||
|
||||
**`RouteInventoryTests`:**
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 770 ms
|
||||
```
|
||||
|
||||
Both `Every_mapped_route_is_authz_gated_or_on_the_named_allow_list` and
|
||||
`Every_gate_marker_names_a_known_admin_wrapper` pass.
|
||||
|
||||
**Full backend suite:** `dotnet test --filter "Category!=Integration"` — **262/262
|
||||
passing**, plus the one known, pre-existing, container-dependent failure
|
||||
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`),
|
||||
which does not run under `npm run ci` and reproduces on a clean tree with no OpenZaak
|
||||
container running — not this ticket's bug.
|
||||
|
||||
**`dotnet build`** (both projects): 0 warnings, 0 errors. **`dotnet format
|
||||
BigRegister.slnx --verify-no-changes`**: clean.
|
||||
|
||||
**No new test was added.** Per the ticket's Definition of Done: this is a zero-semantic-
|
||||
change commit, and §3c's pre-existing 97.4% line / 84.8% branch coverage of `Program.cs`
|
||||
is the regression net CQ-006 itself names. Nothing about this diff needs a new test to be
|
||||
trustworthy — a passing pre-existing suite plus an empty sorted-route diff is stronger
|
||||
evidence for "nothing changed" than a new test asserting the same thing would be.
|
||||
|
||||
## The regenerated pair
|
||||
|
||||
`npm run gen:api` was run after the reorder. It produced a diff in both
|
||||
`backend/swagger.json` (2 hunks) and `libs/shared/src/infrastructure/api-client.ts` (5
|
||||
hunks) — both **pure reordering, zero content change**. Confirmed by sorting every line of
|
||||
each file (before vs. after) and diffing the sorted output: empty in both cases. The only
|
||||
two OpenAPI paths that moved position in the document are `/api/v1/admin/audit` and
|
||||
`/api/v1/admin/cases/{id}` — the two documented (non-`ExcludeFromDescription`) endpoints
|
||||
this ticket actually reordered relative to their OpenAPI-document neighbours; the
|
||||
generated client's `audit()`/`cases()` methods and their `process*` helpers moved by the
|
||||
same amount, unchanged in every other respect (parameters, return types, status-code
|
||||
branches, JSDoc). Both regenerated files are committed alongside `Program.cs`, per the
|
||||
ticket's explicit instruction: "if the only change is ordering inside swagger.json, say
|
||||
so explicitly and commit the regenerated pair rather than leaving CI's drift job to
|
||||
fail."
|
||||
|
||||
**`npm run ci`**: every job through "backend dependency audit" passed before this
|
||||
ticket's files were committed; the one job that legitimately failed pre-commit was "api-
|
||||
client drift" (`git diff --exit-code` against the not-yet-committed regenerated files —
|
||||
expected, since that step compares the working tree to `HEAD`, and `HEAD` still had the
|
||||
pre-reorder client at that point). After committing, `npm run ci` was re-run to confirm a
|
||||
clean, fully green result against the committed tree — see the final PASS/exit-code
|
||||
reported in this ticket's closing message.
|
||||
|
||||
## What a reviewer should check
|
||||
|
||||
This diff is too large to read top-to-bottom without guidance. The fastest way to review
|
||||
it with confidence:
|
||||
|
||||
1. **Trust the sorted-route diff, not a manual read of every hunk.** The "Verification"
|
||||
section above shows the set of `HTTP METHOD + path` strings is byte-identical before
|
||||
and after. If you want to reproduce it yourself: check out this commit's parent,
|
||||
extract the same `grep -oE` pattern from both revisions of `Program.cs`, sort, diff.
|
||||
2. **Spot-check the five per-route table entries above**, not the whole file — those are
|
||||
the only routes whose position (and, for three of them, gate-vs-handler match)
|
||||
actually matters for this ticket's correctness claim.
|
||||
3. **Diff `git show <this-commit> -- backend/src/BigRegister.Api/Program.cs` with
|
||||
whitespace-insensitive word diff** (`git diff -w --color-words`) if you want to
|
||||
confirm no character inside a moved handler body changed — the line-range-slicing
|
||||
approach in "Design" above makes this a formality rather than a real risk, but it is
|
||||
cheap to re-check.
|
||||
4. **Do not expect Werkvoorraad/Beoordeling/Besluit/zgw-notificaties to have moved
|
||||
position relative to each other** — only their absolute line numbers shifted, as a
|
||||
side effect of the admin-cases block growing above them.
|
||||
5. **The regenerated `swagger.json`/`api-client.ts` diff is expected and pre-verified as
|
||||
ordering-only** (sorted-file diff is empty) — it does not need a second manual read.
|
||||
@@ -0,0 +1,117 @@
|
||||
# RB-20 — route `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`, surface the error
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-002 ·
|
||||
`00-baseline.md` BL-007 · `99-backlog.md` RB-20 · SIGN-OFF: consolidation approved
|
||||
2026-08-27, HALT lifted
|
||||
|
||||
## What was wrong
|
||||
|
||||
`ApplicationsStore.cancel` and `AdminCasesStore.delete` both owned an optimistic write next
|
||||
to their `RemoteData` read, and both reached `ApplicationsAdapter` directly instead of going
|
||||
through `runSubmit` (the fold + Idempotency-Key mint every other mutation in the repo uses,
|
||||
including `createSubmitChangeRequest` in the same folder). The failure path was a bare
|
||||
`catch { this.state.set(before); }`: a failed cancel or delete rolled the row back, but the
|
||||
user saw no message at all — no `ActionState`, no ProblemDetails `detail`, nothing. The
|
||||
`Idempotency-Key` on the wire was also a fresh UUID per HTTP attempt (minted by
|
||||
`api-client.provider.ts`'s default), not the per-logical-submit key `runSubmit` promises —
|
||||
harmless today only because `Program.cs` happens to ignore the header outside the `Submit`
|
||||
helper (CQ-005's note).
|
||||
|
||||
## What changed
|
||||
|
||||
CQ-002's option (a) — the smallest fix, applied identically to both stores. No new command
|
||||
factory, no adapter split (CQ-002's own "Not filed" note reserves that split for option (b),
|
||||
which this ticket does not take).
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/ssp/src/app/registratie/application/applications.store.ts` | `cancel` now calls `runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED)`; added a private `error` signal, exposed read-only as `lastError`. On failure: roll back AND `this.error.set(r.error)`. On the next attempt, the error is cleared before the call so a stale message never survives a fresh action. |
|
||||
| `apps/ssp/src/app/registratie/application/applications.store.spec.ts` (new) | 4 specs: load+parse, optimistic cancel, roll-back-and-surface-error on failure, stale-error-clears-on-next-attempt. No spec file existed for this store before RB-20. |
|
||||
| `apps/ssp/src/app/registratie/application/admin-cases.store.ts` | Same shape as `applications.store.ts`: `delete` through `runSubmit`, `error`/`lastError` signal pair. |
|
||||
| `apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts` | Existing "rolls back … when the delete fails" spec extended to also assert `lastError()`; one new stale-error-clears spec added. |
|
||||
| `apps/ssp/src/app/registratie/ui/dashboard.page.ts` | One `@if (cancelError(); as err) { <app-alert type="error">{{ err }}</app-alert> }` above the aanvragen list, mirroring `brief.page.ts`'s `lastError` rendering. `cancelError` is a `computed(() => this.apps.lastError())`. |
|
||||
| `apps/ssp/src/app/registratie/ui/admin-cases.page.ts` | Same `@if (store.lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }`, placed above `<app-async>` inside the `canManage()` branch (`store` was already `protected`, so no new exposure needed). |
|
||||
|
||||
`applications.adapter.ts` (`cancel`, `deleteAny`) is **unchanged** — the fix is entirely in
|
||||
the two stores, which now wrap the existing thin adapter calls in `runSubmit` at the call
|
||||
site, exactly as `createSubmitChangeRequest` wraps `ChangeRequestAdapter.changeRequest`. The
|
||||
adapter methods still return a bare `Promise<void>`; `runSubmit` is what folds that into a
|
||||
`Result`.
|
||||
|
||||
Neither UI change introduces a new user-facing string: the rendered text is either the
|
||||
existing `SUBMIT_FAILED` constant (`@@submit.failed`, already translated in
|
||||
`messages.en.xlf` since RB-17) or, when the backend sends one, a ProblemDetails `detail`
|
||||
string carried verbatim from the server — never a new `$localize` id. `messages.en.xlf` did
|
||||
not need a new `<target>`.
|
||||
|
||||
## The tests, and their red failures
|
||||
|
||||
Both specs assert `store.lastError()` after a rejected adapter call, which only the fix can
|
||||
satisfy — the old bare `catch { this.state.set(before) }` never touched an error signal, so
|
||||
`lastError()` stayed `null` forever.
|
||||
|
||||
**Verified red without the fix** (an `Edit` undo of the store method, not `git checkout`, so
|
||||
the rest of the change — imports, the other store, the UI, the specs — stayed in place):
|
||||
|
||||
- `applications.store.ts`: reverted `cancel` to `try { await this.adapter.cancel(id); } catch
|
||||
{ this.state.set(before); }`. Reran `ng test ssp --include applications.store.spec.ts`:
|
||||
2 of 4 failed —
|
||||
`rolls back the removal and surfaces the error when the cancel fails` and
|
||||
`clears a stale error on the next cancel attempt`, both with
|
||||
`AssertionError: expected null to be 'Het indienen is niet gelukt. Probeer het later opnieuw.'`.
|
||||
The other two specs (load, optimistic-cancel-success) stayed green, as expected — they
|
||||
don't touch the error path. Re-applied the fix (`Edit` back to the `runSubmit` version);
|
||||
reran: 4/4 green.
|
||||
- `admin-cases.store.ts`: same procedure on `delete`. Reran
|
||||
`ng test ssp --include admin-cases.store.spec.ts`: 2 of 4 failed with the identical
|
||||
`expected null to be '...'` shape. Reverted to the fix; reran: 4/4 green.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Signal naming**: private backing field `error`, public readonly `lastError` — matching
|
||||
the name `BriefStore`/`OrgTemplateStore` already expose for exactly this purpose (CQ-002's
|
||||
own citation), rather than inventing a new name per store.
|
||||
- **Error cleared at the start of each write**, not only on success, so a second cancel/delete
|
||||
attempt after a failure doesn't leave a stale banner up if the retry itself is still in
|
||||
flight. Covered by the "clears a stale error on the next attempt" spec in each file.
|
||||
- **No `ActionState`/`SaveState` pair** (the fuller shape `BriefStore` uses for busy-state and
|
||||
save-state together) — CQ-002 explicitly scoped option (a) to "one `error` signal", and
|
||||
neither store needs a busy indicator: the row already disappears optimistically the instant
|
||||
the click happens, so there is nothing for a spinner to cover.
|
||||
- **UI placement**: one alert per page, above the list the mutated row belongs to, using the
|
||||
same `@if (x(); as err) { <app-alert type="error">{{ err }}</app-alert> }` shape as
|
||||
`brief.page.ts` — composition of an existing atom, no new building block (CLAUDE.md §2).
|
||||
- **`applications.adapter.ts` left untouched, on purpose** — CQ-002's "Not filed" note ties
|
||||
the read/write file split to option (b) only; taking option (a) means this ticket changes
|
||||
no adapter code at all, matching the ticket's own framing ("(a) touches 2 files plus a UI
|
||||
line each").
|
||||
|
||||
## Ticket accuracy
|
||||
|
||||
CQ-002's description matched the code as found: both stores' `cancel`/`delete` reached the
|
||||
adapter directly with a bare `catch { this.state.set(before); }`, no `Result`, no error
|
||||
channel — no discrepancy to flag.
|
||||
|
||||
## Residuals (not this ticket)
|
||||
|
||||
- RB-18 (key `IdempotencyStore` on `{SubjectId}:{idemKey}`) is unaffected: `cancel`/`delete`
|
||||
now mint a key through `runSubmit` like every other mutation, so it lands on the same
|
||||
write-only call set RB-18 already targets.
|
||||
- RB-21 (extract `createDraftSync`'s read half) is a separate CQRS-light finding in the same
|
||||
context, untouched by this ticket.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci` (foreground, `timeout: 600000`): **green** — `✔ local CI passed`. Lint,
|
||||
typecheck, `dep:check` (342 + 226 modules, 0 violations), `format:check`, `check:tokens`,
|
||||
`check:seam`, tests (ssp 263/263 — 5 more than the pre-RB-20 258, from the new/extended
|
||||
specs above — behandelportal 37/37, shared 138/138, beheer 23/23), `ng build --localize`
|
||||
(both apps), `npm audit` (0 vulnerabilities), backend `dotnet format --verify-no-changes` +
|
||||
`dotnet test --filter "Category!=Integration"` (260/260 — this filter is what keeps the
|
||||
known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent test, which needs a live
|
||||
OpenZaak container, out of `npm run ci` entirely; it is a standing caveat, not introduced by
|
||||
this change, and not exercised by this run), backend dependency audit (0 vulnerable
|
||||
packages), `gen:snippets` / `gen:behaviour-spec` / `gen:api` drift checks all clean once the
|
||||
regenerated `behaviour-spec.mdx` was staged alongside the code (the local gate's
|
||||
`git diff --exit-code` compares the working tree to the index, so it is clean once the file
|
||||
is staged — this is the documented pre-commit behaviour from RB-17's note, not a defect).
|
||||
@@ -0,0 +1,119 @@
|
||||
# RB-21 — extract the read half of `createDraftSync` into `find-concept.ts`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-001 ·
|
||||
`00-baseline.md` §4a (`createDraftSync` 143 lines, the largest function in the repo), §9
|
||||
(`fn > 40` threshold) · `99-backlog.md` RB-21
|
||||
|
||||
## What was wrong
|
||||
|
||||
`createDraftSync` (`apps/ssp/src/app/registratie/application/draft-sync.ts`) was registered
|
||||
as a command factory but owned three read paths (`load`, `findConcept`, and the read half of
|
||||
`resume`) mixed into the same function as the write path (`ensureId`, `flush`, `submit`,
|
||||
`reset`). CQ-001 named three pieces of shared mutable closure state — `id`, `ensuring`,
|
||||
`resumeGate` — as load-bearing: `resumeGate` exists only so the write path (`ensureId`) can
|
||||
wait for the read path (`resume`) to finish. That coupling is genuine and stays in place.
|
||||
|
||||
## What changed
|
||||
|
||||
CQ-001's proposal, applied as a pure move, no redesign.
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/ssp/src/app/registratie/application/find-concept.ts` (new) | `findConcept(adapter, type)` and `loadConcept(adapter, id)` — free functions taking `ApplicationsAdapter`, no `inject()`. `loadConcept` returns a `LoadedConcept` union (`{tag:'concept', draft}` \| `{tag:'not-concept'}`) instead of the boolean-shaped branching the inline version had. |
|
||||
| `apps/ssp/src/app/registratie/application/find-concept.spec.ts` (new) | Direct spec, no TestBed — a fake `ApplicationsAdapter` object passed straight to the functions. |
|
||||
| `apps/ssp/src/app/registratie/application/draft-sync.ts` | Removed the inline `findConcept` closure and the body of `load`; both now call the free functions. `createDraftSync` keeps `id`, `ensuring`, `resumeGate`, and the whole write path, unchanged. |
|
||||
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `find-concept.spec.ts` describe blocks. |
|
||||
|
||||
`createDraftSync` shrank from 187 lines (`export function createDraftSync` to its closing
|
||||
brace, HEAD~1) to 169 lines. The whole file went from 236 to 216 lines.
|
||||
|
||||
The two call sites that used the old inline `findConcept()` now pass the adapter and type
|
||||
explicitly:
|
||||
|
||||
```ts
|
||||
// ensureId's 409-recovery catch (WP-35)
|
||||
const existing = await findConcept(adapter, deps.type);
|
||||
```
|
||||
|
||||
```ts
|
||||
// resume(), no ?aanvraag in the URL
|
||||
const existing = await findConcept(adapter, deps.type);
|
||||
```
|
||||
|
||||
`load` keeps setting the closure `id` and calling `applyResume` (both closure-dependent), but
|
||||
delegates the actual read to `loadConcept`:
|
||||
|
||||
```ts
|
||||
const load = (linked: string): Promise<void> => {
|
||||
id = linked;
|
||||
return loadConcept(adapter, linked).then((result) => {
|
||||
if (result.tag === 'not-concept') {
|
||||
id = undefined;
|
||||
applyResume(null);
|
||||
return;
|
||||
}
|
||||
applyResume(result.draft);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## `draft-sync.spec.ts` — unchanged
|
||||
|
||||
`draft-sync.spec.ts` was not edited. It never called `resume()`/`load()` directly — its
|
||||
coverage is the debounce, `submit()` (including the 409-recovery path, which exercises the
|
||||
extracted `findConcept` indirectly through `ensureId`'s catch), and `flushPending`. All of
|
||||
that stayed in `createDraftSync`, so the spec is unchanged and still exercises the wiring
|
||||
between `createDraftSync` and the two new free functions (the 409-recovery test would fail if
|
||||
that wiring were wrong). It passed unchanged, 8/8.
|
||||
|
||||
## The new spec, and its verified red
|
||||
|
||||
`find-concept.spec.ts` covers the branches CQ-001 named:
|
||||
|
||||
- `findConcept`: match found → id returned; no match of that type → `undefined`; match found
|
||||
but not `Concept` status → `undefined`; `adapter.list()` resolves to an unparsable shape
|
||||
(`parseApplications` fails) → `undefined`; `adapter.list()` rejects → `undefined`.
|
||||
- `loadConcept`: `Concept` with a draft → `{tag:'concept', draft}`; `Concept` with no draft →
|
||||
`{tag:'concept', draft:null}`; a non-`Concept` status (e.g. `Ingediend`, submitted) →
|
||||
`{tag:'not-concept'}`; `adapter.detail()` rejects (unknown/deleted id) →
|
||||
`{tag:'not-concept'}`.
|
||||
|
||||
**Verified red without the fix.** Used `Edit` (not `git checkout`) to invert one condition in
|
||||
`loadConcept` — `dto.status.tag !== 'Concept'` → `dto.status.tag === 'Concept'` — reran `ng
|
||||
test ssp --include find-concept.spec.ts`. Result: 3 of 9 tests failed —
|
||||
|
||||
```
|
||||
loadConcept > reads the draft off a Concept
|
||||
AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: { step: 1 } }
|
||||
loadConcept > reports a missing draft as null
|
||||
AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: null }
|
||||
loadConcept > reports not-concept when the id has moved past Concept (submitted)
|
||||
AssertionError: expected { tag: 'concept', draft: null } to deeply equal { tag: 'not-concept' }
|
||||
```
|
||||
|
||||
Then used `Edit` again to flip the condition back to `!==`, reran the same command: 9/9
|
||||
green. `findConcept`'s and `loadConcept`'s other branches were not separately mutated — the
|
||||
inverted condition alone was enough to prove the spec is sensitive to the extraction being
|
||||
correct, and re-verifying full green after the revert confirmed no collateral change was left
|
||||
in the file.
|
||||
|
||||
## Scope held
|
||||
|
||||
- No restructuring of the write path (`ensureId`, `flush`, `submit`, `reset`) — untouched
|
||||
beyond the two call-site updates shown above.
|
||||
- `applications.adapter.ts` was not split (CQ-002's "Not filed" note rules that out for this
|
||||
design; out of scope here regardless).
|
||||
- `resume()`'s semantics (URL-param precedence, the `resumeGate` release-in-`finally`, the
|
||||
navigate-to-stamp-the-id side effect) are unchanged — only its two `findConcept()`/`load()`
|
||||
calls now go through the free functions.
|
||||
- No wire change, no DTO change, no behaviour change.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci` (foreground): **green** — lint, typecheck, `dep:check`, `format:check`,
|
||||
`check:tokens`, `check:seam`, tests (ssp includes `find-concept.spec.ts` 9/9 new,
|
||||
`draft-sync.spec.ts` 8/8 unchanged), `ng build --localize` (both apps), `npm audit`, backend
|
||||
`dotnet test` (the known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure
|
||||
is expected and outside `npm run ci`'s scope), `gen:snippets` drift clean, `gen:behaviour-spec`
|
||||
drift clean once the regenerated file is committed alongside the code. Full counts are in the
|
||||
commit's `npm run ci` run — see the session note for the exact step-by-step output.
|
||||
@@ -0,0 +1,139 @@
|
||||
# RB-22 — `BriefStore.load()` tolerates a 404, calling `reset()` exactly once
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 ·
|
||||
`99-backlog.md` RB-22, "Tickets that were rejected and split" · `implementation/rb-17.md`
|
||||
(the `runResult`/`runSubmit` seam this store already sits on)
|
||||
|
||||
This is the **expand** half of an expand/contract pair. RB-23 (backend: `GET /brief` 404s
|
||||
when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`) ships after this
|
||||
ticket, in a later merge. Today's backend never 404s `GET /brief`, so this ticket's new
|
||||
branch is dead code in the running app — provably backend-frontend-safe by construction.
|
||||
|
||||
## What was wrong
|
||||
|
||||
CQ-007 flags `GET /brief` (`Program.cs:603` → `BriefStore.GetOrCreate`,
|
||||
`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a
|
||||
persisted write, breaking the read/write split every other endpoint respects. The fix is
|
||||
split across both sides of the seam because the FE must be ready to receive a 404 before
|
||||
the backend can safely start sending one. This ticket is the FE half: `BriefStore.load()`
|
||||
(`apps/ssp/src/app/brief/application/brief.store.ts`) had no notion of "no brief exists
|
||||
yet" — every adapter failure, 404 included, dispatched `BriefLoadFailed` and showed the
|
||||
generic error banner. `BriefAdapter.load()` (`brief.adapter.ts`) also had no way to tell
|
||||
the store a failure was specifically an HTTP 404: it folded every failure through the
|
||||
shared `runResult` helper (RB-17), which keeps only a human-readable string and throws
|
||||
away the HTTP status.
|
||||
|
||||
The ticket read as filed against the current code: `BriefStore.load()` is exactly where
|
||||
CQ-007 says it is, `BriefAdapter.load()` is exactly the read `runResult` call RB-17 pointed
|
||||
at it, and nothing about either file was factually wrong. Nothing to flag here.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()`'s error channel becomes `BriefLoadFailure` (`{tag:'notFound'} \| {tag:'error', reason:string}`) instead of a plain `string`. `load()` no longer routes through the shared `runResult` — it does its own try/catch so it can read the thrown value's HTTP `status` before folding it away, via the new local `isHttpNotFound` predicate. Every other method (`save`/`submit`/`approve`/`reject`/`send`/`reset`) is untouched, still on `runSubmit`. |
|
||||
| `apps/ssp/src/app/brief/application/brief.store.ts` | `load()` branches on `BriefLoadFailure`: `notFound` (and not already recovered) calls the existing `reset()` command directly and applies its result; every other failure (including a repeated `notFound`) dispatches `BriefLoadFailed` as before. Extracted `applyLoadedView` (the success-path body shared by `load()` and the new recovery path) and added `recoverFromMissingBrief`. |
|
||||
| `apps/ssp/src/app/brief/application/brief.store.spec.ts` | New `describe('BriefStore.load — 404 tolerance (RB-22)')` with the two required cases. Six pre-existing `load:` fakes' explicit `Result<string, BriefView>` return-type annotations updated to `Result<BriefLoadFailure, BriefView>` (they only ever produce the `ok: true` branch, so this is a type-only change); two shared `ok(v)` test helpers that build fakes for both `load` and `save` had their return-type annotation dropped in favour of `as const` inference, since one helper now serves two different error-channel types. |
|
||||
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the two new `it()` titles. |
|
||||
|
||||
No `backend/` file was touched — `Program.cs` and `Data/BriefStore.cs` are RB-23's, per the
|
||||
ticket's explicit scope.
|
||||
|
||||
## How the once-only bound is structural
|
||||
|
||||
`BriefStore` gains one field: `private hasRecoveredFromMissingBrief = false`. `load()`
|
||||
takes the recovery branch only when `r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief`,
|
||||
and the branch's first statement sets the flag before doing anything else. A second 404 —
|
||||
whether from a second `load()` call, or in principle from `reset()` itself somehow also
|
||||
404ing — falls through to the plain `BriefLoadFailed` branch instead, on every subsequent
|
||||
call, for the life of the store instance. This is a field on the singleton store, not a
|
||||
comment: nothing in the reachable call graph can flip it back to `false`.
|
||||
|
||||
The loop CQ-007's proposed change warns about ("the reset's own load must not be able to
|
||||
loop") is not merely bounded, it is **structurally absent**: `recoverFromMissingBrief`
|
||||
calls `this.adapter.reset()` and applies its `BriefView` result directly (the same
|
||||
`applyLoadedView` the success path uses) — it never calls `this.load()` again. There is no
|
||||
recursive edge from the recovery path back into `load()` for the once-only flag to have to
|
||||
stop; the flag exists only to stop a **second, separate** `load()` invocation (e.g. a
|
||||
caller retrying navigation) from reaching `reset()` again.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **`load()` no longer uses `runResult`, only for this one method.** `runResult`
|
||||
(`libs/shared/src/application/submit.ts`, RB-17) intentionally keeps only a string —
|
||||
every other read in the app is fine with that. This is the first read that needs one
|
||||
more bit (the HTTP status) than `runResult` exposes, so `load()` does its own
|
||||
try/catch instead, matching `runResult`'s shape (`problemDetail(e, fallback)` on the
|
||||
non-404 path) but adding the 404 branch first. `libs/shared/src/application/submit.ts`
|
||||
itself is untouched — changing a shared helper used by many call sites for one adapter's
|
||||
need was out of scope and unjustified.
|
||||
- **404 detection reads `(e as {status?:unknown}).status === 404`, not
|
||||
`SwaggerException.isSwaggerException`.** The generated client throws a plain
|
||||
`SwaggerException` for `GET /brief` today (no OpenAPI 404 response is declared for it
|
||||
yet), but throws the parsed `ProblemDetails` object instead for an endpoint whose spec
|
||||
**does** declare a 404 (both shapes carry a `status` field). Checking `status` alone,
|
||||
not the `SwaggerException` type, means this predicate keeps working unchanged once
|
||||
RB-23 regenerates the client with a documented 404 response for `briefGET()` — no
|
||||
follow-up FE edit needed for detection to keep working.
|
||||
- **`BriefLoadFailure` is a new exported type, not a sentinel string.** CLAUDE.md's
|
||||
default reflex is a discriminated union over a second flag; a magic string
|
||||
(`'__not_found__'`) compared by identity would have kept `load()`'s signature at
|
||||
`Result<string, BriefView>` and touched fewer test lines, but it is exactly the kind
|
||||
of stringly-typed control flow the union tool exists to avoid. The touched-test cost
|
||||
was six type annotations plus two helper signatures, all in the one already-scoped
|
||||
spec file — judged worth it for the correct shape.
|
||||
`BriefLoadFailure` is exported.
|
||||
- **On a repeated 404, the store shows `BRIEF_LOAD_FAILED`** (the same generic banner
|
||||
text `load()` already used for every other failure), not a distinct "still missing"
|
||||
message. No new user-facing copy was needed or added, so no new `$localize` id and no
|
||||
`messages.en.xlf` change — confirmed by diffing for `$localize` occurrences: both
|
||||
hits in the diff are unchanged context lines, not new additions.
|
||||
- **`resetDemo()` (the "start over" button) was left untouched**, even though it
|
||||
duplicates part of the same apply-a-fresh-view logic now factored into
|
||||
`applyLoadedView`. It also manages `actionState`/`saveState`/`rejectionSnapshot` that
|
||||
`recoverFromMissingBrief` correctly does not touch (an automatic recovery on first
|
||||
load is not a user-initiated "start over" action), and refactoring it was not asked
|
||||
for by this ticket.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Verified red without the fix.** Temporarily replaced `load()`'s body (via `Edit`,
|
||||
not `git checkout`) with the pre-fix shape — every failure, `notFound` included,
|
||||
dispatches `BriefLoadFailed` straight away, no `reset()` call — and reran the spec
|
||||
file. Both new tests failed:
|
||||
`expected "vi.fn()" to be called 1 times, but got 0 times` on `reset`, for both "a 404
|
||||
drives exactly one reset()" and "a second 404 does not drive a second reset()"; the
|
||||
other 18 tests in the file stayed green. Restored the real fix with a second `Edit`
|
||||
and reran: all 20 tests in the file green, 30/30 across both touched spec files.
|
||||
- `npm run ci` (foreground, no background/Monitor): **green**, exit 0 — lint,
|
||||
typecheck, `dep:check` (341 + 226 modules, 0 violations), `format:check`,
|
||||
`check:tokens`, `check:seam`, tests (ssp 260/260, behandelportal 37/37, shared
|
||||
138/138, beheer 23/23 — 458 total), `ng build --localize` (both apps), `npm audit`
|
||||
(0 vulnerabilities), backend `dotnet test` (260/260 — the known
|
||||
`OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not
|
||||
reproduce on this run, matching the standing caveat that it needs a live OpenZaak
|
||||
container and is not this ticket's bug), backend dependency audit clean, `gen:snippets`
|
||||
drift clean, `gen:behaviour-spec` drift clean once the regenerated file was staged (the
|
||||
local gate diffs the working tree against the index, so it necessarily shows a diff
|
||||
before the file is staged/committed — the same documented, expected behaviour RB-17
|
||||
recorded, not a defect).
|
||||
- `npx prettier --check` on every touched file (including the reformatted
|
||||
`99-backlog.md` table and the regenerated `behaviour-spec.mdx`): clean.
|
||||
|
||||
## What RB-23 must do
|
||||
|
||||
Once `GET /brief` in `Program.cs` returns a real 404 (no `ProblemDetails` body is
|
||||
required — `BriefAdapter.load()`'s `isHttpNotFound` only reads the HTTP `status`, not the
|
||||
response body), and `BriefStore.GetOrCreate` splits into `Get` (query) + the existing
|
||||
`ResetAndCreate` (already there, already used by `POST /brief/reset`), this ticket's
|
||||
`notFound` branch stops being dead code and starts running on first-ever page load for any
|
||||
owner with no persisted brief. Run `npm run gen:api` as part of RB-23 so `briefGET()`
|
||||
regenerates with a documented `status === 404` branch throwing the parsed `ProblemDetails`
|
||||
(matching the shape most other endpoints already use) — the detection predicate here
|
||||
already tolerates that shape and needs no FE follow-up change. Two things worth
|
||||
re-verifying once RB-23 lands, not fixing preemptively here: first, that the resulting
|
||||
double round-trip (404, then `reset()`) is an acceptable UX cost on a first visit, per
|
||||
CQ-007's own framing of this as its least certain finding; second, that this store's
|
||||
`hasRecoveredFromMissingBrief` field — private to one store instance, reset only by a full
|
||||
reload — is still the right lifetime for the once-only guard once a real 404 can occur in
|
||||
production traffic, not only in a test's fake adapter.
|
||||
@@ -0,0 +1,183 @@
|
||||
# RB-23 — `GET /brief` 404s when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 ·
|
||||
`99-backlog.md` RB-23, "Tickets that were rejected and split" · `implementation/rb-22.md`
|
||||
(the FE **expand** half this ticket **contracts** against)
|
||||
|
||||
This is the **contract** half of the RB-22/RB-23 expand/contract pair. RB-22 shipped first
|
||||
and made `BriefStore.load()` tolerate a 404 by calling `reset()` once, as a no-op against
|
||||
the (then) still-seeding backend. This ticket is what makes that branch live: `GET /brief`
|
||||
now 404s when the owner has no brief yet, and the endpoint no longer performs a persisted
|
||||
write on a read.
|
||||
|
||||
## What was wrong
|
||||
|
||||
CQ-007 flagged `GET /brief` (`Program.cs:676` → `BriefStore.GetOrCreate`,
|
||||
`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a
|
||||
persisted write, breaking the read/write split every other endpoint respects. The FE
|
||||
retries GETs automatically (`api-client.provider.ts`, `retry({ count: 2, delay: 500 })`,
|
||||
GET-only, precisely because GETs are assumed safe), so a transient failure could enter the
|
||||
create path more than once; `GetOrCreate`'s `lock` prevented a duplicate row today, but the
|
||||
safety depended on the lock rather than on the endpoint being a query.
|
||||
|
||||
The ticket read as filed against the current code: `GetOrCreate` was exactly at
|
||||
`BriefStore.cs:50`, `GET /brief` called it exactly as described, and `ResetAndCreate`
|
||||
already existed and was already the sole body of `POST /brief/reset`. One thing the
|
||||
ticket's own text did not mention: `BriefStore.GetOrCreate` had a **second** call site,
|
||||
`GET /brief/preview` (`Program.cs:769`, excluded from the OpenAPI doc — a hand-written FE
|
||||
`fetch`, same seam as uploads). Splitting `GetOrCreate` away necessarily touches that
|
||||
call site too, or the file does not compile. See "What changed" below — this was a forced
|
||||
consequence of the split, not a new business decision, and it is reported here rather than
|
||||
silently worked around.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `GetOrCreate` removed. New `Get(string owner): BriefEntity?` — pure query, `lock`-guarded like every other method in this file for consistency, no write. `ResetAndCreate` is untouched. |
|
||||
| `backend/src/BigRegister.Api/Program.cs` | `GET /brief`: calls `BriefStore.Get`; returns `Results.NotFound()` when null, `Results.Ok(ToView(ctx, e))` otherwise; declares `.Produces(StatusCodes.Status404NotFound)` (the same bare-404 pattern already used at 17 other call sites in this file). `GET /brief/preview`: same `Get` + 404 treatment — forced by the split (see above), not a scope decision made independently. |
|
||||
| `backend/src/BigRegister.Api/Data/AppDbContext.cs` | One comment updated (`GetOrCreate's invariant` → `ResetAndCreate's invariant`) — the unique index on `Owner` it annotates is unchanged. |
|
||||
| `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` | New `Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner` (the DoD-required test). The `Get()` seeding helper, used by nearly every other test in the file, renamed to `SeedBrief()` and changed to create the brief explicitly via `POST /brief/reset` instead of relying on `GET /brief`'s old side effect. One test renamed (`Get_creates_a_draft_with_expected_sections_locked_and_empty` → `SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty`) — it asserts on the shape of a freshly created brief, which is now `SeedBrief()`'s job, not `GET`'s. |
|
||||
| `backend/tests/BigRegister.Tests/PreviewEndpointTests.cs` | Two tests explicitly create the brief (`POST /brief/reset`) before hitting `/brief/preview`, instead of relying on the old `GET /brief` implicit create. |
|
||||
| `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` | Five call sites (three bare seeding `GetAsync` calls, two `GetFromJsonAsync<BriefViewDto>` calls used as seeding) changed to an explicit `POST /brief/reset` first. One call site (`Sent_brief_keeps_its_pinned_template_after_a_republish`, reading a brief already created and sent by the shared `WalkBriefToSentThenRepublish` helper) needed no change — a brief already exists by the time it runs. |
|
||||
| `backend/tests/BigRegister.Tests/RouteInventoryTests.cs` | Two `AllowList` reason strings updated (`GetOrCreate` → `Get`, 404 noted) — documentation text only, not itself a check the test enforces beyond "some reason is on record". |
|
||||
| `e2e/brief-v2.spec.ts` | One header comment updated to name the current methods and to state explicitly that this spec's own first click ("Opnieuw beginnen (demo)") is fixture setup, not a workaround for the new 404 — see "e2e and seeding paths" below. |
|
||||
| `libs/shared/src/infrastructure/api-client.ts` | Regenerated (`npm run gen:api`). `briefGET()` gains a `status === 404` branch. See "The generated client" below for the shape it actually took. |
|
||||
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-23's status cell: `open` → `implemented`. |
|
||||
|
||||
No `apps/ssp/src/app/brief/**` file was touched — RB-22's `BriefStore.load()` recovery and
|
||||
`BriefAdapter.load()`'s `BriefLoadFailure`/`isHttpNotFound` are unchanged, per this
|
||||
ticket's explicit scope.
|
||||
|
||||
## The generated client
|
||||
|
||||
RB-22's handoff note predicted `briefGET()` would regenerate "throwing the parsed
|
||||
`ProblemDetails` (matching the shape most other endpoints already use)". That did not
|
||||
happen, and the actual result is still correct. `Results.NotFound()` (this ticket's
|
||||
implementation, and the pattern used at every one of the 17 other bare-404 call sites in
|
||||
`Program.cs` — none of them use `ProducesProblem`/a typed body) declares a 404 with **no**
|
||||
response body schema. With nothing to parse into, NSwag emits a generic branch that throws
|
||||
a plain `SwaggerException` carrying `status: 404` — the same shape `briefGET()` already
|
||||
threw before this ticket, for the same reason (no declared 404 body). `BriefAdapter.load()`'s
|
||||
`isHttpNotFound` predicate (`(e as {status?:unknown}).status === 404`) already tolerates
|
||||
both a `SwaggerException` and a parsed `ProblemDetails`, by design, precisely so this
|
||||
detail would not matter — RB-22's own comment says as much. No FE follow-up was needed, and
|
||||
none was made.
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **`GET /brief/preview` also moved off `GetOrCreate`, to `Get` + 404.** Not mentioned in
|
||||
the ticket text, but unavoidable: `GetOrCreate` no longer exists once split, and this
|
||||
was its only other caller. The alternative — leaving a private, undocumented
|
||||
`GetOrCreate`-shaped helper only for this one endpoint — would have reintroduced
|
||||
exactly the GET-writes-on-read pattern CQ-007 is about, in the one place nobody would
|
||||
think to look for it. Returning 404 there too keeps both `/brief` GETs behaving the
|
||||
same way. In the running app this is unreachable in practice: the preview button
|
||||
only renders inside the brief page's `@if (loaded(); as s)` block
|
||||
(`apps/ssp/src/app/brief/ui/brief.page.ts`), which by construction only shows once
|
||||
`BriefStore.load()` has already succeeded — including via RB-22's 404-recovery branch.
|
||||
So a brief always exists by the time a real user can trigger `/brief/preview`; the 404
|
||||
path there is a defensive consequence of the type split, not a new user-facing
|
||||
behaviour anyone will hit.
|
||||
- **`BriefStore.Get` keeps the `lock (_gate)` wrap**, even though a plain SQLite read
|
||||
does not strictly need the same mutual exclusion a write does. Every other method in
|
||||
this file, including the pre-existing `ApplicationStore.Get`-style query in the
|
||||
sibling store, locks unconditionally — matching that convention was judged more
|
||||
valuable than a lock-free read this ticket did not need to justify removing.
|
||||
- **Existing test changes create the brief via `POST /brief/reset`, not a new
|
||||
`BriefStore.Get`/`ResetAndCreate` direct call from the test.** Going through the HTTP
|
||||
endpoint (as the old `Get()` helper always did) keeps the tests exercising the real
|
||||
request pipeline (identity resolution, `ToView` mapping) rather than reaching around
|
||||
it — the same reasoning that already justified an `IClassFixture<TestWebApplicationFactory>`
|
||||
HTTP-level test suite in the first place.
|
||||
|
||||
## e2e and seeding paths
|
||||
|
||||
- **`e2e/brief-v2.spec.ts`** is the only e2e spec that reaches `/brief`. It already opens
|
||||
`/brief?role=drafter` and immediately clicks "Opnieuw beginnen (demo)" (`POST
|
||||
/brief/reset`) before asserting anything — a deliberate fixture reset, not a
|
||||
workaround. With this ticket live, the page's first `GET /brief` on the fresh
|
||||
per-run database (WP-74) now 404s; RB-22's `BriefStore.load()` recovers from that by
|
||||
calling `reset()` once, so the page still renders correctly, and the spec's own
|
||||
explicit reset click still runs on top of that (harmless — resetting an
|
||||
already-fresh brief). No behavioural change to the spec was needed; one comment was
|
||||
updated to say this explicitly rather than leave it to be re-derived.
|
||||
- **Storybook**: no `brief.page.stories.ts` exists, and none of the eleven `brief/ui/**`
|
||||
component stories call `HttpClient`/`fetch`/`ApiClient` — every story supplies data
|
||||
through component `input()`s, per the house convention (design-system/component
|
||||
stories are not live-network integration tests). Nothing in Storybook depended on
|
||||
`GET /brief`'s old seeding behaviour.
|
||||
|
||||
## The double round-trip — verdict
|
||||
|
||||
CQ-007 named this its least certain point: a first-ever visit to `/brief` now costs a 404
|
||||
followed by a `reset()` call, instead of one request that both creates and returns the
|
||||
brief. **Shipped as-is; the cost is acceptable.** Three reasons:
|
||||
|
||||
1. **It happens once per browser tab, ever, for one demo entity.** `BriefStore`'s
|
||||
`hasRecoveredFromMissingBrief` flag (RB-22) makes the 404 unreachable again for the
|
||||
life of the store instance; a real deployment has one brief per zorgverlener, created
|
||||
the first time that person ever opens the page. This is not a cost paid on every
|
||||
page load, or even every session — a page reload still 404s once if the flag reset
|
||||
with the page, but the underlying row is already there by then, so the _second_ call
|
||||
in the pair — `reset()` — is now hitting an existing row rather than truly
|
||||
first-creating one, and returns just as fast as `Get` would have.
|
||||
2. **An extra round-trip is not an extra spinner.** `BriefStore.load()`'s failure
|
||||
handling for `notFound` calls `reset()` and applies the result through the same
|
||||
`applyLoadedView` the success path uses — there is no intermediate "not found" UI
|
||||
state rendered to the user between the two calls; the page shows its loading state
|
||||
once, for the combined duration of both requests.
|
||||
3. **The alternative was rejected, not merely deprioritized.** CQ-007's own
|
||||
documentation-only alternative — leave `GetOrCreate` in place, just write down that
|
||||
the GET seeds on first call — was rejected outright by agent 07 in `99-backlog.md`:
|
||||
"a non-idempotent GET must be visible in the code, not only in a ticket." Given that,
|
||||
the only way to remove the mixing is some version of this two-call shape; a
|
||||
single-call alternative would mean either GET creates (the defect) or `POST
|
||||
/brief/reset` runs unconditionally on load (destructive — it deletes an existing
|
||||
brief, unacceptable for anyone with real content already saved).
|
||||
|
||||
## The once-only guard's lifetime — re-verified
|
||||
|
||||
RB-22 flagged this as worth re-checking once a real 404 could occur in production
|
||||
traffic, not only in a test's fake adapter. Having now made the 404 real: `hasRecoveredFromMissingBrief`
|
||||
is a private field on `BriefStore`, which is `providedIn: 'root'` — one instance per
|
||||
browser tab (per CLAUDE.md's "shared cross-page state = one root singleton" convention),
|
||||
reset only by a full page reload. That lifetime is still correct for what the flag
|
||||
guards: it exists to stop a _second, separate_ `load()` call in the same tab session from
|
||||
re-triggering `reset()` (e.g. a caller retrying navigation after the first recovery
|
||||
already ran) — not to remember "this owner has a brief" across reloads or across owners,
|
||||
which is the server's job (`BriefStore.Get` returning non-null). A page reload correctly
|
||||
starts the guard over: the first `load()` after a reload will find the now-existing row
|
||||
via a plain `GET` (no 404, no `reset()` call at all), so the flag never actually gets
|
||||
exercised a second time in the reload case either. No FE change was needed or made.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Verified red without the fix.** Temporarily (via `Edit`, never `git checkout`)
|
||||
restored `BriefStore.GetOrCreate` alongside the new `Get`, and pointed `GET /brief` in
|
||||
`Program.cs` back at `GetOrCreate`. Ran the new test alone:
|
||||
```
|
||||
BigRegister.Tests.BriefEndpointTests.Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner [FAIL]
|
||||
Assert.Equal() Failure: Values differ
|
||||
Expected: NotFound
|
||||
Actual: OK
|
||||
```
|
||||
Restored the real fix with a second `Edit` (removed the temporary `GetOrCreate`,
|
||||
pointed `GET /brief` back at `Get` + 404) and reran: green.
|
||||
- Full backend suite after the fix: **262/262 passing**, plus the one known,
|
||||
pre-existing, container-dependent failure
|
||||
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||
"Connection refused (localhost:8000)") — not this ticket's bug, does not run under
|
||||
`npm run ci`, reproduces on a clean tree with no OpenZaak container running.
|
||||
- `npm run gen:api`: the client changed (`libs/shared/src/infrastructure/api-client.ts`,
|
||||
`briefGET()` gains a `status === 404` branch — 4 lines). Regenerated and committed;
|
||||
see "The generated client" above for why the shape differs from RB-22's prediction and
|
||||
why that difference is harmless.
|
||||
- `npm run ci` (foreground, no background/Monitor): see result below.
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
`apps/ssp/src/app/brief/application/brief.store.ts`, `brief.store.spec.ts`, and
|
||||
`apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` are unchanged — RB-22's FE logic
|
||||
was already correct and already tested against exactly this contract, per this ticket's
|
||||
explicit scope.
|
||||
@@ -0,0 +1,178 @@
|
||||
# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 ·
|
||||
`99-backlog.md` RB-24, "Merges" table row for RB-25/26/27
|
||||
|
||||
## What was wrong
|
||||
|
||||
`libs/shared/src/upload/` held five files outside the folder-per-layer convention every
|
||||
other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw
|
||||
`XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`.
|
||||
`upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/`
|
||||
folder. The exception was hard-coded into the enforcement itself:
|
||||
`.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read
|
||||
`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the
|
||||
violation instead of the violation being fixed, which is why the baseline scan reported 0
|
||||
violations despite this.
|
||||
|
||||
## What changed
|
||||
|
||||
| From `libs/shared/src/upload/` | To |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` |
|
||||
| `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) |
|
||||
| `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` |
|
||||
| `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` |
|
||||
|
||||
All five moves used `git mv`. `libs/shared/src/upload/` no longer exists.
|
||||
|
||||
**Import updates.** 24 consumer files import from `@shared/upload/*` (found with
|
||||
`grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated
|
||||
`@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths
|
||||
were rewritten to the new locations (30 import statements total, some files import more
|
||||
than one symbol). No export was renamed, no file was split, no logic changed in any of
|
||||
these 24 files beyond the import path string.
|
||||
|
||||
**Within the five moved files**, three had relative imports (`./upload.adapter`,
|
||||
`./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases:
|
||||
`upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine` →
|
||||
`@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and
|
||||
`upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`;
|
||||
`upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it
|
||||
and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine`
|
||||
import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed
|
||||
pure rename.
|
||||
|
||||
**The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule:
|
||||
`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` → `from: { pathNot: '/infrastructure/' }`,
|
||||
comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further
|
||||
consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules"
|
||||
paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only
|
||||
inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since
|
||||
leaving it would document a rule that no longer exists.
|
||||
|
||||
## A second, real violation the move exposed — fixed, not just reported
|
||||
|
||||
Deleting the carve-out did not by itself make `dep:check` pass. A **separate,
|
||||
pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import
|
||||
`infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because
|
||||
before this move the file's path did not contain `/infrastructure/` at all. Three UI
|
||||
components were injecting `UploadAdapter` directly:
|
||||
`apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`,
|
||||
`apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`,
|
||||
and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically
|
||||
moved into `infrastructure/`, `dep:check` correctly flagged all three:
|
||||
|
||||
```
|
||||
error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts
|
||||
error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts
|
||||
error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts
|
||||
```
|
||||
|
||||
This is judged in-scope to fix, not a second unrelated finding to merely report, for three
|
||||
reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the
|
||||
deletion, the move is incomplete, so fix the move rather than restoring the clause."
|
||||
Second, all three call sites used `UploadAdapter` for exactly one thing —
|
||||
`.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported,
|
||||
injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no
|
||||
injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into
|
||||
its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts`
|
||||
already used that pure function directly; the three UI files had independently reinvented
|
||||
`inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays
|
||||
inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27
|
||||
target:
|
||||
|
||||
- `libs/shared/src/application/upload-controller.ts` — the object `createUploadController`
|
||||
returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure
|
||||
`uploadContentUrl`. Both wizard components already hold a `createUploadController`
|
||||
instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now
|
||||
delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself.
|
||||
- `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects
|
||||
`UploadAdapter` legitimately — it's application layer) gained one more computed-style
|
||||
field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now
|
||||
reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`.
|
||||
|
||||
No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the
|
||||
identical string (the method is a one-line pass-through to the function), and the
|
||||
`demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method
|
||||
verbatim.
|
||||
|
||||
## Verification
|
||||
|
||||
- **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as
|
||||
a 0-insertion/0-deletion pure rename — no content changed, including its own imports
|
||||
(both files moved into `domain/` together, so its `./upload.machine` relative import
|
||||
needed no edit). No spec content changed anywhere in this ticket.
|
||||
- `npm run dep:check`: **passes for both apps** with the carve-out clause removed —
|
||||
`✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp),
|
||||
`✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal).
|
||||
- `npm run lint`: clean.
|
||||
- `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing**
|
||||
(ssp / behandelportal / shared / beheer).
|
||||
- `npm run build`: both apps build (pre-existing, unrelated warnings about
|
||||
`/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time —
|
||||
present before this ticket, vendored assets resolved at serve/deploy time, not a
|
||||
regression from this move).
|
||||
- **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`):
|
||||
the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100%
|
||||
functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002
|
||||
predicted landing in a folder the baseline reported at "0% spec reach across 3 files"
|
||||
(`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which
|
||||
remain unspecced — that gap is pre-existing and out of this ticket's scope).
|
||||
|
||||
## Non-TypeScript references to the old path — findings
|
||||
|
||||
Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config
|
||||
file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js`
|
||||
files, and `libs/shared/docs/*.mdx`.
|
||||
|
||||
- **Storybook config, angular.json, dependency-cruiser app configs**: no reference to
|
||||
`shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change.
|
||||
- **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself,
|
||||
deleted (see above).
|
||||
- **`docs/reference/architecture/dependencies.md`**: one prose reference to the same
|
||||
carve-out, corrected in this diff (see above) since it directly describes the rule this
|
||||
ticket edits.
|
||||
- **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or
|
||||
`@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and
|
||||
`shared/ui/upload/...` by filename/short-path only, never the full old directory path —
|
||||
both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling
|
||||
folder).
|
||||
- **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**:
|
||||
each carries a handful of `<context context-type="sourcefile">src/app/shared/upload/upload.machine.ts</context>`
|
||||
/`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor,
|
||||
informational only (they tell a translator where a string originated; they are not
|
||||
read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is
|
||||
`npm run extract-i18n`'s job for the source-locale file and does not touch the
|
||||
hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the
|
||||
move plus import updates, not a translation-tooling refresh. They will self-correct
|
||||
the next time `extract-i18n` runs for an unrelated reason.
|
||||
- **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**:
|
||||
several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`,
|
||||
`06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`)
|
||||
reference the old path — expected, since most of them describe or cite the violation
|
||||
this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell
|
||||
(see below).
|
||||
|
||||
## What RB-25/26/27 now find where
|
||||
|
||||
- **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`):
|
||||
`KeepaliveTransport` and `UploadShellService` are both now in
|
||||
`libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The
|
||||
token belongs in `application/` alongside them — nothing about the token's shape or
|
||||
location changes because of this move.
|
||||
- **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now
|
||||
`libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected`
|
||||
callback — the accept/reject decision RB-26 targets — is in
|
||||
`libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise
|
||||
unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket —
|
||||
see above). RB-26 should extend `upload.machine.ts` in its new location; no import path
|
||||
in that file needs touching beyond what this ticket already did.
|
||||
- **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure
|
||||
is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same
|
||||
file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and
|
||||
`genericError` are all still exactly where they were, just under `infrastructure/`.
|
||||
|
||||
## `npm run ci`
|
||||
|
||||
Result and step count reported in the final answer.
|
||||
@@ -0,0 +1,130 @@
|
||||
# RB-25 — `UPLOAD_TRANSPORT` injection token replaces `inject(KeepaliveTransport)`
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-003 ·
|
||||
`99-backlog.md` RB-25, "Merges" table row for RB-25/26/27 · Depends on
|
||||
`implementation/rb-24.md` (the move that put this file at its current path)
|
||||
|
||||
## What was wrong
|
||||
|
||||
`libs/shared/src/application/upload-shell.service.ts` defines `export interface
|
||||
UploadTransport` and documents it as the swap seam for upload transport. It then binds
|
||||
`private transport: UploadTransport = inject(KeepaliveTransport)` — the concrete class,
|
||||
which is `@Injectable` but not exported. A spec cannot reference the class to override its
|
||||
provider, and cannot provide against the interface either, because an interface is not a
|
||||
DI token. The port existed on paper only.
|
||||
|
||||
The consequence TE-003 measures: `upload()`, `delete()`, `cancel()`, and `pollReturning()`
|
||||
— the methods that translate transport and adapter outcomes into `UploadMsg`s — had no
|
||||
spec at all. `libs/shared/upload` sat at 52.0% line / 50.0% branch, and
|
||||
`upload-shell.service.ts` was one of the two unreached non-`ui/` files.
|
||||
|
||||
## What changed
|
||||
|
||||
One file plus one new spec, exactly as scoped:
|
||||
|
||||
- `libs/shared/src/application/upload-shell.service.ts`: added
|
||||
|
||||
```ts
|
||||
export const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT', {
|
||||
providedIn: 'root',
|
||||
factory: () => inject(KeepaliveTransport),
|
||||
});
|
||||
```
|
||||
|
||||
copied verbatim from TE-003's own fix, placed directly under the `KeepaliveTransport`
|
||||
class it wraps. `UploadShellService.transport` now reads
|
||||
`inject(UPLOAD_TRANSPORT)` instead of `inject(KeepaliveTransport)`. This is the same
|
||||
interface-plus-token shape as `SessionPort`/`SESSION_PORT`
|
||||
(`libs/shared/src/application/session.port.ts`), the repo's one other explicit port.
|
||||
`KeepaliveTransport` itself is untouched: still a private, unexported `@Injectable`, and
|
||||
still the default factory's target — a real app gets the exact same singleton instance
|
||||
it always did.
|
||||
|
||||
- `libs/shared/src/application/upload-shell.service.spec.ts` (new): a recording fake
|
||||
`UploadTransport` (records every `send()` call, exposes `resolveDone`/`rejectDone` per
|
||||
call so a test drives the returned `Promise` by hand) provided against `UPLOAD_TRANSPORT`,
|
||||
plus a fake `UploadAdapter` (a plain object with `vi.fn()` for `status`/`deleteDocument`)
|
||||
provided against the already-exported `UploadAdapter` class. 16 specs across all four
|
||||
target methods:
|
||||
- `upload()` — `UploadQueued` carries the transport's `backgroundSyncAvailable`;
|
||||
`onProgress` → `UploadProgress`; a resolved transport → `UploadComplete`; a rejected
|
||||
transport → `UploadFailed` with the rejection reason; a rejection with the
|
||||
`UPLOAD_ABORTED` sentinel dispatches nothing.
|
||||
- `cancel()` — calls the stored cancel function for an in-flight upload and forgets it
|
||||
(a second `cancel()` on the same id is a no-op); an unknown id is a no-op.
|
||||
- `delete()` — `UploadDeleting` then `UploadDeleteComplete` on success;
|
||||
`UploadDeleteFailed` with the server's `detail` on a ProblemDetails rejection; falls
|
||||
back to an empty reason when the rejection carries no `detail`.
|
||||
- `pollReturning()` — skips the adapter call entirely for an empty upload list;
|
||||
dispatches `BackgroundUploadsReturned` filtered to only the items the server reports
|
||||
`complete` with a `documentId`; dispatches nothing when nothing has arrived.
|
||||
|
||||
No other file changed. `UploadAdapter`, `upload.machine.ts`, and `upload-controller.ts`
|
||||
are untouched, per the ticket's file-scope fence (RB-26 and RB-28 are concurrently in
|
||||
adjacent files).
|
||||
|
||||
## Verification
|
||||
|
||||
- **Coverage, `upload-shell.service.ts`** (`npm run test:coverage` narrowed to `shared`,
|
||||
read from `coverage/shared/lcov.info`):
|
||||
|
||||
| Metric | Before | After |
|
||||
| --------- | ------ | -------------- |
|
||||
| Lines | 0% | 88.57% (31/35) |
|
||||
| Branches | 0% | 85.00% (17/20) |
|
||||
| Functions | 0% | 87.50% (14/16) |
|
||||
|
||||
"Before" is 0% across the board: no spec file for this service existed prior to this
|
||||
ticket (confirmed by `grep -rln UploadShellService --include=*.spec.ts`, which returns
|
||||
only the new spec), matching TE-003's "unreached" classification. The remaining
|
||||
uncovered lines are the `KeepaliveTransport` class body (`send()`, its `inject`) and the
|
||||
`UPLOAD_TRANSPORT` factory closure itself — both require a real `XMLHttpRequest`/real DI
|
||||
resolution to exercise and are intentionally out of this ticket's scope: TE-003's fix is
|
||||
the seam, not a rewrite of the transport it wraps.
|
||||
|
||||
- **Red-proof.** Edited `upload()`'s success branch from
|
||||
`dispatch({ type: 'UploadComplete', localId: req.localId, documentId })` to
|
||||
`dispatch({ type: 'UploadFailed', localId: req.localId, reason: 'BROKEN-FOR-RED-PROOF' })`,
|
||||
ran `ng test shared`. Result: 1 failed / 150 passed, with
|
||||
|
||||
```
|
||||
AssertionError: expected "vi.fn()" to be called with arguments: [ { type: 'UploadComplete', …(2) } ]
|
||||
Received:
|
||||
1st vi.fn() call: [{ "backgroundSync": false, "localId": "l1", "type": "UploadQueued" }]
|
||||
2nd vi.fn() call: [{ "localId": "l1", "reason": "BROKEN-FOR-RED-PROOF", "type": "UploadFailed" }]
|
||||
```
|
||||
|
||||
at `upload-shell.service.spec.ts:79` (the `UploadComplete` assertion). Re-applied the
|
||||
original line with a second edit (not `git checkout`); `git diff` against HEAD shows
|
||||
only the intended token change — the red edit left no trace. Re-ran: 151/151 green.
|
||||
|
||||
- `npm run ci`: result and step count in the final answer.
|
||||
|
||||
## Judgement call
|
||||
|
||||
- **The fake `UploadAdapter` is a plain object, not a class extending `UploadAdapter`.**
|
||||
`UploadAdapter` is exported and already usable as a DI token (it always was — TE-003's
|
||||
gap was specific to `KeepaliveTransport`, not `UploadAdapter`), so `delete()` and
|
||||
`pollReturning()` (which never touch `this.transport`) were technically fakeable before
|
||||
this ticket by providing a fake `UploadAdapter`. Nobody had written that spec, though,
|
||||
and `upload()`/`cancel()` still needed `UPLOAD_TRANSPORT` regardless (they populate and
|
||||
drain the `inflight` map via `transport.send()`). The spec fakes both seams together so
|
||||
all four methods are exercised as one coherent suite, per the ticket's own framing
|
||||
("provide a recording fake transport and assert the message translation in `upload()`,
|
||||
`delete()`, `cancel()` and `pollReturning()`").
|
||||
|
||||
## Handoff to RB-27
|
||||
|
||||
RB-27 extracts `uploadOutcome(status, responseText)` out of the XHR closure in
|
||||
`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` — a different file,
|
||||
untouched by this ticket. The token makes RB-27's optional half (moving the
|
||||
`currentScenario()` branch into `KeepaliveTransport.send()`) no easier and no harder than
|
||||
before: `KeepaliveTransport` is still unexported and its `send()` body is unchanged, one
|
||||
line (`inject(UploadAdapter)`, `return this.adapter.xhrUpload(req, onProgress)`). If RB-27
|
||||
takes that optional move, it can inject `UPLOAD_TRANSPORT` in its own spec to assert the
|
||||
scenario branch without touching this file — the seam is there and provided-in-root, but
|
||||
RB-27 does not need to change anything here to use it.
|
||||
|
||||
## `npm run ci`
|
||||
|
||||
Result and step count reported in the final answer.
|
||||
@@ -0,0 +1,109 @@
|
||||
# RB-26 — move the accept/reject decision into `planFileSelection` (`upload.machine.ts`)
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-004 ·
|
||||
`99-backlog.md` RB-26 · Depends on `implementation/rb-24.md` (moved the upload files into
|
||||
`infrastructure`/`domain`/`application`)
|
||||
|
||||
## What was wrong
|
||||
|
||||
TE-004: `createUploadController` does three `inject()` calls, registers an `effect()`, and
|
||||
adds a `window` focus listener, all before it returns. A spec must run inside a `TestBed`
|
||||
injection context with `UploadAdapter`, `UploadShellService`, and `DestroyRef` all
|
||||
satisfied to reach anything inside it. What sits behind that cost is real policy:
|
||||
`onFileSelected` decides, per file, whether to reject it with reason `'multiple'`, reject
|
||||
it with a `rejectReason` result, or start its upload — a decision over
|
||||
`(categories, categoryId, files)` with no I/O in it. `rejectReason`, the predicate that
|
||||
decision calls, was already exported and spec'd; the decision that calls it was not.
|
||||
|
||||
## What changed
|
||||
|
||||
`libs/shared/src/domain/upload.machine.ts` gains one pure export:
|
||||
|
||||
```ts
|
||||
export function planFileSelection(
|
||||
state: UploadState,
|
||||
categoryId: string,
|
||||
files: { name: string; type: string; size: number }[],
|
||||
): UploadMsg[];
|
||||
```
|
||||
|
||||
It takes plain `{ name, type, size }` objects, not `File` — a spec needs no DOM. The body
|
||||
is the old `onFileSelected` decision, moved: an unknown category plans nothing; too many
|
||||
files for a single-file category plans one `FileRejected` with reason `'multiple'` and
|
||||
skips the per-file checks; otherwise each file is judged by `rejectReason` and plans
|
||||
either a `FileRejected` or a `FileSelected` entry, one entry per input file, in order.
|
||||
|
||||
`libs/shared/src/application/upload-controller.ts`'s `onFileSelected` now maps `selected:
|
||||
File[]` to plain candidates, calls `planFileSelection`, and executes the result: a
|
||||
`FileRejected` entry dispatches as-is; anything else starts the upload for the file at
|
||||
that same array index (`crypto.randomUUID()`, `files.set()`, `shell.upload()` — the three
|
||||
things that must stay impure and stay in the controller). No other method changed.
|
||||
`previewUrlFor` (added by RB-24) is untouched.
|
||||
|
||||
## The `localId` placeholder — a deliberate, contained choice
|
||||
|
||||
An accepted file's planned `FileSelected` entry carries `localId: ''`. A real id needs
|
||||
`crypto.randomUUID()`, and the ticket is explicit that call stays in the controller, not
|
||||
the domain. The controller reads only each entry's `.type` to route it — it dispatches a
|
||||
`FileRejected` entry verbatim, but for a `FileSelected` entry it discards the entry and
|
||||
calls `start(categoryId, selected[i])`, which builds its own message with a real id.
|
||||
The placeholder is therefore never dispatched. This was the only way found to keep the
|
||||
return type exactly `UploadMsg[]` (as the ticket's own code sketch specifies) while still
|
||||
letting the plan carry a per-file, order-preserving "start this one" signal — the
|
||||
`FileRejected` variant carries no file identity (state keys rejections by category only),
|
||||
so position in the returned array is what the controller uses to find the matching
|
||||
original `File`. A discriminated `{ kind: 'reject' | 'start'; msg? }` return would avoid
|
||||
the placeholder but was not built, since the ticket's signature is explicit and the
|
||||
placeholder design meets it without changing behaviour.
|
||||
|
||||
## Behaviour
|
||||
|
||||
Same messages, same order, for the same inputs. Tracing all three original branches:
|
||||
|
||||
- Unknown category: original returns without dispatching; new code calls `planFileSelection`
|
||||
(returns `[]`), then `forEach` over an empty array — no dispatch, no start.
|
||||
- Too many files for a single-file category: original dispatches one `FileRejected`
|
||||
('multiple') and returns; new code gets a one-entry plan and dispatches that one entry —
|
||||
`forEach` never reaches indices past the plan's length, so no file starts.
|
||||
- Per-file loop: original dispatches `FileRejected` or calls `start` for each file, in
|
||||
order; new code's plan has one entry per file, in the same order, and the controller
|
||||
dispatches or starts at each index identically.
|
||||
|
||||
## Testing
|
||||
|
||||
`libs/shared/src/domain/upload.machine.spec.ts` gained a `planFileSelection` describe
|
||||
block: unknown category (plans nothing), the `'multiple'` batch rejection, a passing
|
||||
single file against a single-file category, `rejectReason`'s two reject cases (`'type'`,
|
||||
`'size'`) reached through the plan, the accept case's exact `FileSelected` shape
|
||||
(including the `localId: ''` placeholder), and a mixed multiple-file case asserting
|
||||
order (`['FileSelected', 'FileRejected', 'FileSelected']`).
|
||||
|
||||
**Proved red before green**, per the ticket's instruction not to use `git checkout`:
|
||||
temporarily replaced the function body with a stub returning `[]` unconditionally (an
|
||||
edit, not a revert), ran `ng test shared`, and got:
|
||||
|
||||
```
|
||||
Test Files 1 failed | 23 passed (24)
|
||||
Tests 6 failed | 139 passed (145)
|
||||
```
|
||||
|
||||
The 6 failures were the `'multiple'` rejection, both `rejectReason` cases, the accepted-
|
||||
file shape, and the mixed-order case — every outcome that depends on the real branching,
|
||||
each failing with `expected [] to deeply equal [...]`. The unknown-category case passed
|
||||
even against the stub, since both the stub and the real implementation return `[]` there
|
||||
— expected, not a gap, since that branch has no policy to exercise. A second edit restored
|
||||
the real body; the same run returned to `24 passed / 145 passed`.
|
||||
|
||||
## Scope held
|
||||
|
||||
No change to `createUploadController`'s construction, the `effect()`, or the `window`
|
||||
listener — those are RB-25/RB-27's targets (RB-25 is `UploadShellService`, running
|
||||
concurrently in the same commit window; RB-27 is `upload.adapter.ts`'s XHR closure).
|
||||
Neither file was touched. The controller's public surface (`previewUrlFor`,
|
||||
`onFileSelected`, `onRemove`, `onRetry`, `onDelete`, `onChannelChange`) is unchanged in
|
||||
name and signature, and the organism that calls it (`<app-document-upload>`) needed no
|
||||
change.
|
||||
|
||||
## `npm run ci`
|
||||
|
||||
Result and step count reported in the closing message.
|
||||
@@ -0,0 +1,193 @@
|
||||
# RB-27 — `uploadOutcome` extracted from the XHR `load` closure
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-005 ·
|
||||
`99-backlog.md` RB-27, "Merges" table row for RB-25/26/27 · Depends on
|
||||
`implementation/rb-24.md` (the move that put this file at its current path) and
|
||||
`implementation/rb-25.md` (handoff paragraph read before deciding the optional half)
|
||||
|
||||
## What was wrong
|
||||
|
||||
`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` constructs
|
||||
`new XMLHttpRequest()` directly and attaches its `load` listener inline. The listener
|
||||
body held the actual decisions: 2xx-vs-not, `JSON.parse` of the response body with a
|
||||
fallback to a generic error, and (on a non-2xx status) ProblemDetails mapping via the
|
||||
un-exported `parseError`. None of it is reachable without stubbing the XHR global, so
|
||||
the interpretation logic had no spec.
|
||||
|
||||
TE-005's baseline citation: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57 (5.3% branch)**.
|
||||
The file was counted "reached" in the module total only because another spec imports
|
||||
it — essentially nothing in it executed.
|
||||
|
||||
## What changed
|
||||
|
||||
One function extracted from the `load` listener, in the same file:
|
||||
|
||||
```ts
|
||||
export function uploadOutcome(
|
||||
status: number,
|
||||
responseText: string,
|
||||
): Result<string, { documentId: string }> {
|
||||
if (status < 200 || status >= 300) return err(parseError(responseText));
|
||||
try {
|
||||
return ok({ documentId: JSON.parse(responseText).documentId });
|
||||
} catch {
|
||||
return err(genericError());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
placed next to `genericError`/`parseError` (below the class, above the dev
|
||||
`simulateUpload`). It contains exactly the 2xx-vs-not check, the `JSON.parse`-with-
|
||||
fallback, and the ProblemDetails mapping — the three decisions TE-005 names. The `load`
|
||||
listener is now a two-line dispatch:
|
||||
|
||||
```ts
|
||||
xhr.addEventListener('load', () => {
|
||||
const outcome = uploadOutcome(xhr.status, xhr.responseText);
|
||||
outcome.ok ? resolve(outcome.value) : reject(outcome.error);
|
||||
});
|
||||
```
|
||||
|
||||
`Result`, `ok`, `err` are imported from `@shared/kernel/fp` (the repo's one `Result`
|
||||
type, already used the same way by `libs/shared`'s other infrastructure adapters).
|
||||
`parseError` and `genericError` are untouched — `uploadOutcome` calls them exactly as
|
||||
the old listener body did, so their own behavior (ProblemDetails detail extraction,
|
||||
generic fallback) is unchanged.
|
||||
|
||||
## Abort-vs-error: left as a separate, smaller concern
|
||||
|
||||
TE-005 names abort-vs-error disambiguation in the same sentence as the extraction
|
||||
target, but its proposed signature — `uploadOutcome(status: number, responseText:
|
||||
string)` — has no way to express "the request was aborted before any response
|
||||
arrived." That is a real, structural mismatch, not an oversight to route around:
|
||||
|
||||
- `uploadOutcome` runs inside the `load` listener, which fires only when the browser
|
||||
received a complete HTTP response — it has a `status` and a `responseText` by
|
||||
construction.
|
||||
- The `abort` listener fires instead of `load` when `xhr.abort()` was called
|
||||
client-side. There is no HTTP response at that point — no status, no body — so
|
||||
folding it into `uploadOutcome`'s signature would mean inventing a fake status (e.g.
|
||||
`0`) to stand for "not actually a response," which trades one implicit convention for
|
||||
another and makes the pure function's contract lie about what it receives.
|
||||
|
||||
The existing code already expresses this as the smallest form it can take:
|
||||
|
||||
```ts
|
||||
xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError())));
|
||||
```
|
||||
|
||||
one ternary, deciding between two sentinels based on which native event fired and
|
||||
whether `cancel()` was called first — not on response content. It is not a second
|
||||
`uploadOutcome`-shaped decision hiding in a closure; it is a one-line dispatch already.
|
||||
Extracting it into its own named function would add a call site and an import for a
|
||||
single ternary with no reachable-only-via-DOM logic left inside it. Left in place, as
|
||||
DoD point 2 allows.
|
||||
|
||||
## Spec added, verified red
|
||||
|
||||
`libs/shared/src/infrastructure/upload.adapter.spec.ts` (new file) — plain
|
||||
`describe`/`it`, no `TestBed`, no DOM, no XHR stub, matching the DoD's explicit
|
||||
"that is the entire point." Five cases:
|
||||
|
||||
1. 2xx status with a valid JSON body → `{ ok: true, value: { documentId } }`.
|
||||
2. 2xx status with an unparseable body → falls back to the generic `UPLOAD_FAILED`
|
||||
text (the `JSON.parse`-with-fallback branch).
|
||||
3. Non-2xx status with a ProblemDetails body → the `detail` field, via `parseError`.
|
||||
4. Non-2xx status with a body that is not ProblemDetails-shaped → falls back to the
|
||||
generic text.
|
||||
5. The 200/300 boundary: 299 is success, 300 is not.
|
||||
|
||||
`UPLOAD_FAILED`'s text is not exported (unchanged by this ticket), so the spec holds
|
||||
its own copy of the Dutch string as a local constant with a comment pointing at the
|
||||
source — the same trade every other spec makes when asserting against `$localize`
|
||||
constants that never leave their module ($localize`strings are English-first prose
|
||||
only where the source is`nl`, so this is the source text as written, not a stand-in).
|
||||
|
||||
**Red-proof.** Edited `uploadOutcome`'s body down to a single line —
|
||||
`return ok({ documentId: JSON.parse(responseText).documentId });`, dropping the
|
||||
status check and the try/catch — with an `Edit` (not `git checkout`). Ran
|
||||
`ng test shared`. Result: 4 of the 5 new specs failed:
|
||||
|
||||
```
|
||||
SyntaxError: Unexpected token 'o', "not json" is not valid JSON
|
||||
❯ uploadOutcome libs/shared/src/infrastructure/upload.adapter.ts:168:32
|
||||
|
||||
AssertionError: expected { ok: true, value: { …(1) } } to deeply equal { ok: false, …(1) }
|
||||
- Expected "error": "Document is al aan een aanvraag gekoppeld.", "ok": false,
|
||||
+ Received "ok": true, "value": { "documentId": undefined },
|
||||
|
||||
SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
|
||||
|
||||
AssertionError: expected true to be false // Object.is equality
|
||||
```
|
||||
|
||||
(only the plain 2xx-valid-JSON case still passed, as expected of a mutant that always
|
||||
reports success). Re-applied the real body with a second `Edit`; `git diff` against
|
||||
HEAD shows only the intended net change — the red edit left no trace. Re-ran:
|
||||
163/163 green.
|
||||
|
||||
## Coverage, `upload.adapter.ts`
|
||||
|
||||
| Metric | Before (TE-005 baseline) | After |
|
||||
| -------- | ------------------------ | ---------------------- |
|
||||
| Lines | LH 5 / LF 64 (7.8%) | LH 12 / LF 65 (18.5%) |
|
||||
| Branches | BRH 3 / BRF 57 (5.3%) | BRH 7 / BRF 59 (11.9%) |
|
||||
|
||||
(`LF`/`BRF` grew by one line and two branches because `uploadOutcome` is new source;
|
||||
`npm run test:coverage`'s shared run, `coverage/shared/lcov.info`, narrowed to this
|
||||
file's `SF:` block.) The jump is real but modest in absolute percentage: `uploadOutcome`
|
||||
itself is now fully exercised (`FNDA:6,uploadOutcome`, both branches of the status
|
||||
check hit, both the try and the catch path hit), but the class methods
|
||||
(`categoriesResource`, `status`, `deleteDocument`, `xhrUpload`'s own body,
|
||||
`simulateUpload`) remain unreached — they need DI/XHR/timers to test and are
|
||||
out of this ticket's scope, exactly as TE-005 scopes it ("extract the interpretation,
|
||||
not the transport").
|
||||
|
||||
## Optional scenario-branch move: not taken
|
||||
|
||||
TE-005 suggests, as an explicitly optional second half, moving the `currentScenario()`
|
||||
branch from `xhrUpload` up into `KeepaliveTransport.send()`
|
||||
(`libs/shared/src/application/upload-shell.service.ts`) so `xhrUpload` becomes
|
||||
transport-only. RB-25's handoff confirms the seam is available (`KeepaliveTransport`
|
||||
is still unexported, `send()` is still an unchanged one-liner) but not required.
|
||||
|
||||
This ticket does not take that half, for a reason RB-25's handoff does not settle:
|
||||
the ticket's own **Scope** section restricts this ticket to `upload.adapter.ts` and its
|
||||
spec only ("RB-24, RB-25, RB-26, RB-28 have all already merged — nothing else in the
|
||||
upload module is in flight, so you have the folder to yourself"). Moving the scenario
|
||||
branch requires editing `upload-shell.service.ts` too — exporting `simulateUpload` (or
|
||||
moving it) out of `upload.adapter.ts` and importing it into the application-layer
|
||||
`send()` — which is a second file, outside the stated scope. Doing it anyway would also
|
||||
widen this single-file ticket's diff for an explicitly optional half the ticket itself
|
||||
says to skip when it "complicates the diff." The dev simulator's behavior is therefore
|
||||
byte-for-byte unchanged: `xhrUpload` still checks `currentScenario()` first and still
|
||||
delegates to the untouched `simulateUpload` for `upload-slow`/`upload-fail`, verified by
|
||||
inspection (the only edit inside `xhrUpload` is the `load`-listener dispatch) and by the
|
||||
full `shared` suite staying green, including `upload-shell.service.spec.ts`'s existing
|
||||
scenario-adjacent assertions.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run lint`: clean.
|
||||
- `npm run dep:check`: unaffected — the only new import is `@shared/kernel/fp`, already
|
||||
the repo's shared `Result` module, imported the same way by other `libs/shared`
|
||||
infrastructure adapters (no new import direction).
|
||||
- `npm test` / `ng test shared`: 163/163, across 26 spec files — 5 of those tests are
|
||||
the new `upload.adapter.spec.ts`, the other 158 across 25 pre-existing files are
|
||||
unchanged by this ticket.
|
||||
- `npm run ci`: result and step count reported in the implementing agent's final answer.
|
||||
|
||||
## Batch 5 close-out
|
||||
|
||||
Batch 5 (RB-25 through RB-30) is now fully implemented. For `libs/shared/upload`
|
||||
(moved to its layered home by RB-24) specifically: `upload.machine.ts` (domain) has its
|
||||
own spec and `planFileSelection` extracted by RB-26; `upload-shell.service.ts`
|
||||
(application) has a full spec covering `upload()`/`cancel()`/`delete()`/
|
||||
`pollReturning()` via the `UPLOAD_TRANSPORT` token RB-25 added; `upload-controller.ts`
|
||||
(application) was already spec'd before this batch; `upload.adapter.ts`
|
||||
(infrastructure) now has `uploadOutcome` as a pure, spec'd seam, though the class's
|
||||
HTTP-bound methods (categories/status/delete/the XHR transport itself) remain
|
||||
untested by design — XHR is the one boundary this batch deliberately does not
|
||||
abstract, per TE-005's own instruction. End to end, every layer of the upload module
|
||||
that can hold pure logic now does, and has a spec proving it; what is left uncovered is
|
||||
exactly the DOM/network edge the module exists to wrap, not logic hiding behind it.
|
||||
@@ -0,0 +1,180 @@
|
||||
# RB-28 — `BLOB_PRESENTER` token unlocks the three blob-to-browser success paths
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-006 ·
|
||||
`99-backlog.md` RB-28
|
||||
|
||||
## What was wrong
|
||||
|
||||
Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot
|
||||
meaningfully execute: `StamdataStore.download()`
|
||||
(`libs/beheer/src/application/stamdata.store.ts`) did `URL.createObjectURL` →
|
||||
`document.createElement('a')` → `a.click()` → `URL.revokeObjectURL`;
|
||||
`BriefStore.previewLetter()` (`apps/ssp/src/app/brief/application/brief.store.ts`) and
|
||||
`OrgTemplateStore.proefbrief()` (`apps/ssp/src/app/brief/application/org-template.store.ts`)
|
||||
both did `window.open(URL.createObjectURL(blob), '_blank')`. Because the call was the
|
||||
last statement of each command, TE-006 recorded the whole success path as effectively
|
||||
unassertable, and `download()`'s two-clause guard (`if (!s || !this.canDownload())
|
||||
return;`) as permanently dark on its true branch.
|
||||
|
||||
## What changed
|
||||
|
||||
One new file, `libs/shared/src/application/blob-presenter.ts`, mirroring the
|
||||
`SESSION_PORT` token already in that folder — an interface, a production
|
||||
implementation, and an `InjectionToken`:
|
||||
|
||||
```ts
|
||||
export interface BlobPresenter {
|
||||
open(blob: Blob): void;
|
||||
download(blob: Blob, filename: string): void;
|
||||
}
|
||||
|
||||
const realBlobPresenter: BlobPresenter = {
|
||||
open(blob) {
|
||||
window.open(URL.createObjectURL(blob), '_blank');
|
||||
},
|
||||
download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
export const BLOB_PRESENTER = new InjectionToken<BlobPresenter>('BLOB_PRESENTER', {
|
||||
providedIn: 'root',
|
||||
factory: () => realBlobPresenter,
|
||||
});
|
||||
```
|
||||
|
||||
`open()` never revokes the object URL (the tab it opens outlives the call —
|
||||
`BriefStore.previewLetter`'s original comment already said so and is preserved,
|
||||
moved onto the token's own doc comment); `download()` does revoke, once the click has
|
||||
fired. This asymmetry is preserved deliberately, not unified — the two call sites
|
||||
behaved differently before this ticket and still do.
|
||||
|
||||
Each of the three commands now injects `BLOB_PRESENTER` and calls it instead of the DOM
|
||||
directly:
|
||||
|
||||
| File | Before (last statement) | After |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `stamdata.store.ts` | `createObjectURL` → `createElement('a')` → `click()` → `revokeObjectURL` (6 lines) | `this.blobPresenter.download(blob, \`${s.table.id}.json\`);` |
|
||||
| `brief.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` |
|
||||
| `org-template.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` |
|
||||
|
||||
`OrgTemplateStore.previewUrlFor` (added by RB-24, a different seam — a document
|
||||
content URL for an `<a href>`, not a blob handoff) is untouched.
|
||||
|
||||
## Tests added
|
||||
|
||||
**`libs/beheer/src/application/stamdata.store.spec.ts`** — a new
|
||||
`StamdataStore.download (RB-28)` describe block with a recording fake `BlobPresenter`:
|
||||
|
||||
1. Does not call the presenter while `canDownload()` is false because nothing is dirty
|
||||
yet — the guard's previously-dark true branch, first clause.
|
||||
2. Does not call the presenter while previewing a date, even with a real edit present —
|
||||
the guard's true branch, second clause.
|
||||
3. **The success path**, asserting `toJson(...)`'s exact output reaches the file: reads
|
||||
the recorded blob's text and compares it byte-for-byte against a direct call to
|
||||
`toJson(store.table()!, store.rows())`, and asserts the filename is
|
||||
`professions.json`.
|
||||
|
||||
**`apps/ssp/src/app/brief/application/brief.store.spec.ts`** — the existing
|
||||
`BriefStore.previewLetter` describe block's success test previously spied directly on
|
||||
`window.open`/`URL.createObjectURL` (both already jsdom-spyable, since the properties
|
||||
exist even though calling them for real throws "not implemented"). It now provides the
|
||||
recording fake via `BLOB_PRESENTER` and asserts `opened` holds exactly the resolved
|
||||
blob — the same outcome, reached through the new seam instead of monkey-patching two
|
||||
global browser objects.
|
||||
|
||||
**`apps/ssp/src/app/brief/application/org-template.store.spec.ts`** (new file —
|
||||
`OrgTemplateStore` had no spec at all before this ticket) — a
|
||||
`OrgTemplateStore.proefbrief (RB-28)` describe block: the success path (presenter
|
||||
receives the resolved blob, no error) and the failure path (presenter never reached,
|
||||
error surfaced). A `Partial<UploadAdapter>` stub with a no-op `categoriesResource`
|
||||
(status `'idle'`) satisfies the store's constructor effect without touching the
|
||||
logo-upload sub-state, which these tests do not exercise.
|
||||
|
||||
## Verified red without the fix
|
||||
|
||||
Broke `StamdataStore.download()` with an `Edit` (not `git checkout`): changed the
|
||||
filename from `` `${s.table.id}.json` `` to `` `${s.table.id}.csv` ``. Ran the new
|
||||
success-path spec:
|
||||
|
||||
```
|
||||
AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality
|
||||
|
||||
Expected: "professions.json"
|
||||
Received: "professions.csv"
|
||||
❯ libs/beheer/src/application/stamdata.store.spec.ts:134:36
|
||||
```
|
||||
|
||||
Re-applied the correct filename with a second `Edit`; the full `stamdata.store.spec.ts`
|
||||
file (6 tests) went green again.
|
||||
|
||||
## Verification
|
||||
|
||||
- **`grep` for remaining DOM blob calls** in all three stores —
|
||||
`grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)"` —
|
||||
zero matches. The only occurrences of those calls anywhere in `apps`/`libs` are inside
|
||||
`blob-presenter.ts` itself (checked with a second, unscoped grep — no fourth inlined
|
||||
handoff exists).
|
||||
- `npm run lint`: clean.
|
||||
- `npm run dep:check`: unaffected (no new import direction — `libs/shared` still does not
|
||||
depend on `libs/beheer`; both `libs/beheer` and `apps/ssp/brief` import the new token
|
||||
from `libs/shared`, never the reverse).
|
||||
- `npm test` (all four projects): all pass — ssp 276, behandelportal 37, shared 138,
|
||||
beheer 26 (up from 23; +3 for the new `download()` describe block).
|
||||
- Coverage, `npm run test:coverage` narrowed per project:
|
||||
- `libs/beheer/src/application/stamdata.store.ts` — **before** BRH 15 / BRF 37
|
||||
(40.5% branch, confirmed against the current tree, matching TE-006's citation
|
||||
exactly); **after** BRH 25 / BRF 37 (**67.6% branch**). `libs/beheer/src/application`
|
||||
has exactly this one file, so the module figure moves the same way.
|
||||
- `apps/ssp/src/app/brief/application/brief.store.ts` — **before** BRH 39 / BRF 72
|
||||
(54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23
|
||||
already added branches (the 404-tolerance path) since the finding was written — see
|
||||
"What TE-006 got wrong" below. **After**: BRH 39 / BRF 72, unchanged — swapping the
|
||||
global-spy assertions for the injected fake changes how the success branch is
|
||||
reached in the spec, not whether it is reached; it was already covered before this
|
||||
ticket (see below).
|
||||
- `apps/ssp/src/app/brief/application/org-template.store.ts` — no spec existed before
|
||||
this ticket, so there is no meaningful "before" branch figure for it specifically.
|
||||
**After**: BRH 17 / BRF 77, including both `proefbrief()` branches newly covered.
|
||||
- `npm run ci` (foreground, `timeout: 600000`, no background/Monitor): result reported
|
||||
in the implementing agent's final answer.
|
||||
|
||||
## What TE-006 got wrong
|
||||
|
||||
TE-006 states: "`brief.store.spec.ts` demonstrates this exactly: it tests
|
||||
`previewLetter`'s failure case ... and cannot test the success case." This is not
|
||||
accurate for the code as it stood at the start of this ticket. The spec already had an
|
||||
`'opens the composed letter in a new tab on success'` test that used
|
||||
`vi.spyOn(URL, 'createObjectURL')` and `vi.spyOn(window, 'open')` to assert the success
|
||||
path — jsdom defines both properties (as functions that throw "not implemented" if
|
||||
actually invoked), so `vi.spyOn` can already replace them, and the pre-existing test
|
||||
did. That test passed both before and after this ticket's change; this ticket did not
|
||||
newly unlock `previewLetter`'s success path, it moved an already-passing assertion off
|
||||
two hand-spied global browser objects and onto the new injectable seam. `git log
|
||||
--follow -p` on the spec file shows this test dates to the WP-67 monorepo merge, not to
|
||||
any of RB-22/23/24.
|
||||
|
||||
The seam is still worth having: `StamdataStore.download()`'s success path (five
|
||||
DOM/API calls in a row: `createObjectURL`, `createElement`, `.href`, `.download`,
|
||||
`.click()`, `revokeObjectURL`) is a materially harder thing to spy on faithfully than a
|
||||
single `window.open` call, and was in fact still dark before this ticket (no
|
||||
`download()` test of any kind existed). `OrgTemplateStore.proefbrief()` also had no
|
||||
spec at all. TE-006's diagnosis (three commands share the same class of problem, one
|
||||
token fixes all three) is sound; only the specific "cannot test" claim about
|
||||
`previewLetter` overstates what was true for that one call site. Scope was not reduced
|
||||
because of this — all three call sites are migrated per the ticket's own instruction to
|
||||
ship them together rather than half-adopt the seam.
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
`OrgTemplateStore.previewUrlFor` (RB-24) — confirmed present and unchanged at
|
||||
`org-template.store.ts:78`. `libs/shared/src/application/upload-shell.service.ts`
|
||||
(RB-25) and `upload-controller.ts` (RB-26) — not read beyond what RB-24's own note
|
||||
already described, not edited. `libs/shared/docs/behaviour-spec.mdx` — regenerated by
|
||||
`npm run gen:behaviour-spec` (part of `npm run ci`) to reflect the new/renamed test
|
||||
names; never hand-edited.
|
||||
@@ -0,0 +1,125 @@
|
||||
# RB-29 — Thread `at` through `LetterHtml.ResolveAuto`'s `datum` case
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-007 ·
|
||||
`99-backlog.md` RB-29
|
||||
|
||||
## What was wrong
|
||||
|
||||
`LetterHtml.Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)`
|
||||
(`backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs`) already took the letter's
|
||||
instant and used it correctly for the letterhead date
|
||||
(`sb.Append(Enc(FormatDatumNl(at)))`). The body's `datum` placeholder resolved through
|
||||
the private `ResolveAuto(string key, string label)`, which ignored `at` and called
|
||||
`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` instead — a pure `Domain/` rule
|
||||
class reading the wall clock. `ResolveAuto` is reached only through the private chain
|
||||
`RenderNode` ← `RenderParagraphs` ← `Render`, so no caller outside this file could pin
|
||||
the value a test would see.
|
||||
|
||||
The ticket read as filed against the current code: `Render`'s signature, the letterhead's
|
||||
correct use of `at`, and `ResolveAuto`'s `UtcNow` read were all exactly as TE-007
|
||||
described (line numbers had moved — CC around the file has grown since the finding was
|
||||
written — but the code shape had not). One thing TE-007 named as the visible symptom
|
||||
also checked out: `LetterHtmlTests.cs` already declares a
|
||||
`new PlaceholderDefDto("datum", "Datum", true)` in its golden-file fixture, but no
|
||||
`RichTextNodeDto` in that fixture's `Sections` actually references the `datum` key in
|
||||
the body — it is declared but never rendered there, so the existing golden-file test
|
||||
could not have caught this even if it asserted on dates (which it does not either).
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` | `ResolveAuto(string key, string label)` → `ResolveAuto(string key, string label, string at)`; `"datum" => FormatDatumNl(at)`. `at` threaded down through the two private call sites in the chain: `RenderParagraphs` and `RenderNode` both gained an `at` parameter, passed from `Render`'s own `at`. |
|
||||
| `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` | New fixture `FixtureBriefWithDatumInBody()` — a minimal brief whose body actually references the `datum` placeholder (the golden fixture never does). Two new `[Fact]`s (see below) plus two small extraction helpers. |
|
||||
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-29 status cell: `open` → `implemented`. |
|
||||
|
||||
`Render`'s own signature is unchanged — TE-007's "zero public API change, zero
|
||||
call-site change" held exactly. `Program.cs:697`, `:708`, and `BriefStore.cs:120` (the
|
||||
three callers) needed no edit.
|
||||
|
||||
## What the fix looks like
|
||||
|
||||
```csharp
|
||||
private static void RenderParagraphs(
|
||||
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs,
|
||||
string at)
|
||||
{
|
||||
// ... unchanged body, forwards `at` to RenderNode ...
|
||||
}
|
||||
|
||||
private static void RenderNode(
|
||||
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs, string at)
|
||||
{
|
||||
// ... unchanged body, forwards `at` to ResolveAuto ...
|
||||
}
|
||||
|
||||
private static string ResolveAuto(string key, string label, string at) => key switch
|
||||
{
|
||||
"naam_zorgverlener" => SeedData.Registration.Naam,
|
||||
"big_nummer" => SeedData.Registration.BigNummer,
|
||||
"datum" => FormatDatumNl(at),
|
||||
_ => label,
|
||||
};
|
||||
```
|
||||
|
||||
Both call sites already had `at` in scope (`Render`'s own parameter), so this is a pure
|
||||
threading change — no new state, no new dependency.
|
||||
|
||||
## Tests added
|
||||
|
||||
TE-007 named the exact gap: the golden-file fixture declares the `datum` placeholder but
|
||||
never renders it in the body, so no existing assertion could catch a body/letterhead
|
||||
mismatch. A new fixture and two focused tests close it:
|
||||
|
||||
1. **`Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock`** —
|
||||
renders `FixtureBriefWithDatumInBody()` with a fixed historical `at`
|
||||
(`2019-03-14T08:00:00.0000000+00:00`) and asserts the body's rendered paragraph is the
|
||||
exact string `"14 maart 2019"`. A test using today's date would have passed before
|
||||
and after the fix and proven nothing — this one pins a date nowhere near "now", so it
|
||||
fails whenever the resolver reads the wall clock instead of `at`.
|
||||
2. **`Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at`**
|
||||
— same fixture and historical `at`, asserts the letterhead `<dd>` date and the body's
|
||||
rendered `datum` paragraph are equal. This is TE-007's stated payoff: not a shipped
|
||||
bug today (every current caller passes `Now()` at render time, so the two dates always
|
||||
coincided even with the bug present), but a latent one — the moment `Render` is ever
|
||||
called with a historical `at` (re-rendering an archive, back-dating a letter), the
|
||||
letterhead and body would disagree within a single document. This test is the one
|
||||
that would have caught that.
|
||||
|
||||
Both tests use the repo's one date formatter (`FormatDatumNl`, already used by both call
|
||||
sites under test) only indirectly, through the literal expected string `"14 maart
|
||||
2019"` — no second hand-rolled `ToString` format was introduced in the test file either.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Verified red without the fix.** Reverted only the `ResolveAuto` expression (via
|
||||
`Edit`, not `git checkout`) back to
|
||||
`"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))`, leaving the new
|
||||
tests and the threaded signatures in place. Ran the two new tests:
|
||||
```
|
||||
Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock [FAIL]
|
||||
Assert.Equal() Failure: Strings differ
|
||||
Expected: "14 maart 2019"
|
||||
Actual: "27 augustus 2026"
|
||||
|
||||
Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at [FAIL]
|
||||
Assert.Equal() Failure: Strings differ
|
||||
Expected: "14 maart 2019"
|
||||
Actual: "27 augustus 2026"
|
||||
```
|
||||
Both failures show the body rendering the run's actual wall-clock date (today,
|
||||
2026-08-27) instead of the pinned historical `at` — the precise defect TE-007
|
||||
describes. Restored the fix with a second `Edit` and re-ran: all 4 tests in
|
||||
`LetterHtmlTests` green (2 pre-existing + 2 new).
|
||||
- `grep -n "UtcNow\|DateTime.Now\|DateTime.Today\|DateTimeOffset.Now"
|
||||
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` — no matches. No ambient
|
||||
clock read remains anywhere in the file.
|
||||
- `npm run ci` (foreground, no background/Monitor): see result reported alongside this
|
||||
ticket.
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
`LetterHtml.cs`'s overall structure and CC (TE-007 records it at 21, the third-highest
|
||||
in the backend) are unchanged — reducing that is out of scope for this ticket, per its
|
||||
own text. `Data/BriefStore.cs` and any `BriefRules.cs` file were not touched — a
|
||||
concurrent ticket owns that file.
|
||||
@@ -0,0 +1,210 @@
|
||||
# RB-30 — extract `BriefStore`'s guards into `Domain/Letters/BriefRules.cs`
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source finding: `02-testability.md` TE-008 ·
|
||||
`99-backlog.md` RB-30
|
||||
|
||||
RB-30 moves the brief workflow's five guard decisions out of `BriefStore` (a
|
||||
lock-held, DB-opening static store) into a pure `Domain/Letters/BriefRules.cs`, and
|
||||
adds a free-running unit test file for them. This is a pure extraction: the store
|
||||
keeps its lock, its `Db.Create()`, its static shape, and every method's signature.
|
||||
|
||||
## What was wrong
|
||||
|
||||
Five guard clusters in `Data/BriefStore.cs` are pure decisions over `(status tag,
|
||||
actor role, entity completeness)` — Save, Submit, Send, and the shared Approve/Reject
|
||||
review path each start with an `if` cascade that is a function of two enums and a
|
||||
bool. But every one of those `if`s sat inside a method that had already done `lock
|
||||
(_gate) { using var db = Db.Create(); ... }`, so a spec could not exercise the
|
||||
decision without a booted host and a real SQLite file. `Domain/Letters/` held only
|
||||
`LetterHtml.cs` and `OrgTemplateRules.cs`; there was no `BriefRules` class, even
|
||||
though `Authz.CanActOn` — a pure `Domain/Authorization/` call one line away from
|
||||
three of the guards — already proved the pattern worked for this exact file.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs` | New. Five pure statics: `CanSave`, `StatusAfterSave`, `RequiredFilled` + `CanSubmit`, `CanSend`, `CanDecide`. All take `BriefStatusDto`/`bool`/`Principal`/`string`, never `BriefEntity` — no persistence type reaches this file. |
|
||||
| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `Save`, `Submit`, `Send`, and the private `Review` (the Approve/Reject shared path) each replace their inline `if` cascade with one call into `BriefRules`, then branch only on the returned `Outcome`. The private `RequiredFilled(BriefEntity e)` helper is deleted — `BriefRules.RequiredFilled(IReadOnlyList<LetterSectionDto>)` replaces it. Lock, `Db.Create()`, method signatures, and the public `Outcome` enum are all unchanged. |
|
||||
| `backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs` | New. 29 `[Fact]`/`[Theory]` assertions covering every branch of all five rules — see "Tests added" below. |
|
||||
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-30's status cell: `open` → `done`. |
|
||||
|
||||
## The surface, as built — and where it differs from TE-008's proposal
|
||||
|
||||
TE-008 proposed:
|
||||
|
||||
```
|
||||
CanSave(BriefStatusDto status, bool isDrafter) → Outcome
|
||||
StatusAfterSave(BriefStatusDto) → BriefStatusDto
|
||||
CanSubmit(status, isDrafter, bool requiredFilled) → Outcome
|
||||
CanSend(status)
|
||||
CanDecide(status, Principal, drafterId)
|
||||
```
|
||||
|
||||
The ticket explicitly names this a proposal, not a specification. What was built
|
||||
matches it almost exactly, with two adjustments forced by the real code:
|
||||
|
||||
- **`Outcome` is `BriefStore.Outcome`, not a new type.** `BriefStore` already
|
||||
exposes a public `enum Outcome { Ok, Forbidden, Conflict }`, and `Program.cs`'s
|
||||
`BriefResult` switches on it directly across every brief endpoint. TE-008 itself
|
||||
says: "if `Outcome` does not already exist as a domain concept, use whatever the
|
||||
sibling rule classes already return" — it does exist, so `BriefRules` returns it
|
||||
rather than inventing a second result shape. This does mean `Domain/Letters/`
|
||||
references a type nested in `Api.Data`; the same cross-reference already exists in
|
||||
this file's neighbor, `LetterHtml.cs` (`using BigRegister.Api.Data;`, for
|
||||
`BriefEntity`), and in `Authz.cs` (for `BriefStore`'s role-id constants) — both in
|
||||
the same single-assembly project, so this is a namespace convention, not an
|
||||
assembly boundary. `Outcome` itself is a plain three-value enum with no EF/ASP.NET
|
||||
attached, so this does not pull a persistence type into `Domain/`.
|
||||
- **`CanDecide` takes an explicit `BriefAction action` parameter**, not just
|
||||
`(status, Principal, drafterId)`. The real guard — `BriefStore.Review` — is one
|
||||
private method shared by both `Approve` and `Reject`, and it calls
|
||||
`Authz.CanActOn(action, principal, drafterId)`, which needs to know which action is
|
||||
being attempted. `BriefRules.CanDecide` composes that existing pure
|
||||
`Authz.CanActOn` call with the status check, rather than re-implementing the SoD
|
||||
logic a second time — so the four-eyes rule still has exactly one source of truth.
|
||||
|
||||
The `RequiredFilled` predicate is a sixth pure static, not one of the five guards
|
||||
proper — TE-008 names it separately ("plus the `RequiredFilled(e)` predicate") and it
|
||||
is built the same way: `RequiredFilled(IReadOnlyList<LetterSectionDto> sections) →
|
||||
bool`, taking the section list rather than the entity.
|
||||
|
||||
## Order and behaviour preserved
|
||||
|
||||
Every rule keeps the original check order, which matters because `Outcome.Forbidden`
|
||||
must outrank `Outcome.Conflict` (a non-drafter or non-entitled caller sees Forbidden
|
||||
even against an otherwise-invalid status):
|
||||
|
||||
- `CanSave`: `!isDrafter` (Forbidden) before the status-tag check (Conflict).
|
||||
- `CanSubmit`: `!isDrafter` (Forbidden) before `status.Tag != "draft" ||
|
||||
!requiredFilled` (Conflict).
|
||||
- `CanDecide`: `!Authz.CanActOn(...)` (Forbidden) before `status.Tag != "submitted"`
|
||||
(Conflict) — the exact order the old inline check in `Review` used, per its own
|
||||
comment ("checked BEFORE the status guard").
|
||||
- `CanSave`'s entity-not-found branch (`e is null → Conflict`) stays inline in
|
||||
`BriefStore` — it is a persistence fact ("no row for this owner"), not one of the
|
||||
three business axes TE-008 names (status tag, actor role, entity completeness), so
|
||||
it was left where it was rather than forced into a rule that would then need to
|
||||
accept a nullable entity.
|
||||
|
||||
## Tests added
|
||||
|
||||
`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, 29 assertions, alongside
|
||||
the seven Domain test files that already existed:
|
||||
|
||||
- **`CanSave`** — drafter saves draft/rejected (Ok, `[Theory]`); drafter saves
|
||||
submitted/approved/sent (Conflict, `[Theory]`); non-drafter saves draft or submitted
|
||||
(Forbidden both times — proves role beats status).
|
||||
- **`StatusAfterSave`** — rejected → draft; draft stays draft.
|
||||
- **`RequiredFilled`** — no sections; an unfilled optional section; a filled required
|
||||
section; an unfilled required section; one filled + one unfilled required section
|
||||
(proves one bad section blocks the whole letter).
|
||||
- **`CanSubmit`** — filled draft (Ok); unfilled draft (Conflict — **the required-filled
|
||||
gate the ticket explicitly asked for**); non-draft status (Conflict); non-drafter
|
||||
on a filled draft (Forbidden — role beats completeness).
|
||||
- **`CanSend`** — approved (Ok); draft/submitted/rejected/sent (Conflict, `[Theory]`).
|
||||
- **`CanDecide`** — approver decides a submitted letter drafted by someone else, for
|
||||
both Approve and Reject (Ok, `[Theory]`); a drafter attempting to decide (Forbidden
|
||||
— **the non-drafter denial the ticket asked for**); an approver whose acting id
|
||||
equals the drafter id, i.e. self-review (Forbidden — the four-eyes/SoD case); an
|
||||
approver deciding a non-submitted letter (Conflict); an approver who is also the
|
||||
drafter AND the status is non-submitted (Forbidden, not Conflict — proves the
|
||||
priority order survived the extraction).
|
||||
|
||||
## Verified red without the fix
|
||||
|
||||
Inverted `CanSubmit`'s completeness check (`!requiredFilled` → `requiredFilled`) with
|
||||
an `Edit`, ran `BriefRuleTests` alone:
|
||||
|
||||
```
|
||||
[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_not_submit_an_unfilled_draft [FAIL]
|
||||
Assert.Equal() Failure: Values differ
|
||||
Expected: Conflict
|
||||
Actual: Ok
|
||||
[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_submit_a_filled_draft [FAIL]
|
||||
Assert.Equal() Failure: Values differ
|
||||
Expected: Ok
|
||||
Actual: Conflict
|
||||
|
||||
Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29
|
||||
```
|
||||
|
||||
Reverted with a second `Edit` (never `git checkout` — that would have discarded the
|
||||
whole file). Reran: 29/29 green.
|
||||
|
||||
## Existing tests — unchanged
|
||||
|
||||
`BriefEndpointTests.cs`, `PreviewEndpointTests.cs`, and `OrgTemplateEndpointTests.cs`
|
||||
(the three host-booting suites that exercise the brief endpoints) needed **no
|
||||
changes**. Ran together: 32/32 passing, proving the extraction preserved every HTTP
|
||||
outcome (`Save_is_drafter_only`, `Submit_blocks_on_empty_required_section`,
|
||||
`Submit_succeeds_when_required_sections_filled`,
|
||||
`Drafter_cannot_approve_own_letter_but_a_different_reviewer_can`,
|
||||
`Reject_returns_comments`, `Editing_a_rejected_letter_reopens_it_to_draft`,
|
||||
`Send_only_from_approved`, and the rest, all unmodified).
|
||||
|
||||
## The metric TE-008 cares about: host-booting brief-rule assertions
|
||||
|
||||
Before this ticket, the five guard decisions had **zero** free-running unit
|
||||
assertions. Every branch of every guard was reachable only through the seven
|
||||
host-booting endpoint test methods above (six of them containing an explicit
|
||||
`Assert.Equal(HttpStatusCode.Forbidden/Conflict, ...)`, each paying a full
|
||||
`TestWebApplicationFactory` host boot plus a real SQLite round-trip, run serially
|
||||
process-wide because of `[assembly: DisableTestParallelization]`).
|
||||
|
||||
After this ticket:
|
||||
|
||||
- **0 → 29** free-running unit assertions covering these branches
|
||||
(`BriefRuleTests.cs`, `dotnet test --filter FullyQualifiedName~BriefRuleTests`
|
||||
completes in **~120 ms**, no host, no SQLite file).
|
||||
- **7 → 7** host-booting endpoint tests, unchanged. They stay — they are now the
|
||||
proof that `BriefStore` wires `BriefRules`'s answer to the right HTTP status, not
|
||||
the only place the business decision itself is checked. That split (wiring proven
|
||||
at the integration layer, decision logic proven at the unit layer) is the seam
|
||||
TE-008 argued for.
|
||||
- New branches this ticket made assertable that the endpoint suite never covered
|
||||
directly: the SoD self-review case (`An_approver_may_not_decide_a_letter_they_drafted_themselves`)
|
||||
and the Forbidden-beats-Conflict priority ordering for both `CanSave`/`CanSubmit`
|
||||
(role checked first) and `CanDecide` (entitlement checked first) — these existed as
|
||||
implicit behaviour in the original `if` cascades but had no assertion pinning them
|
||||
before RB-30.
|
||||
|
||||
## What was not extracted
|
||||
|
||||
Nothing — all five guards named in TE-008, plus the `RequiredFilled` predicate, moved
|
||||
cleanly. None needed the `DbContext`: each was already a function of values already
|
||||
resident on the in-memory `BriefEntity` (its `Status`, `Sections`, `DrafterId`), never
|
||||
of a query against the database itself.
|
||||
|
||||
## Scope respected
|
||||
|
||||
- `Domain/Letters/LetterHtml.cs` was not touched (a concurrent agent owns it).
|
||||
- `BriefEntity.ToDto()` was not touched — its CC 16 is a separate, out-of-scope
|
||||
finding per the ticket.
|
||||
- `Data/Db.cs`'s static-store decision and `TestWebApplicationFactory`'s serialized-test
|
||||
position were not challenged; the store's lock, `Db.Create()`, and public shape are
|
||||
byte-for-byte the same as before this ticket, other than the `if` cascades moving
|
||||
out.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet build`: 0 warnings, 0 errors.
|
||||
- `dotnet test --filter FullyQualifiedName~BriefRuleTests`: 29/29, ~120 ms.
|
||||
- `dotnet test --filter FullyQualifiedName~BriefEndpointTests|...PreviewEndpointTests|...OrgTemplateEndpointTests`:
|
||||
32/32, unchanged.
|
||||
- Full backend suite: **291/292 passing**, plus the one known, pre-existing,
|
||||
container-dependent failure
|
||||
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||
"Connection refused (localhost:8000)") — not this ticket's bug, does not run under
|
||||
`npm run ci`, reproduces on a clean tree with no OpenZaak container running.
|
||||
- `npm run ci` (foreground, no background/Monitor): see the commit message / session
|
||||
report for the exit code and step count.
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
No frontend file was touched — the brief workflow's status machine is server-
|
||||
authoritative, and the FE's own pure reducer (mirroring these same transitions for
|
||||
UX) was already out of this ticket's scope. No file outside `backend/Data/BriefStore.cs`,
|
||||
`backend/Domain/Letters/BriefRules.cs`,
|
||||
`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, and `99-backlog.md` was
|
||||
changed.
|
||||
@@ -0,0 +1,155 @@
|
||||
# RB-31 — replay real messages in the four hand-rolling machine specs
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-010 ·
|
||||
`99-backlog.md` RB-31
|
||||
|
||||
RB-31 replaces four hand-rolled state-literal fixtures with `given(reduce, initial)`
|
||||
replays, per ADR-0006 §2 ("no object is built directly; a fixture is the result of
|
||||
running real `Msg`s through the real `reduce`"). This is a fixture-construction change
|
||||
only. No `*.machine.ts` production file was touched.
|
||||
|
||||
## What was wrong
|
||||
|
||||
Four machine specs built their starting `Answering`/`Invullen`/`Editing`/`loaded` state
|
||||
with a local object-literal helper instead of replaying messages:
|
||||
|
||||
- `intake.machine.spec.ts` — `answering(answers, cursor, scholingThreshold)` hardcoded
|
||||
`errors: {}`. `intake.testing.ts` (exporting `givenIntake`) already existed next to
|
||||
it and was already correct, but was imported only by `intake.acceptance.spec.ts`.
|
||||
- `registratie-wizard.machine.spec.ts` — `invullen(draft, cursor)` hardcoded `errors: {}`
|
||||
and `upload: initialUpload`.
|
||||
- `besluit.machine.spec.ts` — `editingWith(besluit, toelichting)` hardcoded `errors: {}`.
|
||||
- `brief.machine.spec.ts` — `loaded(status, sections)` built the `'loaded'` tag object
|
||||
directly (no `errors` field on this union, so this one did not hardcode `errors: {}`,
|
||||
but it still skipped the reducer).
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts` | Removed the local `answering(...)` helper. Every fixture is now built with the existing `givenIntake` (imported from `intake.testing.ts`), matching `intake.acceptance.spec.ts`'s own style. |
|
||||
| `apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts` | New. One-liner: `export const givenRegistratieWizard = given(reduce, initial)`. |
|
||||
| `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` | Removed the local `invullen(...)` helper and the now-unused `validAdres`/`validDraft`/`Draft`/`initialUpload` fixtures. Added module-level replay helpers (`toAdresValid`, `toBeroepStep`, `toBeroepStepWithDiploma`, `toControleStep`, `toIndienen`, `toFullDraftAtCursor0`) built from `givenRegistratieWizard` + `reduce`, reused across every `describe` block (the file's pre-existing `reduce (message-driven happy path)` block already had three of these, scoped locally; they are now module-level and shared, removing the duplication). |
|
||||
| `apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts` | New. One-liner: `export const givenBesluit = given(reduce, initial)`. |
|
||||
| `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts` | Removed the local `editingWith(...)` helper. Every fixture is now built with `givenBesluit` (or, for the empty-draft case, the machine's own `initial` — see below). |
|
||||
| `apps/ssp/src/app/brief/domain/brief.testing.ts` | New. One-liner: `export const givenBrief = given(reduce, initial)`. |
|
||||
| `apps/ssp/src/app/brief/domain/brief.machine.spec.ts` | Rewrote the `loaded(...)` helper to replay a real `BriefLoaded` message through `givenBrief` instead of building the `'loaded'` tag object directly. Also converted one further inline `BriefState` literal in the "deep-copies content" test to the same replay (same anti-pattern, same file, not named individually by the finding's evidence list but visibly the same shape — see "Beyond the letter of the finding" below). |
|
||||
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-31's status cell: `open` → `implemented`. |
|
||||
|
||||
## Message sequence used per machine
|
||||
|
||||
### `intake.machine.spec.ts`
|
||||
|
||||
Every fixture in this file has `cursor` 0, 1, or 2 and the default or an overridden
|
||||
`scholingThreshold`. All are built as direct sequences of `SetAnswer`/`Next`/`SetPolicy`
|
||||
through `givenIntake`, mirroring `intake.acceptance.spec.ts`'s own explicit style (no new
|
||||
generic wrapper was added — the finding's own resolution is "wire the spec to
|
||||
`givenIntake`", not "invent a second helper"). Representative sequences:
|
||||
|
||||
- Cursor 0, plain answers (most tests): `givenIntake({SetAnswer buitenlandGewerkt}, {SetAnswer uren}, ...)`.
|
||||
- Cursor 0 with an overridden threshold: adds a trailing `{tag:'SetPolicy', scholingThreshold: N}`.
|
||||
- Cursor 1 ("editing an answer leaves the cursor fixed"): `SetAnswer buitenlandGewerkt=ja`,
|
||||
`SetAnswer land`, `SetAnswer buitenlandseUren`, `Next` (buitenland step now valid ->
|
||||
cursor 1), then the edit under test.
|
||||
- Cursor 2 ("gaNaarStap jumps back..."): the above sequence continued with
|
||||
`SetAnswer uren=4160`, `Next` (werk step valid -> cursor 2).
|
||||
|
||||
No drift found here: `intake.testing.ts` already existed correctly, and every one of the
|
||||
nine cursor/threshold combinations the old literal used turned out to be reachable by a
|
||||
real message sequence.
|
||||
|
||||
### `registratie-wizard.machine.spec.ts`
|
||||
|
||||
- `toAdresValid()` = `PrefillAdres(straat, postcode, woonplaats)`, `SetCorrespondentie('post')`
|
||||
— cursor 0, matches the old `invullen(validAdres)`.
|
||||
- `toBeroepStep()` = `reduce(toAdresValid(), Next)` — cursor 0 -> 1, no diploma. Matches
|
||||
`invullen(validAdres, 1)`.
|
||||
- `toBeroepStepWithDiploma()` = `reduce(toBeroepStep(), KiesDiploma('d1','Arts',[]))` —
|
||||
cursor 1, diploma set. Matches `invullen(validDraft, 1)`.
|
||||
- `toControleStep()` = `reduce(toBeroepStepWithDiploma(), Next)` — cursor 1 -> 2. Matches
|
||||
`invullen(validDraft, 2)`.
|
||||
- `toFullDraftAtCursor0()` = `PrefillAdres`, `SetCorrespondentie('post')`, `KiesDiploma(...)`,
|
||||
never advancing the cursor — matches `invullen(validDraft)` (cursor 0). `SetField`/
|
||||
`SetCorrespondentie`/`KiesDiploma` carry no cursor gate, so setting every field before
|
||||
ever pressing `Next` is a genuinely reachable cursor-0 state with a complete draft.
|
||||
- `invullen({})` (five call sites) is exactly the machine's own `initial` value
|
||||
(`{tag:'Invullen', draft:{antwoorden:{}}, cursor:0, errors:{}, upload:initialUpload}`)
|
||||
— replaced with `initial` directly, no message needed.
|
||||
|
||||
### `besluit.machine.spec.ts`
|
||||
|
||||
- `editingWith('')` is exactly `initial` (`draft:{besluit:'',toelichting:''}`) — replaced
|
||||
with `initial` directly.
|
||||
- `editingWith('Afwijzen')` / `editingWith('Goedkeuren')` = `givenBesluit({SetField besluit})`.
|
||||
- `editingWith('Afwijzen', ' niet erkend ')` = `givenBesluit({SetField besluit=Afwijzen}, {SetField toelichting=' niet erkend '})`.
|
||||
- Every `Submitting`/`Failed` fixture is now `givenBesluit({SetField besluit}, {Submit})`
|
||||
composed further with `reduce(..., {SubmitFailed}/{Retry}/{Reset})`.
|
||||
|
||||
### `brief.machine.spec.ts`
|
||||
|
||||
- `loaded(status, sections)` = `givenBrief({tag:'BriefLoaded', brief: briefWith(status, sections), availablePassages: lib, decisions})`.
|
||||
This is a 1:1 replacement: the `'BriefLoaded'` reducer case sets exactly
|
||||
`{tag:'loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions}`
|
||||
— the same three fields the old literal built by hand, with the same values. No drift.
|
||||
|
||||
## Drift found
|
||||
|
||||
Two tests in `registratie-wizard.machine.spec.ts` asserted against a cursor value the
|
||||
real reducer cannot reach:
|
||||
|
||||
- `'validateAll keeps only the answers to the questions that applied'` built
|
||||
`invullen(validAdres, 2)` then called `kiesDiploma(...)` on it — i.e. a wizard already
|
||||
at cursor 2 (`controle`) with **no diploma chosen yet**. That is impossible by replay:
|
||||
advancing past `beroep` (cursor 1 -> 2) requires `validateStep('beroep', ...)` to pass,
|
||||
which requires `diplomaId` and `beroep` to already be set. The literal encoded a state
|
||||
the reducer can never produce.
|
||||
- `'requires a declared beroep + all maximal questions before submit'` had the same
|
||||
problem: `invullen(validAdres, 2)` then `kiesHandmatig(...)`, which leaves `beroep`
|
||||
`undefined` — again a cursor-2 state that could never have been reached via `Next`.
|
||||
|
||||
In both cases the cursor value is not actually load-bearing for the test: `submit()`
|
||||
calls `validateAll(s.draft, s.upload)`, which validates every step regardless of
|
||||
`s.cursor`. Both tests were re-pointed at the reachable **cursor-1** equivalent
|
||||
(`toBeroepStep()` then `kiesDiploma`/`kiesHandmatig`), with an inline `// DRIFT (see
|
||||
rb-31.md)` comment at each site. No assertion changed — both tests still check the same
|
||||
`submit(...)` outcome on the same field values; only the now-irrelevant cursor number
|
||||
in the starting fixture moved from an unreachable 2 to a reachable 1.
|
||||
|
||||
No other named state, across any of the four machines, turned out to be unreachable.
|
||||
|
||||
## Beyond the letter of the finding
|
||||
|
||||
`brief.machine.spec.ts`'s `'BesluitSelected deep-copies content...'` test built a second,
|
||||
separate `BriefState` literal inline (not through the `loaded(...)` helper the finding
|
||||
cited) — same anti-pattern, same file, not itself named in ADR-C-010's evidence list.
|
||||
Since it sits inside one of the four files already being brought into line, and the fix
|
||||
is the identical one-line `BriefLoaded` replay, it was converted too rather than left as
|
||||
a residual violation in a file this ticket otherwise fixed. No other spec, in any other
|
||||
file, was touched.
|
||||
|
||||
## `intake.acceptance.spec.ts` — confirmed unaffected
|
||||
|
||||
`intake.testing.ts` and its `givenIntake` export were not modified. The acceptance spec
|
||||
still imports and uses `givenIntake` exactly as before; it was not read or edited by
|
||||
this ticket beyond confirming (by running it) that it still passes.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx ng test ssp`: 44 test files, 276 tests, all passing (includes
|
||||
`intake.machine.spec.ts`, `intake.acceptance.spec.ts`,
|
||||
`registratie-wizard.machine.spec.ts`, `brief.machine.spec.ts`, and every other ssp
|
||||
spec, unmodified ones included).
|
||||
- `npx ng test behandelportal`: 6 test files, 37 tests, all passing (includes
|
||||
`besluit.machine.spec.ts`).
|
||||
- `npx eslint` on all seven touched/added files: clean.
|
||||
- `npx prettier --check` on all seven touched/added files: clean (one file needed
|
||||
`--write` once, then verified clean).
|
||||
- `npm run ci`: see the commit message / session report for the exit code and step
|
||||
count.
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
No `*.machine.ts` reducer or production domain file was changed — every fixture change
|
||||
is confined to the four `*.spec.ts` files and the three new `*.testing.ts` files listed
|
||||
above. No other machine spec (including `change-request.machine.spec.ts`, which the
|
||||
finding notes already honours the idiom inline) was touched.
|
||||
@@ -0,0 +1,86 @@
|
||||
# RB-32 — add the missing `language-switcher` row to the CIBG gap register
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md`
|
||||
ADR-C-008 · `99-backlog.md` RB-32 · Depends on
|
||||
`implementation/adr-c-007.md` (the same file, left one row short on purpose,
|
||||
filed forward as this ticket)
|
||||
|
||||
## What was wrong
|
||||
|
||||
ADR-0003 §Consequences' final bullet requires every `// CIBG-GAP EXTENSION:`
|
||||
marker in code to have a row in `libs/shared/docs/cibg-gaps.mdx`, "so it's
|
||||
auditable rather than silently drifting." `libs/shared/src/layout/language-switcher/
|
||||
language-switcher.component.ts:7-9` carries a full, well-formed marker
|
||||
("Taal instellen" — no vendored Huisstijl class ships for it — see
|
||||
`cibg-gaps.mdx`) but the register table had no row for it.
|
||||
|
||||
## Verified before editing
|
||||
|
||||
```
|
||||
grep -rln "CIBG-GAP EXTENSION" apps libs --include=*.ts | wc -l # 9
|
||||
```
|
||||
|
||||
Nine files carry the marker: `debug-state`, `language-switcher`, `wizard-shell`,
|
||||
`application-link`, `placeholder-chip`, `rich-text-editor`, `skeleton`,
|
||||
`spinner`, `status-badge`. The register table had 8 rows, and `language-switcher`
|
||||
was the one missing — matching ADR-C-008's finding exactly, re-verified rather
|
||||
than trusted from the finding's own snapshot (per this ticket's DoD point 1, and
|
||||
per `adr-c-007.md`'s own note that it left this exact row for a later ticket).
|
||||
|
||||
## What changed
|
||||
|
||||
One row added to `libs/shared/docs/cibg-gaps.mdx`'s register table, matching the
|
||||
existing rows' two-column shape (component name, closest CIBG concept, reason —
|
||||
wording taken from the component's own marker comment, not invented):
|
||||
|
||||
| Component | Closest CIBG concept | Why hand-rolled |
|
||||
| ------------------- | -------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `language-switcher` | Taal instellen | No vendored Huisstijl class ships for it; a small hand-rolled surface built from the token bridge. |
|
||||
|
||||
No other row was touched. The 8 existing rows were each re-checked against their
|
||||
component's current marker comment while the file was open; none needed a change.
|
||||
|
||||
## Optional CI script: taken
|
||||
|
||||
ADR-C-008 flags a ~10-line `grep -l CIBG-GAP | diff`-style script folded into
|
||||
`check:tokens` as an explicitly optional second step. It is genuinely small and
|
||||
fits the existing script's shape, so `scripts/check-tokens.sh` gained one more
|
||||
guard after its existing hardcoded-colour check:
|
||||
|
||||
```bash
|
||||
gap_register='libs/shared/docs/cibg-gaps.mdx'
|
||||
markers=$(grep -rl 'CIBG-GAP EXTENSION' apps libs --include='*.component.ts' | xargs -n1 dirname | xargs -n1 basename | sort -u)
|
||||
rows=$(grep -oP '^\| `\K[^`]+' "$gap_register" | sort -u)
|
||||
missing=$(comm -23 <(echo "$markers") <(echo "$rows"))
|
||||
if [ -n "$missing" ]; then
|
||||
echo "$missing"
|
||||
echo "FAIL: CIBG-GAP EXTENSION marker(s) with no row in $gap_register"
|
||||
exit 1
|
||||
fi
|
||||
echo 'OK: every CIBG-GAP EXTENSION marker has a cibg-gaps.mdx row'
|
||||
```
|
||||
|
||||
The marker's component directory basename (`dirname` of the flagged file,
|
||||
`.component.ts` files only) is compared against the table's backtick-quoted
|
||||
first column, extracted with `grep -oP`. This matches for all 9 current
|
||||
markers, including the two rows with a parenthetical suffix
|
||||
(`` `wizard-shell` (error summary only) ``, `` `application-link` (non-navigating
|
||||
row) ``) — the regex stops at the closing backtick, before the parenthetical.
|
||||
|
||||
**Verified working, not just written.** Added a scratch component
|
||||
(`libs/shared/src/ui/scratch-gap-test/scratch-gap-test.component.ts`, a single
|
||||
`// CIBG-GAP EXTENSION:` line plus a dummy export) with no matching row. Ran
|
||||
`bash scripts/check-tokens.sh`: failed with exit 1, printing `scratch-gap-test`
|
||||
and the expected `FAIL:` line. Deleted the scratch file and its directory (`rm`,
|
||||
not `git checkout` — it was never tracked). Re-ran the script: passed, exit 0.
|
||||
`git status` confirms no trace of the scratch file remains.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run check:tokens` (includes the new guard): passes — 9 markers, 9 rows,
|
||||
after the register row was added.
|
||||
- `npm run ci`: result and step count reported in the implementing agent's final
|
||||
answer.
|
||||
- No code changed outside `libs/shared/docs/cibg-gaps.mdx`,
|
||||
`scripts/check-tokens.sh`, `99-backlog.md`, and this note — a doc-only ticket
|
||||
plus its optional, self-verifying guard script.
|
||||
@@ -0,0 +1,100 @@
|
||||
# RB-33 — `unwrapOk`: adopt or delete
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-011 ·
|
||||
`99-backlog.md` RB-33
|
||||
|
||||
## Decision: delete
|
||||
|
||||
The ticket names this "adopt or delete", not "adopt", and asks for the judgment call, not
|
||||
the default. I deleted `unwrapOk`.
|
||||
|
||||
## Why delete, not adopt
|
||||
|
||||
`unwrapOk` (`libs/shared/src/testing/value-object.ts`) has had zero consumers across the
|
||||
whole codebase since ADR-0006 shipped it, except its own definition and one sentence in
|
||||
`libs/shared/docs/testing.mdx`. I verified this before changing anything:
|
||||
|
||||
```
|
||||
grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx
|
||||
libs/shared/docs/testing.mdx:92: ...unwrapOk(parseX(raw))...
|
||||
libs/shared/src/testing/value-object.ts:9:export function unwrapOk<E, T>(...)
|
||||
libs/shared/src/testing/value-object.ts:11: throw new Error(`unwrapOk: ...`);
|
||||
```
|
||||
|
||||
The one call site the finding names,
|
||||
`apps/ssp/src/app/registratie/application/submit-change-request.spec.ts`, still has the
|
||||
exact hand-rolled guard the finding quotes:
|
||||
|
||||
```ts
|
||||
const telefoon = parseTelefoonnummer('0612345678');
|
||||
if (!telefoon.ok) throw new Error('fixture phone should parse');
|
||||
```
|
||||
|
||||
I also checked whether any other spec has the same shape, in case the finding's "one call
|
||||
site" undercounted the real duplication:
|
||||
|
||||
```
|
||||
grep -rln "if (!.*\.ok)\s*throw" apps libs --include=*.spec.ts
|
||||
apps/ssp/src/app/registratie/application/submit-change-request.spec.ts
|
||||
```
|
||||
|
||||
Only this one file, anywhere. There is no cast (`'x' as Telefoonnummer`) to close off
|
||||
either — the spec already calls the real `parseTelefoonnummer` and checks `.ok` before
|
||||
touching `.value`. ADR-0006 §3's actual requirement ("never a cast") is already met by the
|
||||
inline code, with or without the helper.
|
||||
|
||||
Weighing it honestly:
|
||||
|
||||
- **For adopt:** it is a one-line change, and the ADR's own worked example literally shows
|
||||
this exact call. Doing it would make the finding's "zero adopters" claim technically
|
||||
false.
|
||||
- **For delete:** a helper that gains its _only_ real-codebase consumer by an agent adding
|
||||
that one call site as an act of ticket compliance is not organic adoption — it is
|
||||
manufacturing a usage to justify keeping the file. `unwrapOk` has sat available, exported,
|
||||
and documented since ADR-0006 (well before this session) without a single spec reaching
|
||||
for it on its own. One caller, forever, is not "removing duplication" (the stated point
|
||||
of a shared test helper) — there is no duplication with only one occurrence. The inline
|
||||
guard is also arguably clearer here: its error message (`'fixture phone should parse'`)
|
||||
names the actual fixture, where `unwrapOk`'s generic message
|
||||
(`unwrapOk: expected ok, got error: ...`) does not.
|
||||
|
||||
Delete wins: it removes dead, unadopted code and its stale doc reference, changes no
|
||||
runtime behaviour anywhere, and costs nothing to reverse if a second real need for this
|
||||
idiom shows up later (three lines, trivial to re-add against actual duplication instead of
|
||||
a single hypothetical site).
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `libs/shared/src/testing/value-object.ts` | Deleted. Its only export, `unwrapOk`, is what this ticket removes; the file had nothing else in it. |
|
||||
| `libs/shared/docs/testing.mdx` | Rewrote the sentence that named `unwrapOk` and the deleted file's path. It now states the same rule in plain terms — call the real `parse*` and check `.ok`, never a cast — and keeps the `RemoteData` half of the sentence pointing at `remote-data.ts` (unchanged, still in use). |
|
||||
| `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` | **Not touched.** Its inline guard already satisfies ADR-0006 §3; this is the "delete" branch, so the fixture-construction behaviour stays exactly as it was. |
|
||||
| `99-backlog.md` | RB-33's status cell: `open` → `implemented`. |
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
`docs/reference/architecture/0006-test-data-builders.md` (the ADR itself) still shows
|
||||
`unwrapOk` in its worked example and decision table. That is deliberate: RB-33 is a code
|
||||
ticket, not one of the five ADR-fix tickets that need architect sign-off
|
||||
(`06-adr-conformance.md`'s "ADR-fix tickets" section). The ADR's illustrated pattern
|
||||
("call the real parser, unwrap through a checked path, never a cast") is still the correct
|
||||
principle — this ticket only removes one now-unused concrete implementation of it, which
|
||||
the inline guard in `submit-change-request.spec.ts` already satisfies without the named
|
||||
helper. Amending the ADR's own text is out of this ticket's scope and is left for a future
|
||||
ADR-fix ticket if one is ever raised. The finding document (`06-adr-conformance.md`) and the
|
||||
historical WP-70/WP-71 backlog notes that mention `unwrapOk` are left as-is — they are
|
||||
records of what was true when written, not living code.
|
||||
|
||||
No other file in `libs/shared/src/testing/` was touched (`expect-tag.ts`, `machine.ts`,
|
||||
`remote-data.ts` are all unrelated and still have real consumers).
|
||||
|
||||
## Verification
|
||||
|
||||
- `grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx` — zero occurrences.
|
||||
- `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` — unchanged file,
|
||||
still passes (see `npm run ci` result below).
|
||||
- No new test added. The ticket is a deletion of unused code plus a doc-sentence rewrite;
|
||||
the surviving inline guard in the spec is exercised the same way it always was, by the
|
||||
spec's three existing `it` blocks.
|
||||
- `npm run ci` (foreground): see the session report for the exit code and step count.
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
// Baseline scanner for 00-baseline.md: duplication % and C# cyclomatic complexity.
|
||||
// Both are the same crude text scan, so they share one file.
|
||||
//
|
||||
// ponytail: line-window hashing, not token-based like jscpd, and regex method
|
||||
// detection that does not understand C# expression-bodied members or nested
|
||||
// lambdas. Deterministic and zero-install, which is what a *baseline* needs.
|
||||
// Upgrade path: jscpd for duplication, a Roslyn analyzer for C# complexity —
|
||||
// only if a ticket's before/after needs more precision than "did it move".
|
||||
//
|
||||
// Usage: node baseline-scan.mjs [--dup] [--complexity] (default: both)
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const ROOT = new URL('../../../../..', import.meta.url).pathname.replace(/\/$/, '');
|
||||
const SKIP_DIR =
|
||||
/(^|\/)(node_modules|dist|coverage|bin|obj|\.angular|\.git|storybook-static.*|TestResults|public|openzaak)$/;
|
||||
const SKIP_FILE = /(api-client\.ts|\.Designer\.cs|AppDbContextModelSnapshot\.cs)$/;
|
||||
const SCAN = ['apps', 'libs', 'backend/src', 'backend/tests', 'e2e'];
|
||||
const WINDOW = 6; // duplicate = >= 6 consecutive normalized lines seen elsewhere
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const e of readdirSync(dir)) {
|
||||
const p = join(dir, e);
|
||||
if (SKIP_DIR.test(p)) continue;
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (/\.(ts|cs)$/.test(p) && !SKIP_FILE.test(p)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Module attribution: the unit the baseline table reports on.
|
||||
function moduleOf(rel) {
|
||||
let m;
|
||||
if ((m = rel.match(/^apps\/(ssp|behandelportal)\/src\/app\/([^/]+)\//)))
|
||||
return `${m[1] === 'ssp' ? 'ssp' : 'bhp'}/${m[2]}`;
|
||||
if (rel.startsWith('apps/ssp/')) return 'ssp/root';
|
||||
if (rel.startsWith('apps/behandelportal/')) return 'bhp/root';
|
||||
if ((m = rel.match(/^libs\/(shared|beheer)\/src\/([^/]+)\//))) return `libs/${m[1]}/${m[2]}`;
|
||||
if (rel.startsWith('libs/')) return rel.split('/').slice(0, 2).join('/');
|
||||
if (rel === 'backend/src/BigRegister.Api/Program.cs') return 'backend/Program.cs';
|
||||
if ((m = rel.match(/^backend\/src\/BigRegister\.Api\/([^/]+)\//))) return `backend/${m[1]}`;
|
||||
if (rel.startsWith('backend/tests/')) return 'backend/tests';
|
||||
if (rel.startsWith('e2e/')) return 'e2e';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const files = SCAN.flatMap((d) => {
|
||||
try {
|
||||
return walk(join(ROOT, d));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}).map((p) => ({ abs: p, rel: relative(ROOT, p) }));
|
||||
|
||||
// --- Duplication ------------------------------------------------------------
|
||||
// Normalize away formatting/comments, hash every WINDOW-line run, and mark a run
|
||||
// duplicated when its hash occurs in more than one place.
|
||||
function duplication() {
|
||||
const norm = new Map(); // rel -> [{line, text}]
|
||||
for (const f of files) {
|
||||
const kept = [];
|
||||
readFileSync(f.abs, 'utf8')
|
||||
.split('\n')
|
||||
.forEach((raw, i) => {
|
||||
const t = raw
|
||||
.replace(/\/\/.*$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (t.length > 3) kept.push({ line: i + 1, text: t });
|
||||
});
|
||||
norm.set(f.rel, kept);
|
||||
}
|
||||
|
||||
const seen = new Map(); // hash -> [{rel, idx}]
|
||||
for (const [rel, lines] of norm)
|
||||
for (let i = 0; i + WINDOW <= lines.length; i++) {
|
||||
const h = createHash('sha1')
|
||||
.update(
|
||||
lines
|
||||
.slice(i, i + WINDOW)
|
||||
.map((l) => l.text)
|
||||
.join('\n'),
|
||||
)
|
||||
.digest('hex');
|
||||
(seen.get(h) ?? seen.set(h, []).get(h)).push({ rel, idx: i });
|
||||
}
|
||||
|
||||
const dupLines = new Map(); // rel -> Set(line)
|
||||
const clones = new Map(); // "relA|relB" -> count
|
||||
for (const occ of seen.values()) {
|
||||
if (occ.length < 2) continue;
|
||||
for (const { rel, idx } of occ) {
|
||||
const set = dupLines.get(rel) ?? dupLines.set(rel, new Set()).get(rel);
|
||||
for (let k = 0; k < WINDOW; k++) set.add(norm.get(rel)[idx + k].line);
|
||||
}
|
||||
const pair = [...new Set(occ.map((o) => o.rel))].sort();
|
||||
if (pair.length > 1) {
|
||||
const key = pair.slice(0, 2).join(' | ');
|
||||
clones.set(key, (clones.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const per = new Map(); // module -> {total, dup}
|
||||
for (const [rel, lines] of norm) {
|
||||
const mod = moduleOf(rel);
|
||||
const e = per.get(mod) ?? per.set(mod, { total: 0, dup: 0 }).get(mod);
|
||||
e.total += lines.length;
|
||||
e.dup += dupLines.get(rel)?.size ?? 0;
|
||||
}
|
||||
|
||||
console.log('## Duplication (normalized lines, window=' + WINDOW + ')\n');
|
||||
console.log('| Module | Sig. lines | Duplicated | % |');
|
||||
console.log('|---|---:|---:|---:|');
|
||||
let T = 0,
|
||||
D = 0;
|
||||
for (const [mod, e] of [...per].sort((a, b) => b[1].dup / b[1].total - a[1].dup / a[1].total)) {
|
||||
T += e.total;
|
||||
D += e.dup;
|
||||
console.log(`| ${mod} | ${e.total} | ${e.dup} | ${((100 * e.dup) / e.total).toFixed(1)}% |`);
|
||||
}
|
||||
console.log(`| **TOTAL** | **${T}** | **${D}** | **${((100 * D) / T).toFixed(1)}%** |`);
|
||||
|
||||
console.log('\n### Top clone pairs (distinct duplicated windows)\n');
|
||||
for (const [pair, n] of [...clones].sort((a, b) => b[1] - a[1]).slice(0, 15))
|
||||
console.log(`- ${n} × — ${pair}`);
|
||||
}
|
||||
|
||||
// --- C# cyclomatic complexity (approximate) ---------------------------------
|
||||
// Two numbers per file. FILE CC (sum of decision points) is exact enough to trust.
|
||||
// Per-METHOD CC uses depth-aware regex detection and is the approximate one.
|
||||
const BRANCH =
|
||||
/\bif\s*\(|\bwhile\s*\(|\bfor\s*\(|\bforeach\s*\(|\bcase\s+|\bcatch\s*[({]|\?\?|&&|\|\||\?\.|\bwhen\s+/g;
|
||||
const TYPE_DECL =
|
||||
/^\s*(?:\[[^\]]*\]\s*)*(?:public|private|internal|protected|static|sealed|abstract|partial|file|\s)*\b(?:class|record|struct|interface|enum|namespace)\b/;
|
||||
const NOT_A_CALL =
|
||||
/^(if|for|foreach|while|switch|catch|using|lock|return|throw|new|await|yield|else|do|fixed|checked)$/;
|
||||
// A member: optional attrs/modifiers, a return type, a name, then `(`.
|
||||
const SIG =
|
||||
/^\s*(?:\[[^\]]*\]\s*)*(?:(?:public|private|internal|protected|static|async|override|virtual|sealed|partial|extern|new|unsafe)\s+)*[\w<>,\[\]?.]+\s+(\w+)\s*(?:<[^>()]*>)?\s*\(/;
|
||||
|
||||
function branchesIn(line) {
|
||||
return (line.replace(/\/\/.*$/, '').match(BRANCH) ?? []).length;
|
||||
}
|
||||
|
||||
function csComplexity() {
|
||||
const rows = [];
|
||||
const fileCc = [];
|
||||
for (const f of files.filter((f) => f.rel.endsWith('.cs'))) {
|
||||
const lines = readFileSync(f.abs, 'utf8').split('\n');
|
||||
let depth = 0,
|
||||
cur = null,
|
||||
total = 1,
|
||||
code = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
const bare = l.replace(/\/\/.*$/, '');
|
||||
const b = branchesIn(l);
|
||||
total += b;
|
||||
if (bare.trim().length > 1 && !/^\s*(\/\/|\/\*|\*)/.test(l)) code++;
|
||||
|
||||
const opens = (bare.match(/{/g) ?? []).length;
|
||||
const closes = (bare.match(/}/g) ?? []).length;
|
||||
|
||||
if (cur) {
|
||||
cur.lines++;
|
||||
cur.cc += b;
|
||||
// Expression-bodied member: `=> expr;` with no block of its own.
|
||||
if (cur.depth === null && /=>/.test(bare)) {
|
||||
if (/;\s*$/.test(bare) && opens === closes) {
|
||||
rows.push(cur);
|
||||
cur = null;
|
||||
} else if (opens > closes) cur.depth = depth;
|
||||
} else if (cur.depth === null && opens > closes) cur.depth = depth;
|
||||
else if (cur.depth !== null && depth + opens - closes <= cur.depth) {
|
||||
rows.push(cur);
|
||||
cur = null;
|
||||
}
|
||||
} else if (!TYPE_DECL.test(l)) {
|
||||
const m = SIG.exec(bare);
|
||||
// `Name(` must not be a call/keyword, and the line must not be a statement.
|
||||
if (
|
||||
m &&
|
||||
!NOT_A_CALL.test(m[1]) &&
|
||||
!/^\s*(var|return|await)\b/.test(bare) &&
|
||||
!/;\s*$/.test(bare.replace(/=>.*/, ''))
|
||||
)
|
||||
cur = {
|
||||
file: f.rel,
|
||||
name: m[1],
|
||||
line: i + 1,
|
||||
cc: 1 + b,
|
||||
lines: 1,
|
||||
depth: opens > closes ? depth : null,
|
||||
};
|
||||
}
|
||||
depth += opens - closes;
|
||||
}
|
||||
if (cur) rows.push(cur);
|
||||
fileCc.push({ file: f.rel, cc: total, code });
|
||||
}
|
||||
|
||||
const p = (arr, q) =>
|
||||
arr.slice().sort((a, b) => a - b)[Math.min(arr.length - 1, Math.floor(q * arr.length))] ?? 0;
|
||||
|
||||
const perFile = new Map();
|
||||
for (const r of fileCc) {
|
||||
const mod = moduleOf(r.file);
|
||||
(perFile.get(mod) ?? perFile.set(mod, []).get(mod)).push(r);
|
||||
}
|
||||
console.log('\n\n## C# complexity — per module\n');
|
||||
console.log(
|
||||
'| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 method CC | CC>10 |',
|
||||
);
|
||||
console.log('|---|---:|---:|---:|---:|---:|---:|---:|');
|
||||
const perMethod = new Map();
|
||||
for (const r of rows) {
|
||||
const mod = moduleOf(r.file);
|
||||
(perMethod.get(mod) ?? perMethod.set(mod, []).get(mod)).push(r);
|
||||
}
|
||||
for (const [mod, fs] of [...perFile].sort(
|
||||
(a, b) => Math.max(...b[1].map((r) => r.cc)) - Math.max(...a[1].map((r) => r.cc)),
|
||||
)) {
|
||||
const ms = perMethod.get(mod) ?? [];
|
||||
const ccs = ms.map((r) => r.cc);
|
||||
console.log(
|
||||
`| ${mod} | ${fs.length} | ${fs.reduce((a, r) => a + r.cc, 0)} | ${Math.max(...fs.map((r) => r.cc))} | ` +
|
||||
`${ms.length} | ${ccs.length ? Math.max(...ccs) : 0} | ${p(ccs, 0.9)} | ${ms.filter((r) => r.cc > 10).length} |`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n### C# files by CC (top 12)\n');
|
||||
console.log('| File CC | Code lines | File |');
|
||||
console.log('|---:|---:|---|');
|
||||
for (const r of fileCc.sort((a, b) => b.cc - a.cc).slice(0, 12))
|
||||
console.log(`| ${r.cc} | ${r.code} | ${r.file} |`);
|
||||
|
||||
console.log('\n### C# methods over CC 10 (approximate detection)\n');
|
||||
console.log('| CC | Lines | Method | Location |');
|
||||
console.log('|---:|---:|---|---|');
|
||||
for (const r of rows.filter((r) => r.cc > 10).sort((a, b) => b.cc - a.cc))
|
||||
console.log(`| ${r.cc} | ${r.lines} | \`${r.name}\` | ${r.file}:${r.line} |`);
|
||||
|
||||
const all = rows.map((r) => r.lines);
|
||||
console.log(
|
||||
`\nMethod-length distribution (n=${rows.length}): p50 ${p(all, 0.5)}, p90 ${p(all, 0.9)}, p99 ${p(all, 0.99)}, max ${Math.max(...all)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const all = args.length === 0;
|
||||
if (all || args.includes('--dup')) duplication();
|
||||
if (all || args.includes('--complexity')) csComplexity();
|
||||
+73
@@ -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)."
|
||||
Reference in New Issue
Block a user