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:
eho
2026-09-08 23:00:38 +02:00
co-authored by Claude Opus 5
parent 097e8468e0
commit 12f17d9d73
161 changed files with 154 additions and 24 deletions
@@ -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.
@@ -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.
@@ -0,0 +1,58 @@
# ADR-C-006 — extract the actor-agnostic route guards to `libs/shared`
Status: **implemented** · 2026-08-26 · Source finding: `06-adr-conformance.md` §ADR-C-006
## What changed
| File | Change |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `libs/shared/src/application/auth.guard.ts` | **new**`authGuard` + `capabilityGuard`, injecting `SESSION_PORT` instead of an app-local `SessionStore` |
| `libs/shared/src/application/auth.guard.spec.ts` | **new** — the single spec, provides `SESSION_PORT`; one case added asserting the guard reads the port |
| `libs/shared/src/application/session.port.ts` | widened by one member: `readonly isAuthenticated: Signal<boolean>` |
| `apps/ssp/src/app/auth/auth.guard.ts` | now a re-export |
| `apps/behandelportal/src/app/auth/auth.guard.ts` | now a re-export |
| `apps/{ssp,behandelportal}/src/app/auth/auth.guard.spec.ts` | **deleted** — both were byte-identical to the new shared spec |
Neither `app.routes.ts` was touched: both still `import { authGuard, capabilityGuard }
from '@auth/auth.guard'`. Routing asks the auth context for its guards, which is the
direction the boundary should read.
## Why the port widening was free
Both `SessionStore`s already exposed `readonly isAuthenticated = computed(() =>
this._session() !== null)` (`session.store.ts:40` in each app), and both apps already
registered `{ provide: SESSION_PORT, useExisting: SessionStore }`
(`app.config.ts:64` / `:65`). `SessionPort` is satisfied structurally, so adding the
member required no change in either app — the seam existed, it was just narrower than
what it already carried.
## Scope discipline
Only the guards moved. Per the finding, **no** ticket to merge `session.store.ts`,
`session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and no
relaxation of ADR-0002 §3. Those five are identical because ADR-C-004 (`Session →
Principal`) was never executed; merging them would cement a citizen DigiD/BSN login as
the backoffice's shared login, which is the outcome §3 exists to prevent.
## Measured effect
Re-ran `tools/baseline-scan.mjs --dup` after the change:
| Metric | Before | After |
| --------------------------- | -----: | --------: |
| `ssp/auth` duplicated lines | 211 | **151** |
| `bhp/auth` duplicated % | 86.8% | **82.5%** |
| Repo-wide duplication | 7.1% | **6.6%** |
The `auth.guard.spec.ts` (36 windows) and `auth.guard.ts` (21 windows) clone pairs have
dropped out of the top-clones list entirely. The remaining `ssp/auth``bhp/auth`
duplication is `session.store.ts` (39), `login-form.component.ts` (35) and `login.page.ts`
(23) — exactly the three ADR-C-004 is expected to differentiate. Re-measure BL-002 after
that lands; the finding's expectation is a drop to under 40 lines.
## Verification
`npm run lint` · `npm run typecheck` · `npm run dep:check` (0 violations, 224 modules)
· `npx prettier --check apps libs` — all clean.
Tests: shared 122, ssp 235, behandelportal 27, beheer 23 — **407 passed, 0 failed**.
`ng build --localize` for both apps.
@@ -0,0 +1,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.
@@ -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.