`,
})
export class LoginFormComponent {
- bsn = '';
- password = '';
- submitted = output();
+ submitted = output();
}
diff --git a/apps/behandelportal/src/app/auth/ui/login.page.ts b/apps/behandelportal/src/app/auth/ui/login.page.ts
index 59671f9..aca9da5 100644
--- a/apps/behandelportal/src/app/auth/ui/login.page.ts
+++ b/apps/behandelportal/src/app/auth/ui/login.page.ts
@@ -1,36 +1,35 @@
-import { Component, inject, signal } from '@angular/core';
+import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
-import { AlertComponent } from '@shared/ui/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store';
+/**
+ * No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
+ * nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
+ * this page would grow one back.
+ */
@Component({
selector: 'app-login-page',
- imports: [PageShellComponent, AlertComponent, LoginFormComponent],
+ imports: [PageShellComponent, LoginFormComponent],
template: `
- @if (error()) {
- {{ error() }}
- }
-
+
`,
})
export class LoginPage {
private store = inject(SessionStore);
private router = inject(Router);
- error = signal('');
- async login(bsn: string) {
- const r = await this.store.login(bsn);
- if (r.ok) this.router.navigate(['/dashboard']);
- else this.error.set(r.error);
+ async login() {
+ await this.store.login();
+ this.router.navigate(['/dashboard']);
}
}
diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf
index 6977b09..e538c31 100644
--- a/apps/behandelportal/src/locale/messages.en.xlf
+++ b/apps/behandelportal/src/locale/messages.en.xlf
@@ -26,65 +26,33 @@
27
-
- * verplichte velden
- * required fields
+
+ U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
+ You sign in through your organization's SSO — no password is needed.src/app/auth/ui/login-form/login-form.component.ts
- 15,18
-
-
- src/app/registratie/ui/change-request-form/change-request-form.component.ts
- 44,46
-
-
- src/app/shared/layout/wizard-shell/wizard-shell.component.ts
- 90,92
-
-
-
- BSN
- BSN
-
- src/app/auth/ui/login-form/login-form.component.ts
- 22,23
-
-
-
- 9-cijferig BSN, elfproef-geldig (demo: 123456782)
- 9-digit BSN, valid eleven-test checksum (demo: 123456782)
-
- src/app/auth/ui/login-form/login-form.component.ts
- 25,28
-
-
-
- Wachtwoord
- Password
-
- src/app/auth/ui/login-form/login-form.component.ts
- 36,37
+ 17,19
- Inloggen met DigiD
- Log in with DigiD
+ Inloggen met SSO
+ Log in with SSOsrc/app/auth/ui/login-form/login-form.component.ts
- 41,43
+ 20,21
- Inloggen
- Log in
+ Inloggen bij het behandelportal
+ Log in to the treatment portalsrc/app/auth/ui/login.page.ts14,16
- Log in op uw persoonlijke BIG-register omgeving.
- Log in to your personal BIG register environment.
+ Voor medewerkers die aanvragen beoordelen.
+ For staff who assess applications.src/app/auth/ui/login.page.ts17,19
diff --git a/apps/ssp/src/app/auth/application/session.store.ts b/apps/ssp/src/app/auth/application/session.store.ts
index 88ed651..dd891b1 100644
--- a/apps/ssp/src/app/auth/application/session.store.ts
+++ b/apps/ssp/src/app/auth/application/session.store.ts
@@ -1,48 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
-import { Session, parseStoredSession } from '../domain/session';
+import { Principal, parseStoredPrincipal } from '../domain/principal';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
-/** Restore a persisted session (best-effort; corrupt entry → logged out).
- The parse + shape validation (G1/G2) lives in `parseStoredSession`
- (`../domain/session`) — pure, spec'd, and testable without stubbing
+/** Restore a persisted principal (best-effort; corrupt entry → logged out).
+ The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
+ (`../domain/principal`) — pure, spec'd, and testable without stubbing
`localStorage`; this just supplies the raw value. */
-function restore(): Session | null {
- return parseStoredSession(localStorage.getItem(STORAGE_KEY));
+function restore(): Principal | null {
+ return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
}
/**
- * Holds the current session for the whole app. Because it is providedIn:'root'
- * there is exactly one instance — every component that injects it sees the same
- * session signal, so logging in is instantly visible everywhere (the guard, the
- * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
- * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
- * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
- * sessionStorage's per-tab clearing dropped the login on the cross-bundle language
- * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
- * in an httpOnly cookie/token, not web storage.
+ * Holds the current zorgverlener principal for the whole SSP. One
+ * `providedIn: 'root'` instance, so logging in is instantly visible everywhere
+ * (the guard, the header). Persisted to localStorage — a refresh or the
+ * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
+ * but never the BSN itself (G1 in the `effect` below): this principal carries a
+ * citizen's national identifier, which the behandelportal's equivalent store does
+ * not have to guard against, because its `medewerker` principal has no BSN.
+ * ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
+ * dropped the login on the cross-bundle language switch. Trade-off: the demo
+ * session now survives tab close; a real portal keeps auth in an httpOnly
+ * cookie/token, not web storage.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
- private _session = signal(restore());
+ private _session = signal(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
- const s = this._session();
+ const p = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
- if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
+ if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
else localStorage.removeItem(STORAGE_KEY);
});
}
- /** Effectful command: authenticate, then store the session on success. */
- async login(bsn: string): Promise> {
+ /** Effectful command: authenticate, then store the principal on success. */
+ async login(bsn: string): Promise> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
diff --git a/apps/ssp/src/app/auth/domain/principal.spec.ts b/apps/ssp/src/app/auth/domain/principal.spec.ts
new file mode 100644
index 0000000..386111a
--- /dev/null
+++ b/apps/ssp/src/app/auth/domain/principal.spec.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { isAuthenticated, parseStoredPrincipal, Principal } from './principal';
+
+const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' };
+
+describe('isAuthenticated', () => {
+ it('narrows a present principal to Principal', () => {
+ expect(isAuthenticated(principal)).toBe(true);
+ });
+
+ it('reports no principal as not authenticated', () => {
+ expect(isAuthenticated(null)).toBe(false);
+ });
+});
+
+describe('parseStoredPrincipal', () => {
+ it('returns null when nothing is stored', () => {
+ expect(parseStoredPrincipal(null)).toBeNull();
+ });
+
+ it('returns null for a non-JSON string', () => {
+ expect(parseStoredPrincipal('not json')).toBeNull();
+ });
+
+ it('returns null when the stored shape is wrong (no naam)', () => {
+ expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
+ });
+
+ it('G1: a stored bsn is never restored, even if present in the raw value', () => {
+ const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
+ expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' });
+ });
+});
diff --git a/apps/ssp/src/app/auth/domain/principal.ts b/apps/ssp/src/app/auth/domain/principal.ts
new file mode 100644
index 0000000..3672e07
--- /dev/null
+++ b/apps/ssp/src/app/auth/domain/principal.ts
@@ -0,0 +1,39 @@
+/**
+ * Who is logged in. Framework-free domain type.
+ *
+ * The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
+ * one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
+ * the union only ever holds this one member. `kind` is still a discriminant, not
+ * decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
+ * genuinely different type rather than a same-shaped coincidence, and what a future
+ * third actor (§4 — admin/auditor/institution-rep) would add a member to.
+ */
+export interface Principal {
+ readonly kind: 'zorgverlener';
+ readonly bsn: string;
+ readonly naam: string;
+}
+
+export function isAuthenticated(p: Principal | null): p is Principal {
+ return p !== null;
+}
+
+/**
+ * Parse a persisted principal out of a raw `localStorage` string (best-effort;
+ * anything that isn't a well-shaped record → logged out). G2: validate the
+ * shape before trusting it. G1: even if a stored entry carries a `bsn`, the
+ * restored principal's `bsn` is always `''` — the BSN is never persisted (see
+ * the `SessionStore` effect that writes it), so a legacy or tampered entry
+ * cannot resurrect one.
+ */
+export function parseStoredPrincipal(raw: string | null): Principal | null {
+ try {
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial;
+ return typeof parsed?.naam === 'string'
+ ? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
+ : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/ssp/src/app/auth/domain/session.spec.ts b/apps/ssp/src/app/auth/domain/session.spec.ts
deleted file mode 100644
index af90034..0000000
--- a/apps/ssp/src/app/auth/domain/session.spec.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { isAuthenticated, parseStoredSession, Session } from './session';
-
-const session: Session = { bsn: '19012345601', naam: 'Test' };
-
-describe('isAuthenticated', () => {
- it('narrows a present session to Session', () => {
- expect(isAuthenticated(session)).toBe(true);
- });
-
- it('reports no session as not authenticated', () => {
- expect(isAuthenticated(null)).toBe(false);
- });
-});
-
-describe('parseStoredSession', () => {
- it('returns null when nothing is stored', () => {
- expect(parseStoredSession(null)).toBeNull();
- });
-
- it('returns null for a non-JSON string', () => {
- expect(parseStoredSession('not json')).toBeNull();
- });
-
- it('returns null when the stored shape is wrong (no naam)', () => {
- expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
- });
-
- it('G1: a stored bsn is never restored, even if present in the raw value', () => {
- const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
- expect(restored).toEqual({ bsn: '', naam: 'Test' });
- });
-});
diff --git a/apps/ssp/src/app/auth/domain/session.ts b/apps/ssp/src/app/auth/domain/session.ts
deleted file mode 100644
index abbbbf6..0000000
--- a/apps/ssp/src/app/auth/domain/session.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/** Who is logged in. Framework-free domain type. */
-export interface Session {
- readonly bsn: string;
- readonly naam: string;
-}
-
-export function isAuthenticated(s: Session | null): s is Session {
- return s !== null;
-}
-
-/**
- * Parse a persisted session out of a raw `localStorage` string (best-effort;
- * anything that isn't a well-shaped record → logged out). G2: validate the
- * shape before trusting it. G1: even if a stored entry carries a `bsn`, the
- * restored session's `bsn` is always `''` — the BSN is never persisted (see
- * the `SessionStore` effect that writes it), so a legacy or tampered entry
- * cannot resurrect one.
- */
-export function parseStoredSession(raw: string | null): Session | null {
- try {
- if (!raw) return null;
- const parsed = JSON.parse(raw) as Partial;
- return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
- } catch {
- return null;
- }
-}
diff --git a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts
index a4956d0..652622c 100644
--- a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts
+++ b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
-import { Session } from '../domain/session';
+import { Principal } from '../domain/principal';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
@@ -9,8 +9,8 @@ export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
- async authenticate(bsn: string): Promise> {
+ async authenticate(bsn: string): Promise> {
const r = parseBsn(bsn);
- return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
+ return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
diff --git a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts
index 8a6f190..b3faf88 100644
--- a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts
+++ b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts
@@ -1,7 +1,7 @@
import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { SessionStore } from '@auth/application/session.store';
-import { Session } from '@auth/domain/session';
+import { Principal } from '@auth/domain/principal';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { map } from '@shared/application/remote-data';
import { Role } from '@shared/domain/role';
@@ -172,6 +172,6 @@ export class DebugStateComponent {
}
}
-function maskSession(s: Session | null): Session | null {
- return s ? { ...s, bsn: maskBsn(s.bsn) } : null;
+function maskSession(p: Principal | null): Principal | null {
+ return p ? { ...p, bsn: maskBsn(p.bsn) } : null;
}
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md
new file mode 100644
index 0000000..f2c2185
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md
@@ -0,0 +1,186 @@
+# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form
+
+Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13
+
+## What was wrong
+
+ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated
+`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker';
+medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands."
+Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow.
+
+Verified before this ticket:
+
+- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in
+ `libs/shared/src/infrastructure/role.ts:8`. No such type existed.
+- `apps/ssp/src/app/auth/domain/session.ts` and
+ `apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical:
+ `interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar
+ carrying a `bsn`, which §3 names as precisely the state the union exists to make
+ unrepresentable.
+- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw
+persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` →
+ `DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de
+Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD,
+ under a citizen's name.
+- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already
+ stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of
+ `SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door
+ instead of the `Principal` union, which is why the two `auth` contexts still measured
+ as identical.
+- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after
+ ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%),
+ `bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's
+ "auth stays duplicated because it's expected to diverge" claim had never actually been
+ tested, only asserted.
+
+RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider`
+able to say "no identity" and fail closed; this ticket is its stated FE half — without
+it, a production behandelportal falls through to the seeded zorgverlener by default,
+open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket
+does not touch that backend behaviour — it makes the FE identity model honest about
+who is actually authenticating.
+
+## What changed
+
+| File | Change |
+| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `apps/ssp/src/app/auth/domain/session.ts` → `principal.ts` | `Session` → `Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession` → `parseStoredPrincipal` (G1/G2 unchanged) |
+| `apps/ssp/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape |
+| `apps/ssp/src/app/auth/application/session.store.ts` | `Session` → `Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) |
+| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` |
+| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session` → `Principal` (the one other consumer of the domain type) |
+| `apps/behandelportal/src/app/auth/domain/session.ts` → `principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) |
+| `apps/behandelportal/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) |
+| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts` → `medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) |
+| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it |
+| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output()` |
+| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) |
+| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) |
+| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) |
+| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure |
+| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases |
+
+## Judgement calls
+
+- **Each app's `Principal` holds only the one variant it has an actor for**, not the
+ full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own
+ proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`:
+ replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener`
+ variant"), and it matches how the codebase already splits `auth` per app. `kind` stays
+ on both single-member types anyway — it is what makes the two types genuinely
+ different rather than a same-shaped coincidence, and it is where a third actor (§4 —
+ admin/auditor/institution-rep) would add a member.
+- **`MedewerkerAdapter.authenticate()` returns `Promise`, not
+ `Promise>`.** The first draft mirrored `DigidAdapter`'s
+ `Result`-returning shape for symmetry, but that `Result`'s error variant could never
+ actually be produced — there is no credential to check, so wrapping the return in a
+ type that claims to have a failure mode was itself a small instance of the thing
+ CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a
+ direct `Promise` and dropped the now-dead error-handling branch from
+ `login.page.ts` (`error` signal, the ``, the `AlertComponent`
+ import) — a real SSO integration is where that branch would come back, not before.
+ This was also the change that did the most to bring the duplication figure down (see
+ below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two
+ pages' control flow, not just their copy, actually differed.
+- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen`
+ lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an
+ unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed
+ `Rol[]` is pure string logic with no Angular dependency, so it belongs in
+ `domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`)
+ stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and
+ hands them to a pure function. `parseRollen` deliberately mirrors the backend's own
+ `StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so
+ `?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule
+ (ADR-0001's boundary is about _authorization decisions_, which still come only from
+ `GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the
+ same header value it is about to send, for display, the same way `DigidAdapter`
+ already fabricates its own fake identity.
+- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a
+ stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN")
+ doesn't apply here — a `medewerker` principal has no national identifier — so there is
+ nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and
+ restores it as-is. This was a deliberate choice against an alternative: reconstructing
+ `medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every
+ restore, which would have made `domain/principal.ts` depend on
+ `infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency
+ rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing
+ `?rollen=` mid-session does not retroactively change an already-restored `Principal`
+ until the next `login()`/`logout()` — the same way changing the DigiD demo BSN
+ requires a fresh login in the SSP. The backend's own authorization is unaffected
+ either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP
+ request regardless of what `SessionStore` holds.
+- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left
+ unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside
+ `auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change,
+ not as something the change renames. `libs/shared/src/application/session.port.ts`'s
+ `SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and
+ `isAuthenticated`, neither of which is `kind`-dependent.
+- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments
+ updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why
+ `?subject=` exists instead of reading the store directly; renaming the type these
+ comments describe without updating the comment would have left them citing a type
+ that no longer exists.
+- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is
+ explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it
+ reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for
+ this ticket to change there.
+- **No backend change.** RB-09 already made `IIdentityProvider` nullable and
+ Production-fail-fast; this ticket is purely the frontend counterpart it named. The
+ residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation
+ callers carrying no identity header once a real, non-stub `IIdentityProvider` exists)
+ is unaffected by anything here — it is about a _future_ real provider replacing the
+ Development-only stub, which this ticket does not touch.
+
+## Duplication, measured (`tools/baseline-scan.mjs --dup`)
+
+| When | `ssp/auth` dup lines | `bhp/auth` dup lines |
+| ----------------------------------- | -------------------: | -------------------: |
+| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — |
+| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) |
+| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** |
+
+Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under
+target. The full clone-pair listing (the script's own output truncates to the top 15
+pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was
+hiding below that cut) resolves to exactly four remaining pairs:
+
+- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before
+ trusting a stored shape" concept with a parallel `describe`/`it` structure (including
+ the shared `import { describe, it, expect } from 'vitest';` line); the assertions
+ themselves differ (BSN-stripping vs. kind/rollen validation).
+- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default`
+ scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject.
+- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is
+ meant to stay identical.
+- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small
+ shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root
+ singleton store in this codebase shares).
+
+None of what remains is re-converged identity or login-flow logic — the domain type,
+the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction
+("the two groups authenticate differently") has been tested for the first time by this
+ticket, not just asserted, and it held.
+
+## Verification
+
+Confirmed each non-trivial change is red without its fix (edited in place, verified red,
+edited back — never `git checkout`):
+
+- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''` →
+ `G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …}
+to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected.
+- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'`
+ clause → `returns null when kind is not medewerker` failed, returning the parsed
+ zorgverlener-shaped object instead of `null`. Reverted.
+- **bhp `parseRollen`:** dropped `.filter(isRol)` → `drops unrecognized tokens` and
+ `returns an empty list for an empty string` both failed (`['geen']`/`['']` returned
+ instead of `[]`). Reverted.
+
+`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) +
+133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs.
+`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp
+--localize` and `ng build behandelportal --localize`: both succeed (the new
+`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro`
+sources all resolve to an English ``). `npm run ci`: green (see the commit this
+doc ships with).
diff --git a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md
index 67774bc..f4c12e6 100644
--- a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md
+++ b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md
@@ -1,6 +1,6 @@
# ADR 0002 — User groups as actors, not bounded contexts
-Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67)
+Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13)
## Problem
@@ -167,28 +167,34 @@ status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67):
`AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`),
`Domain/Authorization/Authz.cs`.
-## Known debt: `Session → Principal` was never built
+A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid
+off by RB-13 the next day. See the amendment below for the historical record and what
+landed.
-§3's `Principal` union is the one decision here that has **not** been executed, and it is now
-debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union
-did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in
-`libs/shared/src/infrastructure/role.ts`. There is no such type.
+## Amendment (RB-13, 2026-08-27): `Session → Principal` landed
-What that omission actually costs, measured 2026-08-26:
+§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the
+"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit
+(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the
+`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`);
+`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant
+(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one
+member of the union it actually has an actor for, per this ADR's own proposed resolution.
+`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the
+dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` —
+unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into
+a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN
+field) rather than the citizen DigiD form it used to share with the SSP verbatim.
-- `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical —
- `diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being
- two extra files in behandelportal.
-- `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`.
- A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent.
-- The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`,
- a dev-only `X-Medewerker` header stamp that never touches `Session`.
-
-The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is
-"expected to diverge". That reasoning still holds — but it has never been **tested**, because
-the change that would test it is this one. Read the two identical copies as evidence that
-§3 is unexecuted, not as evidence that §3 was wrong.
-
-ponytail: this ADR draws the boundaries so nothing has to be undone later. The original
-"YAGNI until the backoffice work starts" call was right when written and has now expired —
-the backoffice started. `Principal` is owed.
+The two `auth` contexts, measured 2026-08-27 after the change
+(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the
+2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What
+remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional
+verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope)
+plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj`
+scaffold) that any two spec or story files share regardless of subject. The prediction in
+§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would
+turn out to authenticate differently enough that sharing `auth` would have been the wrong
+call — has now actually been tested, not just asserted, and held: the two contexts diverge
+in domain type, adapter, and login UI as soon as the union exists to make that
+divergence possible.
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index 37de06e..6f265f5 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
-**is** the suite, reshaped for a business reader. 440 frontend behaviours across
+**is** the suite, reshaped for a business reader. 446 frontend behaviours across
9 contexts; 231 backend behaviours across 39 test
classes.
@@ -30,17 +30,26 @@ classes.
#### isAuthenticated
-- narrows a present session to Session
-- reports no session as not authenticated
-- narrows a present session to Session
-- reports no session as not authenticated
+- narrows a present principal to Principal
+- reports no principal as not authenticated
+- narrows a present principal to Principal
+- reports no principal as not authenticated
-#### parseStoredSession
+#### parseRollen
+
+- parses a single recognized rol
+- is case-insensitive and trims whitespace
+- drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)
+- returns an empty list for an empty string
+
+#### parseStoredPrincipal
- returns null when nothing is stored
- returns null for a non-JSON string
- returns null when the stored shape is wrong (no naam)
-- G1: a stored bsn is never restored, even if present in the raw value
+- returns null when kind is not medewerker
+- returns null when rollen holds an unrecognized token
+- restores a well-shaped stored principal as-is (no BSN to strip)
- returns null when nothing is stored
- returns null for a non-JSON string
- returns null when the stored shape is wrong (no naam)
diff --git a/libs/shared/src/infrastructure/subject.interceptor.ts b/libs/shared/src/infrastructure/subject.interceptor.ts
index 9890495..a25e002 100644
--- a/libs/shared/src/infrastructure/subject.interceptor.ts
+++ b/libs/shared/src/infrastructure/subject.interceptor.ts
@@ -12,7 +12,7 @@ import { currentSubject } from './subject';
* middleware resolves a `CallerIdentity` for every request, not just some endpoints.
*
* **BSN source — a deliberate compromise, read before changing:** the "obvious"
- * source would be the authenticated `Session.bsn` held by each app's own
+ * source would be the authenticated `Principal.bsn` held by each app's own
* `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context
* (the import-direction rule), and the one sanctioned cross-context seam —
* `SessionPort` (`@shared/application/session.port`) — deliberately exposes only
diff --git a/libs/shared/src/infrastructure/subject.ts b/libs/shared/src/infrastructure/subject.ts
index ec64d7f..0d261aa 100644
--- a/libs/shared/src/infrastructure/subject.ts
+++ b/libs/shared/src/infrastructure/subject.ts
@@ -3,7 +3,7 @@ import { isDevMode } from '@angular/core';
/**
* Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see
* `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This
- * POC has no real DigiD identity — `Session.bsn` lives only in each app's own
+ * POC has no real DigiD identity — `Principal.bsn` lives only in each app's own
* in-memory `SessionStore` and is deliberately never persisted (see that store's G1
* comment) — so `subject.interceptor.ts` can't reach it without a layering
* violation (`libs/shared` may not depend on an app-local `auth` context). Instead a
From bc5b2c4b2dfd65309f11146915c1ea5566b973ee Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 17:01:41 +0200
Subject: [PATCH 37/61] docs(backlog): CD batch 3 complete
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All six merged, gate green (14 steps, backend 260/260). Records the three
tickets that could not be built as written — RB-12's wrapper/public binary
does not fit the route table, RB-14's command exits 0 on a High advisory, and
RB-15 needed a third environment name because RB-09 makes Production fail to
boot — plus RB-13's measured duplication drop (168 -> 32 lines per side).
Adds a section on dispatching implementation agents. Four of six agent-runs
were handed a worktree branched from a stale ancestor; batch 3 was three for
three. That, the background-task parking, and the git-checkout-destroys-work
trap are all cheap to prevent in the prompt and expensive to discover.
Co-Authored-By: Claude Opus 5
---
.../refactor-backlog/99-backlog.md | 12 ++---
.../refactor-backlog/_status.md | 48 +++++++++++++++----
2 files changed, 46 insertions(+), 14 deletions(-)
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index 13a945f..fcd230b 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -113,12 +113,12 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
index 10535bc..4bc5e83 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
@@ -14,14 +14,14 @@
## Phase 3 — implementation
-| CD batch | Tickets | Status | Notes |
-| -------- | ---------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
-| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
-| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | not started | RB-13 depends on RB-09. |
-| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. |
-| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. |
-| 6 | RB-31, RB-32, RB-33 | not started | |
+| CD batch | Tickets | Status | Notes |
+| -------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
+| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
+| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
+| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. |
+| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. |
+| 6 | RB-31, RB-32, RB-33 | not started | |
**Standing caveat for every batch:** `dotnet test` reports one failure,
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
@@ -47,3 +47,35 @@ Fixed in `build: stop ci-local.sh swallowing the first half of every paired step
weaker than it reads; the batch-2 completion run above is the first one made on the honest gate
(13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been
re-verified under the fixed gate either.
+
+## Dispatching implementation agents — what actually goes wrong
+
+Batches 2 and 3 ran tickets as parallel agents in git worktrees. Six of seven agent-runs hit at
+least one of these. Put all of it in the prompt.
+
+1. **The worktree base is not reliable.** **Four of the six** agents were handed a worktree
+ branched from a stale ancestor — batch 3 was **three for three**, all landing on `ae7781e`,
+ an unrelated lineage missing every RB ticket _and_ this backlog directory. Make step zero:
+ `git log --oneline -8`, confirm a **named expected commit**, `git merge` the target branch if
+ absent, and report which it was. The one agent that was not told to do this found out by luck.
+2. **Agents park on background tasks.** Two agents in batch 2 ran `npm run ci` in the background,
+ then ended their turn waiting for a notification that never usefully arrived; one finished its
+ work twice and never committed it. Ban `run_in_background` and Monitor, **and say explicitly to
+ pass the Bash tool's own `timeout: 600000`** — a batch-3 agent still auto-backgrounded because
+ "run it in the foreground" alone does not defeat the 120s default.
+3. **`git checkout ` destroys the work.** Agents verify a test is red by undoing the fix;
+ restoring it with `git checkout` reverts the whole file. Tell them to undo and redo with edits.
+4. **`behaviour-spec.mdx` conflicts on nearly every merge.** It is generated and every agent
+ regenerates it. Resolve centrally with `npm run gen:behaviour-spec && git add`; tell agents to
+ expect it and never hand-edit.
+5. **Concurrent `dotnet test` runs flake** — a burst of `SQLite Error 1: 'no such table: Documents'`.
+ Partly host pressure, but RB-12 found a real cause: a bare `new WebApplicationFactory()`
+ races on the static `Db.ConnectionString`. Use the house `TestWebApplicationFactory` +
+ `IClassFixture` idiom. Do not trust a backend green while another agent is testing.
+6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by
+ ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`.
+
+**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong
+about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and
+RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review
+of the diff alone.
From 7fbac8fca57db5c0e9a2593851c8a622fb82ad6f Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 17:03:37 +0200
Subject: [PATCH 38/61] docs: write English prose in Simplified Technical
English
Adds a Conventions rule for Simplified Technical English (ASD-STE100). It
covers documentation, code comments, commit messages, ADRs, and the backlog
notes. STE is a controlled language. It makes text easy to read for people
who do not have English as a first language, and easy to translate. The
readers of this project are mostly non-native English readers.
The rule states that STE governs form, not content. Split a long sentence.
Never remove a caveat, a measurement, or a precise term to make text shorter.
The rule does not apply to Dutch identifiers, $localize copy, quoted output,
or existing documents that you are not already editing. It therefore does not
change the Naming convention above it, which keeps domain contexts in Dutch.
Co-Authored-By: Claude Opus 5
---
CLAUDE.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 88fa614..8bc7e7f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -214,6 +214,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on.
+- **English prose uses Simplified Technical English (STE).** This covers documentation,
+ code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
+ 20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
+ One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
+ articles ("the test fails"). Three nouns together at most. No idioms and no humour.
+ Six sentences per paragraph at most. Write a procedure as numbered steps, one action
+ per step.
+ **STE governs form, not content.** Split a long sentence; never drop a caveat, a
+ measurement, or a precise term to make it shorter.
+ **STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
+ existing documents you are not already editing.
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
first-party `$localize` (no third-party i18n lib), with a stable custom id
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
From d518a1466c0509169e2054e807c9bf29347e9c30 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 18:23:13 +0200
Subject: [PATCH 39/61] refactor(registratie): extract the read half of
createDraftSync (RB-21)
createDraftSync mixed a read path (findConcept, load, the read half of
resume) with its write path (ensureId, flush, submit, reset) in one
187-line function -- CQ-001's finding. Move findConcept and loadConcept
into a new application/find-concept.ts as free functions that take the
adapter, so they get a direct spec with no Angular TestBed.
createDraftSync keeps the closure state (id, ensuring, resumeGate) and
the whole write path unchanged -- this is a move, not a redesign. The
resumeGate coupling that lets the write path wait for the read path
stays exactly where it was.
createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is
unchanged -- it never called resume()/load() directly, and its 409
recovery test for submit() still exercises the extracted findConcept
through ensureId's catch branch.
Co-Authored-By: Claude Opus 5
---
.../app/registratie/application/draft-sync.ts | 42 ++-----
.../application/find-concept.spec.ts | 108 ++++++++++++++++
.../registratie/application/find-concept.ts | 48 +++++++
.../refactor-backlog/99-backlog.md | 70 +++++------
.../refactor-backlog/implementation/rb-21.md | 119 ++++++++++++++++++
libs/shared/docs/behaviour-spec.mdx | 17 ++-
6 files changed, 337 insertions(+), 67 deletions(-)
create mode 100644 apps/ssp/src/app/registratie/application/find-concept.spec.ts
create mode 100644 apps/ssp/src/app/registratie/application/find-concept.ts
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md
diff --git a/apps/ssp/src/app/registratie/application/draft-sync.ts b/apps/ssp/src/app/registratie/application/draft-sync.ts
index ff533dd..eec4426 100644
--- a/apps/ssp/src/app/registratie/application/draft-sync.ts
+++ b/apps/ssp/src/app/registratie/application/draft-sync.ts
@@ -8,10 +8,8 @@ import type {
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
-import {
- ApplicationsAdapter,
- parseApplications,
-} from '@registratie/infrastructure/applications.adapter';
+import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
+import { findConcept, loadConcept } from './find-concept';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot {
@@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
// server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure.
.catch(async (e) => {
- const existing = await findConcept();
+ const existing = await findConcept(adapter, deps.type);
if (existing) return existing;
throw e;
})
@@ -140,32 +138,14 @@ export function createDraftSync(deps: DraftSyncDeps) {
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
const load = (linked: string): Promise => {
id = linked;
- return adapter
- .detail(linked)
- .then((dto) => {
- if (dto.status && dto.status.tag !== 'Concept') {
- id = undefined;
- applyResume(null);
- return;
- }
- applyResume(dto.draft ?? null);
- })
- .catch(() => {
+ return loadConcept(adapter, linked).then((result) => {
+ if (result.tag === 'not-concept') {
id = undefined;
- applyResume(null); // unknown/deleted id → start fresh
- });
- };
-
- // Find the user's existing Concept of this type (at most one), if any.
- const findConcept = async (): Promise => {
- try {
- const parsed = parseApplications(await adapter.list());
- return parsed.ok
- ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
- : undefined;
- } catch {
- return undefined;
- }
+ applyResume(null);
+ return;
+ }
+ applyResume(result.draft);
+ });
};
return {
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
await load(linked);
return;
}
- const existing = await findConcept();
+ const existing = await findConcept(adapter, deps.type);
if (existing) {
await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept.
diff --git a/apps/ssp/src/app/registratie/application/find-concept.spec.ts b/apps/ssp/src/app/registratie/application/find-concept.spec.ts
new file mode 100644
index 0000000..c617ad0
--- /dev/null
+++ b/apps/ssp/src/app/registratie/application/find-concept.spec.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from 'vitest';
+import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
+import { findConcept, loadConcept } from './find-concept';
+
+// Free functions taking the adapter as a parameter (no inject()) — a plain fake
+// object is enough, no Angular TestBed needed.
+function fakeAdapter(overrides: Partial): ApplicationsAdapter {
+ return overrides as ApplicationsAdapter;
+}
+
+describe('findConcept', () => {
+ it('returns the id of the existing Concept of the given type', async () => {
+ const adapter = fakeAdapter({
+ list: async () => [
+ {
+ id: 'a1',
+ type: 'registratie',
+ status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ],
+ });
+
+ await expect(findConcept(adapter, 'registratie')).resolves.toBe('a1');
+ });
+
+ it('returns undefined when the list has no application of the given type', async () => {
+ const adapter = fakeAdapter({ list: async () => [] });
+
+ await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
+ });
+
+ it('returns undefined when the matching type is not a Concept', async () => {
+ const adapter = fakeAdapter({
+ list: async () => [
+ {
+ id: 'a1',
+ type: 'registratie',
+ status: { tag: 'Ingediend', referentie: 'R1' },
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ],
+ });
+
+ await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
+ });
+
+ it('returns undefined when adapter.list() resolves with an unparsable shape', async () => {
+ const adapter = fakeAdapter({ list: async () => 'not-an-array' as unknown as [] });
+
+ await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
+ });
+
+ it('returns undefined when adapter.list() rejects', async () => {
+ const adapter = fakeAdapter({
+ list: async () => {
+ throw new Error('network down');
+ },
+ });
+
+ await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
+ });
+});
+
+describe('loadConcept', () => {
+ it('reads the draft off a Concept', async () => {
+ const adapter = fakeAdapter({
+ detail: async () => ({
+ id: 'a1',
+ status: { tag: 'Concept', stepIndex: 1, stepCount: 3 },
+ draft: { step: 1 },
+ }),
+ });
+
+ await expect(loadConcept(adapter, 'a1')).resolves.toEqual({
+ tag: 'concept',
+ draft: { step: 1 },
+ });
+ });
+
+ it('reports a missing draft as null', async () => {
+ const adapter = fakeAdapter({
+ detail: async () => ({ id: 'a1', status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } }),
+ });
+
+ await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'concept', draft: null });
+ });
+
+ it('reports not-concept when the id has moved past Concept (submitted)', async () => {
+ const adapter = fakeAdapter({
+ detail: async () => ({ id: 'a1', status: { tag: 'Ingediend', referentie: 'R1' } }),
+ });
+
+ await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'not-concept' });
+ });
+
+ it('reports not-concept when the id is unknown or deleted (detail rejects)', async () => {
+ const adapter = fakeAdapter({
+ detail: async () => {
+ throw new Error('404');
+ },
+ });
+
+ await expect(loadConcept(adapter, 'gone')).resolves.toEqual({ tag: 'not-concept' });
+ });
+});
diff --git a/apps/ssp/src/app/registratie/application/find-concept.ts b/apps/ssp/src/app/registratie/application/find-concept.ts
new file mode 100644
index 0000000..6b0cf75
--- /dev/null
+++ b/apps/ssp/src/app/registratie/application/find-concept.ts
@@ -0,0 +1,48 @@
+import { AanvraagType } from '@registratie/domain/aanvraag';
+import {
+ ApplicationsAdapter,
+ parseApplications,
+} from '@registratie/infrastructure/applications.adapter';
+
+/**
+ * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
+ * before it can start writing (RB-21 / CQ-001). Free functions that take the adapter
+ * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
+ * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
+ * these two functions only read.
+ */
+
+/** Find the user's existing Concept of a given type (at most one), if any. */
+export async function findConcept(
+ adapter: ApplicationsAdapter,
+ type: AanvraagType,
+): Promise {
+ try {
+ const parsed = parseApplications(await adapter.list());
+ return parsed.ok
+ ? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Outcome of loading one Concept by id: its draft (or null when it has none), or
+ `not-concept` when the id is not an editable Concept (submitted/gone) or the
+ lookup failed (unknown/deleted id) — the caller treats both the same way, as
+ "start fresh". */
+export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' };
+
+/** Load a specific Concept by id and report whether it is still editable. */
+export async function loadConcept(
+ adapter: ApplicationsAdapter,
+ id: string,
+): Promise {
+ try {
+ const dto = await adapter.detail(id);
+ if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' };
+ return { tag: 'concept', draft: dto.draft ?? null };
+ } catch {
+ return { tag: 'not-concept' };
+ }
+}
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fcd230b..6a6fc80 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | implemented |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md
new file mode 100644
index 0000000..c908e0a
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md
@@ -0,0 +1,119 @@
+# RB-21 — extract the read half of `createDraftSync` into `find-concept.ts`
+
+Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-001 ·
+`00-baseline.md` §4a (`createDraftSync` 143 lines, the largest function in the repo), §9
+(`fn > 40` threshold) · `99-backlog.md` RB-21
+
+## What was wrong
+
+`createDraftSync` (`apps/ssp/src/app/registratie/application/draft-sync.ts`) was registered
+as a command factory but owned three read paths (`load`, `findConcept`, and the read half of
+`resume`) mixed into the same function as the write path (`ensureId`, `flush`, `submit`,
+`reset`). CQ-001 named three pieces of shared mutable closure state — `id`, `ensuring`,
+`resumeGate` — as load-bearing: `resumeGate` exists only so the write path (`ensureId`) can
+wait for the read path (`resume`) to finish. That coupling is genuine and stays in place.
+
+## What changed
+
+CQ-001's proposal, applied as a pure move, no redesign.
+
+| File | Change |
+| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `apps/ssp/src/app/registratie/application/find-concept.ts` (new) | `findConcept(adapter, type)` and `loadConcept(adapter, id)` — free functions taking `ApplicationsAdapter`, no `inject()`. `loadConcept` returns a `LoadedConcept` union (`{tag:'concept', draft}` \| `{tag:'not-concept'}`) instead of the boolean-shaped branching the inline version had. |
+| `apps/ssp/src/app/registratie/application/find-concept.spec.ts` (new) | Direct spec, no TestBed — a fake `ApplicationsAdapter` object passed straight to the functions. |
+| `apps/ssp/src/app/registratie/application/draft-sync.ts` | Removed the inline `findConcept` closure and the body of `load`; both now call the free functions. `createDraftSync` keeps `id`, `ensuring`, `resumeGate`, and the whole write path, unchanged. |
+| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `find-concept.spec.ts` describe blocks. |
+
+`createDraftSync` shrank from 187 lines (`export function createDraftSync` to its closing
+brace, HEAD~1) to 169 lines. The whole file went from 236 to 216 lines.
+
+The two call sites that used the old inline `findConcept()` now pass the adapter and type
+explicitly:
+
+```ts
+// ensureId's 409-recovery catch (WP-35)
+const existing = await findConcept(adapter, deps.type);
+```
+
+```ts
+// resume(), no ?aanvraag in the URL
+const existing = await findConcept(adapter, deps.type);
+```
+
+`load` keeps setting the closure `id` and calling `applyResume` (both closure-dependent), but
+delegates the actual read to `loadConcept`:
+
+```ts
+const load = (linked: string): Promise => {
+ id = linked;
+ return loadConcept(adapter, linked).then((result) => {
+ if (result.tag === 'not-concept') {
+ id = undefined;
+ applyResume(null);
+ return;
+ }
+ applyResume(result.draft);
+ });
+};
+```
+
+## `draft-sync.spec.ts` — unchanged
+
+`draft-sync.spec.ts` was not edited. It never called `resume()`/`load()` directly — its
+coverage is the debounce, `submit()` (including the 409-recovery path, which exercises the
+extracted `findConcept` indirectly through `ensureId`'s catch), and `flushPending`. All of
+that stayed in `createDraftSync`, so the spec is unchanged and still exercises the wiring
+between `createDraftSync` and the two new free functions (the 409-recovery test would fail if
+that wiring were wrong). It passed unchanged, 8/8.
+
+## The new spec, and its verified red
+
+`find-concept.spec.ts` covers the branches CQ-001 named:
+
+- `findConcept`: match found → id returned; no match of that type → `undefined`; match found
+ but not `Concept` status → `undefined`; `adapter.list()` resolves to an unparsable shape
+ (`parseApplications` fails) → `undefined`; `adapter.list()` rejects → `undefined`.
+- `loadConcept`: `Concept` with a draft → `{tag:'concept', draft}`; `Concept` with no draft →
+ `{tag:'concept', draft:null}`; a non-`Concept` status (e.g. `Ingediend`, submitted) →
+ `{tag:'not-concept'}`; `adapter.detail()` rejects (unknown/deleted id) →
+ `{tag:'not-concept'}`.
+
+**Verified red without the fix.** Used `Edit` (not `git checkout`) to invert one condition in
+`loadConcept` — `dto.status.tag !== 'Concept'` → `dto.status.tag === 'Concept'` — reran `ng
+test ssp --include find-concept.spec.ts`. Result: 3 of 9 tests failed —
+
+```
+loadConcept > reads the draft off a Concept
+ AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: { step: 1 } }
+loadConcept > reports a missing draft as null
+ AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: null }
+loadConcept > reports not-concept when the id has moved past Concept (submitted)
+ AssertionError: expected { tag: 'concept', draft: null } to deeply equal { tag: 'not-concept' }
+```
+
+Then used `Edit` again to flip the condition back to `!==`, reran the same command: 9/9
+green. `findConcept`'s and `loadConcept`'s other branches were not separately mutated — the
+inverted condition alone was enough to prove the spec is sensitive to the extraction being
+correct, and re-verifying full green after the revert confirmed no collateral change was left
+in the file.
+
+## Scope held
+
+- No restructuring of the write path (`ensureId`, `flush`, `submit`, `reset`) — untouched
+ beyond the two call-site updates shown above.
+- `applications.adapter.ts` was not split (CQ-002's "Not filed" note rules that out for this
+ design; out of scope here regardless).
+- `resume()`'s semantics (URL-param precedence, the `resumeGate` release-in-`finally`, the
+ navigate-to-stamp-the-id side effect) are unchanged — only its two `findConcept()`/`load()`
+ calls now go through the free functions.
+- No wire change, no DTO change, no behaviour change.
+
+## Verification
+
+`npm run ci` (foreground): **green** — lint, typecheck, `dep:check`, `format:check`,
+`check:tokens`, `check:seam`, tests (ssp includes `find-concept.spec.ts` 9/9 new,
+`draft-sync.spec.ts` 8/8 unchanged), `ng build --localize` (both apps), `npm audit`, backend
+`dotnet test` (the known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure
+is expected and outside `npm run ci`'s scope), `gen:snippets` drift clean, `gen:behaviour-spec`
+drift clean once the regenerated file is committed alongside the code. Full counts are in the
+commit's `npm run ci` run — see the session note for the exact step-by-step output.
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index a334174..38e9cb9 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
-**is** the suite, reshaped for a business reader. 451 frontend behaviours across
+**is** the suite, reshaped for a business reader. 460 frontend behaviours across
9 contexts; 236 backend behaviours across 41 test
classes.
@@ -470,6 +470,14 @@ classes.
- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected
- reference falls back to em dash for a Concept
+#### findConcept
+
+- returns the id of the existing Concept of the given type
+- returns undefined when the list has no application of the given type
+- returns undefined when the matching type is not a Concept
+- returns undefined when adapter.list() resolves with an unparsable shape
+- returns undefined when adapter.list() rejects
+
#### hasProgress
- is false for a fresh wizard
@@ -486,6 +494,13 @@ classes.
- derives the beroep from the chosen diploma and flags origin duo
+#### loadConcept
+
+- reads the draft off a Concept
+- reports a missing draft as null
+- reports not-concept when the id has moved past Concept (submitted)
+- reports not-concept when the id is unknown or deleted (detail rejects)
+
#### manual diploma fallback
- KiesHandmatig flags handmatig with the maximal question set and no beroep yet
From 4631556e6858bf0629c734759266799590ba9992 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 18:27:52 +0200
Subject: [PATCH 40/61] fix(backend): key IdempotencyStore on caller + idem key
(RB-18)
IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.
Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.
Co-Authored-By: Claude Opus 5
---
backend/src/BigRegister.Api/Program.cs | 6 +-
.../BigRegister.Tests/IdempotencyTests.cs | 25 ++++
.../refactor-backlog/99-backlog.md | 70 +++++------
.../refactor-backlog/implementation/rb-18.md | 115 ++++++++++++++++++
libs/shared/docs/behaviour-spec.mdx | 3 +-
5 files changed, 181 insertions(+), 38 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index c80d2fe..ce38f5e 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -987,12 +987,14 @@ void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, Brief
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
-// — so a retried submit dedupes instead of minting a second reference.
+// — so a retried submit dedupes instead of minting a second reference. The key is
+// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same
+// client-chosen header value do not share a cached result.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList? documents = null)
{
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
- ? k.ToString()
+ ? $"{ctx.Caller().SubjectId}:{k}"
: null;
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
diff --git a/backend/tests/BigRegister.Tests/IdempotencyTests.cs b/backend/tests/BigRegister.Tests/IdempotencyTests.cs
index 91ca226..d86ba6e 100644
--- a/backend/tests/BigRegister.Tests/IdempotencyTests.cs
+++ b/backend/tests/BigRegister.Tests/IdempotencyTests.cs
@@ -45,6 +45,31 @@ public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
}
+ // RB-18/BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so
+ // caller B replaying caller A's Idempotency-Key got caller A's cached reference back —
+ // a cross-caller leak of a value caller B never submitted. The store now keys on
+ // "{SubjectId}:{idemKey}", so the same header value from two different callers is two
+ // independent submissions.
+ [Fact]
+ public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
+ {
+ var sharedKey = Guid.NewGuid().ToString();
+
+ var callerARequest = ChangeRequestWithKey(sharedKey);
+ callerARequest.Headers.Add("X-Subject", "111222333");
+ var callerA = await _client.SendAsync(callerARequest);
+ callerA.EnsureSuccessStatusCode();
+ var callerABody = await callerA.Content.ReadFromJsonAsync();
+
+ var callerBRequest = ChangeRequestWithKey(sharedKey);
+ callerBRequest.Headers.Add("X-Subject", "999888777");
+ var callerB = await _client.SendAsync(callerBRequest);
+ callerB.EnsureSuccessStatusCode();
+ var callerBBody = await callerB.Content.ReadFromJsonAsync();
+
+ Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
+ }
+
[Fact]
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
{
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fcd230b..c8fa567 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **implemented** |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md
new file mode 100644
index 0000000..749c4ab
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md
@@ -0,0 +1,115 @@
+# RB-18 — key `IdempotencyStore` on `{SubjectId}:{idemKey}`
+
+Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-018 ·
+`00-baseline.md` §7 (`IdempotencyStore` listed among the 7 stores "Not behind any port"),
+agent 02's `backend/Data` note ("no `Reset()` and no TTL") · `99-backlog.md` RB-18
+
+## What was wrong
+
+`Data/IdempotencyStore.cs` is a process-global `Dictionary` keyed only on
+the raw `Idempotency-Key` header value. `Program.cs`'s `Submit` helper read and wrote it
+with that raw value, never composed with the caller's identity:
+
+```csharp
+var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
+ ? k.ToString()
+ : null;
+```
+
+The client picks the header value. Two different callers who happen to send the same
+value shared one cache slot: the second caller's request short-circuited to the first
+caller's cached `IResult` instead of running its own submission. BIO-018 rates this
+**severity low** — the cached value is only a `ReferentieResponse` (a reference number) or
+a `ProblemDetails`, never personal data — but flags it as a defect in an access-control
+path with a trivial fix.
+
+**Location check against the finding.** BIO-018 cites `Program.cs:901-909` for the read/
+write and `Data/IdempotencyStore.cs:11-27` for the store. RB-17 (landed the day before,
+same file, unrelated change) shifted line numbers; the real call sites are
+`Program.cs:994` (read) and `:1028` (write), inside the local `Submit` helper starting at
+`:991`. The store file itself is untouched by RB-17 and matches the finding's shape
+exactly. `Submit` has exactly one call site (`POST /change-requests`, `:239`) — the
+`ChangeRequestRequest` → `telefoonwijziging` endpoint — so the scoping change lands on a
+single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to
+produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used
+this helper in the first place, so its ordering benefit does not change what this ticket
+touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped
+its residual to "this ticket is unaffected by this split beyond it now landing on a
+correctly write-only call set" — true, and the call set was already this one endpoint
+before and after RB-17.
+
+## What changed
+
+| File | Change |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/BigRegister.Api/Program.cs` | `Submit`'s `idemKey` is now `$"{ctx.Caller().SubjectId}:{k}"` instead of the raw header value `k.ToString()`; doc comment above `Submit` states the scoping and cites RB-18/BIO-018 |
+| `tests/BigRegister.Tests/IdempotencyTests.cs` | **new** `A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result` |
+
+`ctx.Caller()` (`Domain/Authorization/CallerIdentity.cs`) is already in scope in
+`Program.cs` — `ctx.Zorgverlener()` is used elsewhere in the same file — and it throws if
+the identity middleware did not run, so this composition cannot silently fall back to an
+unscoped key. `SubjectId` is the BSN for a `ZorgverlenerCaller` and the medewerkerId for a
+`MedewerkerCaller`; either way it is stable per caller and never empty.
+
+This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound,
+no `Reset()`, no port/interface extraction. `IdempotencyStore`'s own `ponytail:` comment
+("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory
+leak at scale") is untouched — the store is still unbounded and still keyed on a
+client-supplied string, only now composed with a server-resolved one first. The comment
+stays accurate; this ticket did not touch the part it would need to correct.
+
+## The test
+
+`IdempotencyTests.cs` already existed (RB-17's predecessor work, not this ticket) with
+three cases exercising same-caller replay/independence. Added a fourth:
+
+```csharp
+[Fact]
+public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
+{
+ var sharedKey = Guid.NewGuid().ToString();
+
+ var callerARequest = ChangeRequestWithKey(sharedKey);
+ callerARequest.Headers.Add("X-Subject", "111222333");
+ var callerA = await _client.SendAsync(callerARequest);
+ callerA.EnsureSuccessStatusCode();
+ var callerABody = await callerA.Content.ReadFromJsonAsync();
+
+ var callerBRequest = ChangeRequestWithKey(sharedKey);
+ callerBRequest.Headers.Add("X-Subject", "999888777");
+ var callerB = await _client.SendAsync(callerBRequest);
+ callerB.EnsureSuccessStatusCode();
+ var callerBBody = await callerB.Content.ReadFromJsonAsync();
+
+ Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
+}
+```
+
+`X-Subject` is `StubIdentityProvider`'s existing header for setting the caller's BSN in a
+test (the same idiom `ApplicationTests.cs` and `UploadAccessTests.cs` use), so caller A and
+caller B are two different `ZorgverlenerCaller`s sending the identical `Idempotency-Key`.
+
+**Verified red without the fix.** Reverted `Program.cs`'s `idemKey` line to
+`k.ToString()` with an `Edit` (not `git checkout`, so the rest of the working tree stayed
+intact), reran `dotnet test --filter "FullyQualifiedName~IdempotencyTests"`:
+
+```
+Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms]
+ Error Message:
+ Assert.NotEqual() Failure: Strings are equal
+Expected: Not "BIG-2026-476969"
+Actual: "BIG-2026-476969"
+Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4
+```
+
+Caller B received caller A's cached reference. Then reapplied the fix with a second
+`Edit` and reran: `Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4`.
+
+## Verification
+
+`dotnet test` (full suite): **261 passed, 1 failed** — the failure is
+`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
+which needs a live OpenZaak container and fails identically on a stashed tree; it predates
+this change and is not run by `npm run ci`.
+
+`npm run ci` (foreground): green — see the commit's own record for the full step list.
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index a334174..f0d1d35 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -21,7 +21,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 451 frontend behaviours across
-9 contexts; 236 backend behaviours across 41 test
+9 contexts; 237 backend behaviours across 41 test
classes.
## Frontend (by context)
@@ -1049,6 +1049,7 @@ classes.
- Replaying the same idempotency key returns the same reference not a new one
- Different idempotency keys are independent submissions
+- A caller replaying another callers idempotency key does not get their cached result
- A rejected submission replays the same rejection not a retry
### IntakeRuleTests
From 25a5d415a5d21c3b141f766a2257c4d443930500 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 18:29:05 +0200
Subject: [PATCH 41/61] docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and
ADR-C-009
The architect approved the four ADR-fix tickets. All four change what the
architecture documents claim. No code changes.
ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend.
It rewrites against `backend/src/BigRegister.Api`. Every path it named is
repointed. The out-of-scope list drops two discharged bullets: 33 `parse*`
boundaries exist, and `npm run gen:api` is real.
ADR-0001, ADR-C-003: a new section states that the generated client is the wire
contract. A hand-written `contracts/*.dto.ts` is the exception for two cases
only. The four survivors stay, because NSwag emits every property as optional
and flattens `RegistrationStatusDto` into five optional strings. The `parse*`
trust boundary stays mandatory, because a generated type is a compile-time
claim about the wire and not a runtime guarantee.
ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept
the principle and changed its example to `skeleton` and `spinner`. Two of its
claims were false and the amendment says so: `app-alert` wraps the vendored
`.feedback` classes, and `site-header` composes the vendored `.titlebar`.
ADR-0004, ADR-C-009: the exception section states a four-part test instead of
one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07
gated this ticket, because clause 4 needs an audited allow path. RB-07 landed
that, so the ADR does not ratify a control that the code lacks.
Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md
section 2 loses the false `alert` example. Section 4 gets the generated-client
rule and the four-part test.
Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that
reads "SessionStore is in-memory". The session persists to `localStorage` now,
so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4
and missed that the other half is equally false.
Co-Authored-By: Claude Opus 5
---
CLAUDE.md | 24 +++-
.../refactor-backlog/99-backlog.md | 8 +-
.../refactor-backlog/_status.md | 17 +--
.../implementation/adr-c-001.md | 69 +++++++++++
.../implementation/adr-c-003.md | 64 ++++++++++
.../implementation/adr-c-007.md | 65 ++++++++++
.../implementation/adr-c-009.md | 71 +++++++++++
.../0001-bff-lite-decision-dtos.md | 111 +++++++++++++-----
.../architecture/0003-cibg-huisstijl.md | 47 +++++---
.../architecture/0004-stamdata-as-code.md | 50 ++++++--
10 files changed, 458 insertions(+), 68 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md
diff --git a/CLAUDE.md b/CLAUDE.md
index 8bc7e7f..3c7401a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
should be **composition of existing blocks** — adding building blocks is the
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
-the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
-small hand-rolled surface built from the token bridge; see ADR-0003.)
+the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
+`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
+`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
+vendored `.feedback feedback-*` classes.)
### 3. State: make illegal states unrepresentable
@@ -175,8 +177,14 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority.
-DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
-validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
+The generated client
+(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
+the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
+`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
+it too loosely (the four survivors are all the latter — the generator emits every property as
+optional and flattens unions); such a file must still import nothing. Either way a hand-written
+`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
+**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
rules live **only** on the server, with no FE mirror to drift from it — the FE may
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
@@ -185,8 +193,12 @@ never reimplements the _algorithm_.
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
-fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
-(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
+fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
+exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
+code, an unknown key fails closed, the value is operational rather than a shared business rule,
+and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
+`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
+SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
### 5. Testing
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fcd230b..e8ba3e0 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -251,10 +251,10 @@ diff** (CLAUDE.md's own precedence rule: "the docs win — update this file").
| ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status |
| ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- |
-| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | pending |
-| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | pending |
-| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | pending |
-| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | pending |
+| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | **done** |
+| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | **done** |
+| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | **done** |
+| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | **done** |
| **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** |
**No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
index 4bc5e83..e7cb9f0 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
@@ -14,14 +14,15 @@
## Phase 3 — implementation
-| CD batch | Tickets | Status | Notes |
-| -------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
-| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
-| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
-| 4 | RB-18..RB-23 | not started | RB-19 is the only **High**-risk ticket; it needs RB-12's route-table test first. |
-| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. |
-| 6 | RB-31, RB-32, RB-33 | not started | |
+| CD batch | Tickets | Status | Notes |
+| -------- | ------------------------------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
+| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
+| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
+| 4 | RB-18..RB-23 | in progress | Split into three waves to keep the merge order honest, because three of the six tickets touch `Program.cs`. **Wave A (dispatched, parallel):** RB-18, RB-20, RB-21, RB-22 — no file overlap between them. **Wave B:** RB-23, which must merge after RB-22 (expand/contract pair: the FE must tolerate the 404 before the BE returns it). **Wave C:** RB-19 alone and last — it is the only **High**-risk ticket, it reorders all 48 endpoints in `Program.cs`, and landing it last means it reorders the final content instead of conflicting with RB-18's and RB-23's edits to the same file. RB-19 also needs RB-12's route-table test as its safety net; read `rb-12.md` first, including its stated limitation that detection is a declaration, not a derivation. |
+| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. |
+| 6 | RB-31, RB-32, RB-33 | not started | |
+| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. |
**Standing caveat for every batch:** `dotnet test` reports one failure,
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md
new file mode 100644
index 0000000..ef3c39a
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md
@@ -0,0 +1,69 @@
+# ADR-C-001 — rewrite ADR-0001's worked example against the shipped system
+
+Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-001
+
+## What was wrong
+
+ADR-0001's §"Worked example in this POC" opened with _"This POC has no real backend (static
+mock JSON + fake submit timers), so the 'BFF output' is a static file"_. That premise is
+false and every path the section cited was gone. The decision itself was intact; only the
+description had drifted.
+
+## What changed
+
+| File | Change |
+| -------------------------------------------------------- | --------------------------------------------------------------------------- |
+| `docs/reference/architecture/0001-...md` §Worked example | rewritten against `backend/src/BigRegister.Api`; all six paths repointed |
+| same file, §Out of scope here | 4 bullets → 2, plus a paragraph recording which two were discharged and why |
+
+No code changed. No CLAUDE.md edit was required for this finding.
+
+## Paths corrected, each verified
+
+| Claimed | Actual |
+| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| "no real backend … static file" | `backend/src/BigRegister.Api`, `var api = app.MapGroup("/api/v1")` at `Program.cs:168` |
+| `public/mock/dashboard-view.json` | `GET /api/v1/dashboard-view` (`Program.cs:172`) |
+| `public/mock/intake-policy.json` | `GET /api/v1/intake/policy` (`Program.cs:193`) |
+| `src/app/registratie/contracts/dashboard-view.dto.ts` | `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` |
+| `src/app/registratie/infrastructure/dashboard-view.adapter.ts` | `apps/ssp/.../infrastructure/dashboard-view.adapter.ts`, `parseDashboardView` at `:50` |
+| `src/app/herregistratie/contracts/intake-policy.dto.ts` | **deleted** — the DTO is now the generated `IntakePolicyDto`; the adapter is `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts` |
+
+`apps/ssp/public/mock/` does not exist (`ls`: no such directory).
+
+## The finding was wrong about one out-of-scope bullet
+
+ADR-C-001 said to _"reduce §Out of scope to the two items still genuinely open (the
+`BigProfileStore` optimistic-update race, and session persistence / multi-tab sync)"_,
+carrying the original bullet's parenthetical **"`SessionStore` is in-memory"**. That
+parenthetical is no longer true, so the bullet could not be kept verbatim.
+
+- `apps/ssp/src/app/auth/application/session.store.ts:13` reads
+ `parseStoredPrincipal(localStorage.getItem(STORAGE_KEY))`, and `:41` writes it back.
+ Session persistence **has landed** (RB-10 extracted the parser, RB-13 renamed it
+ `parseStoredPrincipal`). The file even carries a `ponytail:` note explaining the choice of
+ `localStorage` over `sessionStorage`.
+- Multi-tab sync has **not** landed: `grep` for a `storage` event listener across `apps` and
+ `libs` returns nothing.
+
+The bullet was therefore narrowed to multi-tab sync only, and states that the session itself
+now persists. Recording this because the finding, taken literally, would have re-asserted a
+false claim in the same edit that removed two others.
+
+The other two survivors were verified rather than assumed: `BigProfileStore` still holds
+`pending` as a bare `signal(false)` with `begin`/`confirm`/`rollback` mutating it
+(`big-profile.store.ts:61-74`), so the concurrent-submit race is real.
+
+## Discharged bullets, both verified
+
+- _"Runtime DTO validation on **every** endpoint (only the dashboard view has it)"_ — 33
+ distinct `export function parse*` boundary functions exist across `apps` and `libs`.
+- _"Real OpenAPI/TypeSpec codegen toolchain"_ — `npm run gen:api` (`package.json:12`) runs
+ `dotnet swagger tofile` then `nswag run`, emitting
+ `libs/shared/src/infrastructure/api-client.ts` (2329 lines). CI's `api-client-drift` job
+ regenerates and runs `git diff --exit-code` (`.github/workflows/ci.yml:319-321`).
+
+## Scope discipline
+
+Descriptive drift only, as the finding states. The decision, the options table, the two
+policy shapes and the migration sequence are untouched.
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md
new file mode 100644
index 0000000..c1731df
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md
@@ -0,0 +1,64 @@
+# ADR-C-003 — state that the generated client is the wire contract
+
+Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-003
+
+## What was wrong
+
+ADR-0001 set "one source of truth that generates types for both sides" as the target state.
+The code reached it. CLAUDE.md §4 still stated the pre-codegen rule — _"DTO lives in
+`contracts/`"_ — as standing law, so §4 could be cited to justify both deleting the four
+survivors and adding new hand-written DTOs for already-generated endpoints.
+
+## What changed
+
+| File | Change |
+| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| `docs/reference/architecture/0001-...md` | **new** §"Where the contract lives, after codegen" |
+| `CLAUDE.md` §4 | the flat "DTO lives in `contracts/`" rule replaced with the generated-client rule + the two exceptions |
+
+No code changed. Per CLAUDE.md's own precedence rule, the ADR was amended first and
+CLAUDE.md corrected to match, in one diff.
+
+## The decision the finding asked for: the four survivors stay
+
+ADR-C-003 required an explicit, recorded decision on the four remaining hand-written DTOs.
+**They stay**, all four under exception case 2 ("the generator types the shape too loosely").
+This is not a preference — adopting the generated shapes would violate CLAUDE.md §3.
+
+Evidence. NSwag emits every property as optional, and flattens a discriminated union into a
+bag of optional fields:
+
+| | generated (`api-client.ts`) | hand-written (`dashboard-view.dto.ts`) |
+| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `DashboardViewDto` | `registration?`, `person?`, `decisions?` — all optional (`:2017`) | all three required |
+| `RegistrationDto` | six optional fields (`:2202`) | six required fields |
+| `RegistrationStatusDto` | **one flat record of five optional strings**, `tag?: string` (`:2211`) | a real union of three variants, `tag: 'Geregistreerd' \| 'Geschorst' \| 'Doorgehaald'`, per-variant fields required |
+
+The generated `RegistrationStatusDto` makes `{ tag: 'Geregistreerd', doorgehaaldOp: '…' }`
+representable. That is precisely the illegal state CLAUDE.md §3 exists to forbid, and the
+`parse*` boundary would have to reconstruct the union by hand anyway.
+
+The ADR therefore records that retiring these four is **not** a cleanup to schedule. It
+becomes correct only if the backend annotates its DTOs so the generator emits required
+properties and real unions — which names the actual prerequisite instead of leaving the
+question open.
+
+## Verified counts, not carried over from the finding
+
+- Hand-written `contracts/*.dto.ts`: **4** —
+ `apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`
+ and `libs/beheer/src/contracts/stamdata.dto.ts`.
+- All four duplicate generated types **by the same names**: `BrpAddressDto` (`:1997`),
+ `DashboardViewDto` (`:2017`), `DuoLookupDto` (`:2056`), `DuoDiplomaDto` (`:2047`),
+ `PolicyQuestionDto` (`:2168`), `ManualDiplomaPolicyDto` (`:2106`), `StamdataColumnDto`
+ (`:2246`), `StamdataTableDto` (`:2253`), `StamdataTableSummaryDto` (`:2261`). None is a
+ codegen gap — the finding's "case 1" has no occupant today, which is worth knowing.
+- The `parse*` boundary is restated as mandatory regardless of type provenance. The amendment
+ says why in one line: a generated type is a compile-time claim about the wire, not a
+ runtime guarantee.
+
+## Gate released
+
+ADR-C-003 blocked any ticket that would delete the four `contracts/*.dto.ts` files or add a
+hand-written DTO for a generated endpoint. No open ticket needed it. The rule is now written
+down, so a future one can be judged against it rather than against a stale §4.
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md
new file mode 100644
index 0000000..a3f2a3c
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md
@@ -0,0 +1,65 @@
+# ADR-C-007 — repoint ADR-0003's WP-67 paths and fix its point 4
+
+Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-007
+
+## What was wrong
+
+Two separate defects in one ADR. Every file path in ADR-0003 predated WP-67's monorepo move,
+and decision point 4 made a claim about `app-alert` that the code contradicts.
+
+## What changed
+
+| File | Change |
+| ---------------------------------------- | ---------------------------------------------------------------------------------- |
+| `docs/reference/architecture/0003-...md` | points 1, 2, 4 and both §Consequences bullets rewritten |
+| `CLAUDE.md` §2 | the `alert` parenthetical corrected to `skeleton`/`spinner` + a denial for `alert` |
+
+No code changed. ADR first, CLAUDE.md to match, one diff.
+
+## Paths, each verified
+
+| Claimed | Actual |
+| ------------------------ | ---------------------------------------------------------------------- |
+| `src/styles.scss` | `libs/shared/styles.scss` — one copy, both apps' `angular.json:41,169` |
+| `src/index.html` | `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html` |
+| `.storybook/` | `.storybook-ssp/` and `.storybook-behandelportal/` |
+| `src/docs/cibg-gaps.mdx` | `libs/shared/docs/cibg-gaps.mdx` |
+
+**One path in the finding's list needed no change.** ADR-C-007 implied point 1's
+`public/cibg-huisstijl/` had moved with the rest. It has not: `public/` is still at the repo
+root, and both apps' `angular.json` asset entries read `"input": "public"` (`:38`, `:166`).
+Both Storybook configs serve it as `staticDirs: ['../public']`. Point 1's vendoring path is
+left as written; only its `index.html` clause changed.
+
+## Point 4: the finding was right, and understated
+
+ADR-C-007 flagged the `.alert` half of point 4. Verified: `libs/shared/src/ui/alert/alert.component.ts`
+documents itself as a _"Thin wrapper over the vendored `.feedback feedback-*` classes"_, its
+template binds `.feedback-info/-success/-warning/-error`, its only local CSS is a 3-line flex
+fix, and it carries **no** `CIBG-GAP EXTENSION` marker. `grep` confirms `feedback-error` is
+present in `public/cibg-huisstijl/css/huisstijl.css` — the class is vendored, so `alert` is not
+a gap.
+
+**The finding missed that the same sentence's second claim is also false.** Point 4 said "the
+header/side-nav use `.nav` + a local blue bar". They do not:
+
+- `site-header.component.ts` composes the vendored `.titlebar` and `.logo__*` classes
+ (`grep` confirms `titlebar` in the vendored CSS) and its own comment says the titlebar
+ _"keeps its own robijn fill — `--ro-layout` — untouched"_.
+- `shell.component.ts` emits only `.layout`, `.main`, `.content`, `.skip` — page scaffolding.
+- No `.nav` class appears in either, and neither carries a gap marker.
+
+Both corrections are stated in the amended point 4 rather than silently dropped, so a reader
+comparing the old text against the code can see which claim was retired and why.
+
+## Replacement example chosen
+
+`skeleton` and `spinner`, as the finding proposed. Both are in the gap register, both carry
+markers reading "No loading-skeleton/spinner class in the vendored build", and both are
+genuinely absent — the cleanest live illustration of the principle point 4 exists to state.
+
+## Noted, not fixed: the gap register is still one row short
+
+`grep` finds **9** `CIBG-GAP EXTENSION` markers; `libs/shared/docs/cibg-gaps.mdx` has **8**
+rows. The missing one is `language-switcher`. That is **ADR-C-008 → RB-32** (batch 6), not
+this ticket, and it was left alone.
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md
new file mode 100644
index 0000000..9ed51ca
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md
@@ -0,0 +1,71 @@
+# ADR-C-009 — state the runtime-editable-config exception as a test, not a list
+
+Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-009
+· Gated on: **RB-07** (satisfied — batch 2)
+
+## What was wrong
+
+ADR-0004 said "never runtime-editable" and then named **one** exception in the singular,
+justified narrowly ("specific to one sub-organization's identity"). WP-47 added a second
+runtime-editable SQLite surface, `FeatureFlagStore`, whose own doc-comment states the
+equivalence the ADR did not: _"SQLite-backed like `OrgTemplateStore`, same single-gate
+idiom."_
+
+The code is right; the ADR's text was wrong. A closed list of one leaves the next
+operational-config surface with no principle to test itself against.
+
+## What changed
+
+| File | Change |
+| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `docs/reference/architecture/0004-...md` | §"The deliberate exception: org-templates" → §"The deliberate exception: operational configuration" — a four-part test plus a table of the two passing surfaces |
+| same file, §Context + the table | `src/locale/*.xlf` → `apps//src/locale/*.xlf` (two apps since WP-67) |
+| `CLAUDE.md` §4 | the singular "Org-templates are the deliberate exception" replaced with the four-part test |
+
+No code changed — the finding says so outright, and verification confirmed it.
+
+## Why the RB-07 gate was real, verified clause by clause
+
+Clause (4) of the test is "writes are admin-capability-gated **and** audited". Signing this
+ADR before RB-07 would have ratified a control the code did not implement. RB-07 has landed,
+so the clause is now true. Read at `backend/src/BigRegister.Api/Program.cs:863-923`: each of
+the five gates now computes `var ok = …`, calls `AuditAuthz(ctx, capability, resource, ok,
+principal)` with the **real** boolean, and only then branches. `FlagsAdmin`'s own comment
+names this ticket: _"this is the surface CQ-004/ADR-C-009 hinge on."_
+
+All four clauses were checked against both surfaces rather than assumed:
+
+| Clause | `OrgTemplateStore` | `FeatureFlagStore` |
+| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
+| (1) catalog in code | the `OrgTemplateDto` shape + `OrgTemplateRules` validate before save (`OrgTemplateStore.cs:49`) | `FeatureFlags.Catalog` (`Domain/Features/FeatureFlags.cs:15`) |
+| (2) fails closed | unknown `subOrgId` → `null` → endpoint 404s (`:44-45,:55-56,:72-73,:94-96`) | `Set` returns false for an unlisted key (`:54`); `IsEnabled` returns false (`:42-43`) |
+| (3) operational | one sub-organisation's letterhead | an on/off rollout switch |
+| (4) gated + audited | `OrgAdmin` → `orgtemplate:edit` (`Program.cs:863`) | `FlagsAdmin` → `flags:manage` (`Program.cs:914`) |
+
+`FeatureFlagStore`'s own comment states clause (1) and (2) explicitly: _"The CATALOG … is
+code … this store only holds the admin's on/off overrides. An unknown key is never
+writable/enabled — the code catalog is the authority."_
+
+## Judgement calls
+
+- **Clause (2) is about the write/enable path, not every read.** `OrgTemplateStore` has a
+ deliberate read-path fallback for briefs from before WP-23 (`:110-114`, its own `ponytail:`
+ comment): an empty `SubOrgId` falls back to the first seeded sub-org rather than failing a
+ whole screen. That is a preview convenience on a read; the four write entry points all
+ return `null` for an unknown sub-org. The clause is worded "cannot invent a setting,
+ enable a feature, or be written" so this read fallback is not caught by it. Recorded
+ because a reader checking clause (2) against `OrgTemplateStore.cs` will meet that
+ fallback first.
+- **Org-templates' publish/rollback versioning is mentioned but excluded from the test.** It
+ is stronger than the test requires, and making it a fifth clause would block a legitimate
+ flag-style surface that has nothing to version.
+- **The stale `src/locale/*.xlf` paths were fixed in the same diff**, though ADR-C-009 did
+ not flag them. They are two occurrences of the same WP-67 drift ADR-C-001 and ADR-C-007
+ exist to correct, in the section being edited, and leaving a known-false path in a document
+ while amending it is the exact failure mode those two findings describe. Scope creep is
+ two words wide here; the alternative is filing a third ticket for it.
+
+## Gate released
+
+ADR-C-009 blocked "any ticket proposing a third runtime-editable config surface". Such a
+ticket can now be judged against a written test rather than by analogy to org-templates.
diff --git a/docs/reference/architecture/0001-bff-lite-decision-dtos.md b/docs/reference/architecture/0001-bff-lite-decision-dtos.md
index 7e18872..7d68c69 100644
--- a/docs/reference/architecture/0001-bff-lite-decision-dtos.md
+++ b/docs/reference/architecture/0001-bff-lite-decision-dtos.md
@@ -69,44 +69,96 @@ the governance/transparency artifact.
The frontend keeps only **format** validation (postcode shape, integer parsing) for
instant feedback — never as the authority.
+### Where the contract lives, after codegen
+
+The paragraph above says "manage it with one source of truth that generates types for
+both sides". That target state has arrived, so this section states which artifact is now
+the contract.
+
+**The generated client is the wire contract.** `libs/shared/src/infrastructure/api-client.ts`
+is regenerated from the backend's OpenAPI document by `npm run gen:api`, and CI fails on
+drift (the `api-client-drift` job regenerates it and runs `git diff --exit-code`). It is the
+single source of truth for the shape of every endpoint. An adapter consumes its types
+directly; 19 of the 20 infrastructure adapters do.
+
+**A hand-written `contracts/*.dto.ts` is the exception, for two cases only:**
+
+1. **Codegen does not reach the endpoint** — a hand-rolled `fetch`/XHR path that the
+ generator never sees.
+2. **The generator types the shape too loosely** — the generated type compiles but is
+ weaker than the wire really is.
+
+In either case the hand-written file must still import nothing. It describes the wire, not
+the domain.
+
+**The `parse*` trust boundary is unchanged and stays mandatory**, whichever way the type
+arrived. A generated type is a compile-time claim about the wire, not a runtime guarantee:
+the server can send anything. `infrastructure/` validates the untrusted shape and maps it
+onto the domain, exactly as before.
+
+**The four surviving hand-written contracts stay.** They are
+`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`
+and `libs/beheer/src/contracts/stamdata.dto.ts`. All four fall under case 2, and the
+dashboard view shows why: the generator emits every property as optional, and it flattens
+a discriminated union into a bag of optional fields.
+
+```ts
+// generated — every field optional, `tag` a bare string, all variants merged
+interface RegistrationStatusDto {
+ tag?: string | undefined;
+ herregistratieDatum?: string | undefined;
+ geschorstTot?: string | undefined;
+ reden?: string | undefined;
+ doorgehaaldOp?: string | undefined;
+}
+
+// hand-written — a real discriminated union, per-variant fields required
+type RegistrationStatusDto =
+ | { tag: 'Geregistreerd'; herregistratieDatum: string }
+ | { tag: 'Geschorst'; geschorstTot: string; reden: string }
+ | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
+```
+
+Adopting the generated shape here would push `undefined` handling into every consumer and
+make an illegal state representable, which CLAUDE.md §3 forbids. Retiring these four is
+therefore **not** a cleanup to schedule; it becomes correct only if the backend annotates
+its DTOs so the generator emits required properties and real unions.
+
## Worked example in this POC
-This POC has no real backend (static mock JSON + fake submit timers), so the
-"BFF output" is a static file; the `decisions` block stands in for what the backend
-would compute. Two slices were implemented to demonstrate **both** policy shapes:
+Implemented against the real backend, `backend/src/BigRegister.Api`. Two slices demonstrate
+**both** policy shapes.
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
-- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
+- Endpoint: `GET /api/v1/dashboard-view` (`Program.cs`), one call replacing three.
+- Contract: `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`).
-- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
- Boundary parse: `parseDashboardView()` in
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
- untrusted shape and maps DTO → domain (hand-written; no schema lib for one
- contract).
-- `BigProfileStore` now derives `profile` and `decisions` from the single
- validated view (was a 3-resource `map2`). One request → one consistent snapshot.
-- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
- computing it client-side. That rule is server-owned: it lives only in
- `HerregistratieRule.cs`, with no FE mirror to drift from it (WP-75).
-- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
- `brp.json`) were deleted — those calls live behind the BFF now.
+ `apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
+ untrusted shape and maps DTO → domain (hand-written; no schema lib).
+- `BigProfileStore` derives `profile` and `decisions` from the single validated view (was a
+ 3-resource `map2`). One request → one consistent snapshot.
+- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of computing
+ it client-side. That rule is server-owned: it lives only in `HerregistratieRule.cs`, with
+ no FE mirror to drift from it (WP-75).
**B. Intake scholing threshold → config value.**
-- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
-- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
+- Endpoint: `GET /api/v1/intake/policy` (`Program.cs`), serving
+ `IntakePolicy.ScholingThreshold`.
+- Contract: the generated `IntakePolicyDto`; the adapter is
+ `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts`.
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
- `lageUren(a, scholingThreshold)` and validation take the value, which lives in
- machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
- remains only as the offline fallback.
+ `lageUren(a, scholingThreshold)` and validation take the value, which lives in machine
+ state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` remains only
+ as the offline fallback.
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
- WP-69: the backend re-validates the threshold as the authority on submit —
`IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit`
- (intake-typed) writes anything, 400ing an incomplete scholing answer instead of
- silently accepting a crafted POST that skips it. (WP-72 deleted the legacy
- `POST /intakes` endpoint this once also covered — deleting the surface is a stronger
- fix than 400ing on it.)
+ (intake-typed) writes anything, 400ing an incomplete scholing answer instead of silently
+ accepting a crafted POST that skips it. (WP-72 deleted the legacy `POST /intakes` endpoint
+ this once also covered — deleting the surface is a stronger fix than 400ing on it.)
## Migration sequence (for the real app)
@@ -119,12 +171,17 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
## Out of scope here (next steps, not built in the worked example)
-- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
- Optimistic-update race fix in `BigProfileStore`
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
concurrent submits).
-- Session persistence / multi-tab sync (`SessionStore` is in-memory).
-- Real OpenAPI/TypeSpec codegen toolchain.
+- Multi-tab session sync. The session itself now persists (`localStorage`, read back
+ through `parseStoredPrincipal`), but a change in one tab does not reach another — no
+ `storage` listener exists.
+
+Two bullets were discharged and removed. Runtime DTO validation is no longer "only the
+dashboard view": 33 `parse*` boundary functions exist. The OpenAPI codegen toolchain is
+real: `npm run gen:api` generates `libs/shared/src/infrastructure/api-client.ts` and CI
+drift-checks it.
ponytail: build the pattern once on one slice; copy it across screens when the real
backend lands, rather than scaffolding all of it up front.
diff --git a/docs/reference/architecture/0003-cibg-huisstijl.md b/docs/reference/architecture/0003-cibg-huisstijl.md
index 78edb54..8ddc4d5 100644
--- a/docs/reference/architecture/0003-cibg-huisstijl.md
+++ b/docs/reference/architecture/0003-cibg-huisstijl.md
@@ -19,28 +19,47 @@ layer — not a palette swap.
## Decision
1. **Vendor the package** under `public/cibg-huisstijl/` (not an npm dep — it was delivered as files),
- loaded via a `` in `src/index.html` so the CSS's relative `url(../fonts|icons|images)`
- references resolve at runtime. Storybook serves the same via `staticDirs`.
-2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
+ loaded via a `` in each app's `index.html` (`apps/ssp/src/index.html` and
+ `apps/behandelportal/src/index.html` — two since WP-67) so the CSS's relative
+ `url(../fonts|icons|images)` references resolve at runtime. `public/` stays at the repo
+ root and both apps' `angular.json` targets copy it. Both Storybook instances serve the
+ same via `staticDirs: ['../public']`.
+2. **Token bridge over token rewrite.** `libs/shared/styles.scss` — one copy, both apps'
+ `angular.json` point at it (WP-67) — redefines the app's ~54 `--rhc-*` tokens
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
- keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
- `check:tokens`, so palette hex lives in that one file only.)
+ keeps the "components reference tokens" convention intact. (`libs/shared/styles.scss` is
+ exempt from `check:tokens`, so palette hex lives in that one file only.)
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
(`app-button` → `btn btn-primary`, `text-input` → `form-control`, radio/checkbox → `form-check-*`);
domain pages compose the same atoms and barely changed.
-4. **Hand-roll what CIBG's build drops.** CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert`
- is a small token-styled surface and the header/side-nav use `.nav` + a local blue bar. Local class
- names that collide with Bootstrap components were renamed (`.card` → `.app-card`, badge → `.status-badge`).
+4. **Hand-roll what CIBG's build drops, and mark it.** Where the vendored build has no class for a
+ concept, the component is a small token-styled surface carrying a `// CIBG-GAP EXTENSION:` marker.
+ The clearest live examples are `skeleton` and `spinner`: CIBG documents "Laadindicatie" but the
+ vendored build ships no loading-skeleton or loading-spinner class, so both are built from the token
+ bridge. Local class names that collide with Bootstrap components were renamed (`.card` → `.app-card`,
+ badge → `.status-badge`).
+
+ Two claims this point used to make were wrong and are corrected here. **`.alert` is not a gap:**
+ `app-alert` is a thin wrapper over the vendored `.feedback feedback-*` classes — the design system
+ owns surface and icon, and the component adds only the icon's a11y label and a flex fix. It carries
+ no gap marker, correctly. **The header is not hand-rolled either:** `site-header` composes the
+ vendored `.titlebar` and `.logo__*` classes and leaves the robijn fill (`--ro-layout`) untouched.
+ The `shell` template's `.layout`/`.main`/`.content` classes are page scaffolding, not a substitute
+ for a missing design-system component, so they carry no marker either.
+
5. **System-font stack; no licensed fonts.** `--bs-font-sans-serif` is overridden to `system-ui`; the
licensed RO/Rijks **text** woffs are removed from the vendored copy (CIBG icon font kept). Logo stays
a text wordmark. Interactivity stays Angular-driven (no Bootstrap JS).
## Consequences
-- Wiring the design system touches `styles.scss` (token bridge), `index.html`, `angular.json`
- (`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
- `shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
+- Wiring the design system touches `libs/shared/styles.scss` (token bridge), both apps'
+ `index.html`, both `angular.json` targets (`public/` already copied), and both Storybook config
+ dirs (`.storybook-ssp/` and `.storybook-behandelportal/` — separate since WP-67, because a single
+ merged tsconfig cannot resolve both apps' `@auth/*` at once) — plus the class strings in ~40
+ `libs/shared/ui` + `libs/shared/layout` + a few domain components. The
+ `@rijkshuisstijl-community/*` deps are dropped.
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
- Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_ —
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
@@ -49,6 +68,6 @@ layer — not a palette swap.
intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
- (`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
- design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
- drifting.
+ (`libs/shared/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation
+ from the design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than
+ silently drifting.
diff --git a/docs/reference/architecture/0004-stamdata-as-code.md b/docs/reference/architecture/0004-stamdata-as-code.md
index 1e7d362..1a05976 100644
--- a/docs/reference/architecture/0004-stamdata-as-code.md
+++ b/docs/reference/architecture/0004-stamdata-as-code.md
@@ -20,7 +20,7 @@ was neither isolated nor validated:
- All reference data and thresholds are **compiled-in C# constants**, served through
screen-shaped BFF-lite endpoints; the frontend renders decisions and holds no reference
data (ADR-0001).
-- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a
+- User-facing UI copy is already **`$localize`** (`apps//src/locale/*.xlf`) — git-tracked, and a
second locale is a translation file, not a code change. That is already the compile-time
model for text.
- The profession↔diploma map lived as a _private_ `Dictionary` inside `DiplomaRules`, mixed
@@ -62,17 +62,49 @@ production database, never runtime-editable.
| Kind | Home | Gate |
| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) |
-| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
+| User-facing UI copy | `$localize` → `apps//src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests |
-### The deliberate exception: org-templates
+### The deliberate exception: operational configuration
-Per-organization letterhead (return address, footer, signature, margins) **is**
-runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is
-intentional and does not contradict this ADR: it is _operational configuration_ owned by an
-admin persona, versioned with publish/rollback inside the app, and specific to one
-sub-organization's identity — not the shared business rules a wrong value would break for
-everyone. Stamdata (the rules and reference tables the whole register runs on) stays code.
+"Never runtime-editable" above is the rule for **stamdata** — the shared reference tables
+and business rules the whole register runs on. It is not a ban on all persisted
+configuration. Some configuration is operational rather than business-rule, and belongs to
+an admin persona at runtime.
+
+This section states the **test** rather than a list, so the next surface can check itself
+instead of arguing by analogy. Runtime-editable persistence is permitted only when all four
+hold:
+
+1. **The catalog lives in code.** What may be set — the keys, the schema, the defaults,
+ the descriptions — is compiled in and reviewed through git. The store holds values, never
+ the definition of what a value means.
+2. **An unknown or unlisted key fails closed.** A row the code catalog does not know cannot
+ invent a setting, enable a feature, or be written. A bad row is inert, not authoritative.
+3. **The value is operational.** Per-organisation identity, or an on/off rollout switch —
+ not a shared business rule whose wrong value breaks the register for everyone. This is the
+ clause that keeps stamdata out.
+4. **Writes are admin-capability-gated and audited.** The write path goes through an `Authz`
+ capability gate, and the gate records the decision — allow as well as deny — in
+ `AuthzAuditStore`.
+
+**Two surfaces pass this test today.**
+
+| Surface | (1) catalog in code | (2) fails closed | (3) operational | (4) gated + audited |
+| ----------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | --------------------------------- | ------------------------------- |
+| `OrgTemplateStore` (WP-23/26) | the `OrgTemplateDto` shape + `OrgTemplateRules` | unknown `subOrgId` → `null` → the endpoint 404s | one sub-organisation's letterhead | `OrgAdmin` → `orgtemplate:edit` |
+| `FeatureFlagStore` (WP-47) | `Domain/Features/FeatureFlags.Catalog` | unknown key → `Set` returns false (404); `IsEnabled` → false | an on/off rollout switch | `FlagsAdmin` → `flags:manage` |
+
+Clause (4) became true for both only with RB-07, which moved `AuditAuthz` from each gate's
+deny branch into the gate itself so the allow path is recorded too. Before that, both
+surfaces were gated and **not** audited, and this ADR would have ratified a control the code
+did not implement.
+
+Org-templates also carry publish/rollback versioning inside the app, which is stronger than
+the test requires but not part of it.
+
+Stamdata itself — the rules and reference tables — fails clause (3) by construction and
+stays code.
## Consequences
From 7def4a7552490404f5305858347e73e2d332a667 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 18:32:51 +0200
Subject: [PATCH 42/61] fix(ssp): route cancel/delete through runSubmit,
surface the error (RB-20)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ApplicationsStore.cancel and AdminCasesStore.delete rolled an optimistic
write back on failure but showed no message — a bare catch with no
Result and no error channel (CQ-002). Both now call runSubmit and set a
lastError signal on failure, mirroring createSubmitChangeRequest in the
same folder. Each page renders the error with the existing app-alert
atom, the same pattern brief.page.ts already uses for lastError.
Added a spec file for ApplicationsStore (none existed) and extended
AdminCasesStore's spec, each asserting the rollback AND the surfaced
error. Verified both new assertions fail without the fix (an Edit
undo/redo of the store method, not git checkout).
Regenerated libs/shared/docs/behaviour-spec.mdx (gen:behaviour-spec) to
pick up the new/renamed test names. Marked RB-20 done in 99-backlog.md
and recorded the change in implementation/rb-20.md.
Co-Authored-By: Claude Opus 5
---
.../application/admin-cases.store.spec.ts | 25 +++-
.../application/admin-cases.store.ts | 21 +++-
.../application/applications.store.spec.ts | 77 ++++++++++++
.../application/applications.store.ts | 20 ++-
.../app/registratie/ui/admin-cases.page.ts | 3 +
.../src/app/registratie/ui/dashboard.page.ts | 5 +
.../refactor-backlog/99-backlog.md | 2 +-
.../refactor-backlog/implementation/rb-20.md | 117 ++++++++++++++++++
libs/shared/docs/behaviour-spec.mdx | 12 +-
9 files changed, 267 insertions(+), 15 deletions(-)
create mode 100644 apps/ssp/src/app/registratie/application/applications.store.spec.ts
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md
diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts
index 4b757dd..ffab1b2 100644
--- a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts
+++ b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
+import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AdminCasesStore } from './admin-cases.store';
@@ -43,7 +44,10 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
});
- it('rolls back the removal when the delete fails', async () => {
+ // RB-20: a failed delete must not be silent — the row rolls back AND the store
+ // surfaces the error the page renders. Before RB-20 this only rolled back
+ // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
+ it('rolls back the removal and surfaces the error when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
await store.load();
@@ -51,5 +55,24 @@ describe('AdminCasesStore', () => {
await store.delete('a');
const s = store.cases();
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
+ expect(store.lastError()).toBe(SUBMIT_FAILED);
+ });
+
+ it('clears a stale error on the next delete attempt', async () => {
+ const deleteAny = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('boom'))
+ .mockResolvedValueOnce(undefined);
+ const store = setup({
+ listAll: () => Promise.resolve([summary('a'), summary('b')]),
+ deleteAny,
+ });
+ await store.load();
+
+ await store.delete('a');
+ expect(store.lastError()).toBe(SUBMIT_FAILED);
+
+ await store.delete('b');
+ expect(store.lastError()).toBeNull();
});
});
diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.ts
index 943dcb4..b4580b9 100644
--- a/apps/ssp/src/app/registratie/application/admin-cases.store.ts
+++ b/apps/ssp/src/app/registratie/application/admin-cases.store.ts
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
+import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
@@ -12,8 +13,9 @@ type Err = Error | undefined;
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously
- * (optimistic) and rolls back on error. Admin delete removes any case (any owner,
- * submitted or not — the server enforces the capability).
+ * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
+ * failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
+ * server enforces the capability).
*/
@Injectable({ providedIn: 'root' })
export class AdminCasesStore {
@@ -22,6 +24,11 @@ export class AdminCasesStore {
private state = signal>({ tag: 'Loading' });
readonly cases = this.state.asReadonly();
+ /** Set on a failed delete (RB-20): the optimistic removal already rolled back by
+ then, this is only the message for the alert the page renders above the list. */
+ private error = signal(null);
+ readonly lastError = this.error.asReadonly();
+
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
last-good value on a resync (only shows Loading on the first load). */
async load() {
@@ -42,16 +49,18 @@ export class AdminCasesStore {
void this.load();
}
- /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
+ /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
+ AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */
async delete(id: string) {
const before = this.state();
if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
}
- try {
- await this.adapter.deleteAny(id);
- } catch {
+ this.error.set(null);
+ const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED);
+ if (!r.ok) {
this.state.set(before); // roll back: the row reappears
+ this.error.set(r.error);
}
}
}
diff --git a/apps/ssp/src/app/registratie/application/applications.store.spec.ts b/apps/ssp/src/app/registratie/application/applications.store.spec.ts
new file mode 100644
index 0000000..f8860af
--- /dev/null
+++ b/apps/ssp/src/app/registratie/application/applications.store.spec.ts
@@ -0,0 +1,77 @@
+import { TestBed } from '@angular/core/testing';
+import { describe, it, expect, vi } from 'vitest';
+import { SUBMIT_FAILED } from '@shared/application/submit';
+import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
+import { ApplicationsStore } from './applications.store';
+
+const summary = (id: string) => ({
+ id,
+ type: 'registratie',
+ status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
+ documentIds: [],
+ createdAt: '2026-07-23T10:00:00Z',
+ updatedAt: '2026-07-23T10:00:00Z',
+});
+
+function setup(adapter: Partial): ApplicationsStore {
+ TestBed.configureTestingModule({
+ providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
+ });
+ // The store's own constructor kicks off `load()` (dashboard revisit refresh) —
+ // give every test a `list` so that initial call has something to resolve.
+ return TestBed.inject(ApplicationsStore);
+}
+
+describe('ApplicationsStore', () => {
+ it('loads and parses the list', async () => {
+ const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) });
+ await store.load();
+ const s = store.applications();
+ expect(s.tag).toBe('Success');
+ expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']);
+ });
+
+ it('cancels optimistically and confirms via the DELETE endpoint', async () => {
+ const cancel = vi.fn().mockResolvedValue(undefined);
+ const store = setup({
+ list: () => Promise.resolve([summary('a'), summary('b')]),
+ cancel,
+ });
+ await store.load();
+
+ await store.cancel('a');
+ expect(cancel).toHaveBeenCalledWith('a');
+ const s = store.applications();
+ expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']);
+ expect(store.lastError()).toBeNull();
+ });
+
+ // RB-20: a failed cancel must not be silent — the row rolls back AND the store
+ // surfaces the error the page renders. Before RB-20 this only rolled back
+ // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
+ it('rolls back the removal and surfaces the error when the cancel fails', async () => {
+ const cancel = vi.fn().mockRejectedValue(new Error('boom'));
+ const store = setup({ list: () => Promise.resolve([summary('a')]), cancel });
+ await store.load();
+
+ await store.cancel('a');
+ const s = store.applications();
+ expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears
+ expect(store.lastError()).toBe(SUBMIT_FAILED);
+ });
+
+ it('clears a stale error on the next cancel attempt', async () => {
+ const cancel = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('boom'))
+ .mockResolvedValueOnce(undefined);
+ const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]), cancel });
+ await store.load();
+
+ await store.cancel('a');
+ expect(store.lastError()).toBe(SUBMIT_FAILED);
+
+ await store.cancel('b');
+ expect(store.lastError()).toBeNull();
+ });
+});
diff --git a/apps/ssp/src/app/registratie/application/applications.store.ts b/apps/ssp/src/app/registratie/application/applications.store.ts
index db96201..5157dad 100644
--- a/apps/ssp/src/app/registratie/application/applications.store.ts
+++ b/apps/ssp/src/app/registratie/application/applications.store.ts
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
+import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
@@ -15,7 +16,8 @@ type Err = Error | undefined;
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
- * computed server-side on read).
+ * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
+ * surfaces `lastError` on failure (RB-20).
*/
@Injectable({ providedIn: 'root' })
export class ApplicationsStore {
@@ -24,6 +26,11 @@ export class ApplicationsStore {
private state = signal>({ tag: 'Loading' });
readonly applications = this.state.asReadonly();
+ /** Set on a failed cancel (RB-20): the optimistic removal already rolled back by
+ then, this is only the message for the alert the page renders above the list. */
+ private error = signal(null);
+ readonly lastError = this.error.asReadonly();
+
constructor() {
void this.load();
}
@@ -50,16 +57,19 @@ export class ApplicationsStore {
}
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
- No resync — the delete succeeded, so the optimistic removal is authoritative. */
+ No resync — the delete succeeded, so the optimistic removal is authoritative. On
+ failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the
+ user guessing why the block came back. */
async cancel(id: string) {
const before = this.state();
if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
}
- try {
- await this.adapter.cancel(id);
- } catch {
+ this.error.set(null);
+ const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
+ if (!r.ok) {
this.state.set(before); // roll back: the block reappears
+ this.error.set(r.error);
}
}
}
diff --git a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts
index aa088a4..8f83fad 100644
--- a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts
+++ b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts
@@ -42,6 +42,9 @@ import { AdminCasesStore } from '@registratie/application/admin-cases.store';
} @else if (!canManage()) {
{{ deniedText }}
} @else {
+ @if (store.lastError(); as err) {
+ {{ err }}
+ }
{{ failedText }}
diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts
index 1e17309..86c5a35 100644
--- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts
+++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts
@@ -51,6 +51,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
>
+ @if (cancelError(); as err) {
+ {{ err }}
+ }
@if (aanvragen().length) {
@for (a of concepten(); track a.id) {
@@ -260,6 +263,8 @@ export class DashboardPage {
protected cancelAanvraag(a: Aanvraag) {
void this.apps.cancel(a.id);
}
+ /** RB-20: the message from a failed cancel, rendered above the list. */
+ protected cancelError = computed(() => this.apps.lastError());
/** Server-computed eligibility (rendered, not recomputed). */
private readonly eligible = computed(() => {
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fcd230b..e1ededd 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -121,7 +121,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md
new file mode 100644
index 0000000..c4b242b
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md
@@ -0,0 +1,117 @@
+# RB-20 — route `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`, surface the error
+
+Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-002 ·
+`00-baseline.md` BL-007 · `99-backlog.md` RB-20 · SIGN-OFF: consolidation approved
+2026-08-27, HALT lifted
+
+## What was wrong
+
+`ApplicationsStore.cancel` and `AdminCasesStore.delete` both owned an optimistic write next
+to their `RemoteData` read, and both reached `ApplicationsAdapter` directly instead of going
+through `runSubmit` (the fold + Idempotency-Key mint every other mutation in the repo uses,
+including `createSubmitChangeRequest` in the same folder). The failure path was a bare
+`catch { this.state.set(before); }`: a failed cancel or delete rolled the row back, but the
+user saw no message at all — no `ActionState`, no ProblemDetails `detail`, nothing. The
+`Idempotency-Key` on the wire was also a fresh UUID per HTTP attempt (minted by
+`api-client.provider.ts`'s default), not the per-logical-submit key `runSubmit` promises —
+harmless today only because `Program.cs` happens to ignore the header outside the `Submit`
+helper (CQ-005's note).
+
+## What changed
+
+CQ-002's option (a) — the smallest fix, applied identically to both stores. No new command
+factory, no adapter split (CQ-002's own "Not filed" note reserves that split for option (b),
+which this ticket does not take).
+
+| File | Change |
+| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `apps/ssp/src/app/registratie/application/applications.store.ts` | `cancel` now calls `runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED)`; added a private `error` signal, exposed read-only as `lastError`. On failure: roll back AND `this.error.set(r.error)`. On the next attempt, the error is cleared before the call so a stale message never survives a fresh action. |
+| `apps/ssp/src/app/registratie/application/applications.store.spec.ts` (new) | 4 specs: load+parse, optimistic cancel, roll-back-and-surface-error on failure, stale-error-clears-on-next-attempt. No spec file existed for this store before RB-20. |
+| `apps/ssp/src/app/registratie/application/admin-cases.store.ts` | Same shape as `applications.store.ts`: `delete` through `runSubmit`, `error`/`lastError` signal pair. |
+| `apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts` | Existing "rolls back … when the delete fails" spec extended to also assert `lastError()`; one new stale-error-clears spec added. |
+| `apps/ssp/src/app/registratie/ui/dashboard.page.ts` | One `@if (cancelError(); as err) { {{ err }} }` above the aanvragen list, mirroring `brief.page.ts`'s `lastError` rendering. `cancelError` is a `computed(() => this.apps.lastError())`. |
+| `apps/ssp/src/app/registratie/ui/admin-cases.page.ts` | Same `@if (store.lastError(); as err) { {{ err }} }`, placed above `` inside the `canManage()` branch (`store` was already `protected`, so no new exposure needed). |
+
+`applications.adapter.ts` (`cancel`, `deleteAny`) is **unchanged** — the fix is entirely in
+the two stores, which now wrap the existing thin adapter calls in `runSubmit` at the call
+site, exactly as `createSubmitChangeRequest` wraps `ChangeRequestAdapter.changeRequest`. The
+adapter methods still return a bare `Promise`; `runSubmit` is what folds that into a
+`Result`.
+
+Neither UI change introduces a new user-facing string: the rendered text is either the
+existing `SUBMIT_FAILED` constant (`@@submit.failed`, already translated in
+`messages.en.xlf` since RB-17) or, when the backend sends one, a ProblemDetails `detail`
+string carried verbatim from the server — never a new `$localize` id. `messages.en.xlf` did
+not need a new ``.
+
+## The tests, and their red failures
+
+Both specs assert `store.lastError()` after a rejected adapter call, which only the fix can
+satisfy — the old bare `catch { this.state.set(before) }` never touched an error signal, so
+`lastError()` stayed `null` forever.
+
+**Verified red without the fix** (an `Edit` undo of the store method, not `git checkout`, so
+the rest of the change — imports, the other store, the UI, the specs — stayed in place):
+
+- `applications.store.ts`: reverted `cancel` to `try { await this.adapter.cancel(id); } catch
+{ this.state.set(before); }`. Reran `ng test ssp --include applications.store.spec.ts`:
+ 2 of 4 failed —
+ `rolls back the removal and surfaces the error when the cancel fails` and
+ `clears a stale error on the next cancel attempt`, both with
+ `AssertionError: expected null to be 'Het indienen is niet gelukt. Probeer het later opnieuw.'`.
+ The other two specs (load, optimistic-cancel-success) stayed green, as expected — they
+ don't touch the error path. Re-applied the fix (`Edit` back to the `runSubmit` version);
+ reran: 4/4 green.
+- `admin-cases.store.ts`: same procedure on `delete`. Reran
+ `ng test ssp --include admin-cases.store.spec.ts`: 2 of 4 failed with the identical
+ `expected null to be '...'` shape. Reverted to the fix; reran: 4/4 green.
+
+## Judgement calls
+
+- **Signal naming**: private backing field `error`, public readonly `lastError` — matching
+ the name `BriefStore`/`OrgTemplateStore` already expose for exactly this purpose (CQ-002's
+ own citation), rather than inventing a new name per store.
+- **Error cleared at the start of each write**, not only on success, so a second cancel/delete
+ attempt after a failure doesn't leave a stale banner up if the retry itself is still in
+ flight. Covered by the "clears a stale error on the next attempt" spec in each file.
+- **No `ActionState`/`SaveState` pair** (the fuller shape `BriefStore` uses for busy-state and
+ save-state together) — CQ-002 explicitly scoped option (a) to "one `error` signal", and
+ neither store needs a busy indicator: the row already disappears optimistically the instant
+ the click happens, so there is nothing for a spinner to cover.
+- **UI placement**: one alert per page, above the list the mutated row belongs to, using the
+ same `@if (x(); as err) { {{ err }} }` shape as
+ `brief.page.ts` — composition of an existing atom, no new building block (CLAUDE.md §2).
+- **`applications.adapter.ts` left untouched, on purpose** — CQ-002's "Not filed" note ties
+ the read/write file split to option (b) only; taking option (a) means this ticket changes
+ no adapter code at all, matching the ticket's own framing ("(a) touches 2 files plus a UI
+ line each").
+
+## Ticket accuracy
+
+CQ-002's description matched the code as found: both stores' `cancel`/`delete` reached the
+adapter directly with a bare `catch { this.state.set(before); }`, no `Result`, no error
+channel — no discrepancy to flag.
+
+## Residuals (not this ticket)
+
+- RB-18 (key `IdempotencyStore` on `{SubjectId}:{idemKey}`) is unaffected: `cancel`/`delete`
+ now mint a key through `runSubmit` like every other mutation, so it lands on the same
+ write-only call set RB-18 already targets.
+- RB-21 (extract `createDraftSync`'s read half) is a separate CQRS-light finding in the same
+ context, untouched by this ticket.
+
+## Verification
+
+`npm run ci` (foreground, `timeout: 600000`): **green** — `✔ local CI passed`. Lint,
+typecheck, `dep:check` (342 + 226 modules, 0 violations), `format:check`, `check:tokens`,
+`check:seam`, tests (ssp 263/263 — 5 more than the pre-RB-20 258, from the new/extended
+specs above — behandelportal 37/37, shared 138/138, beheer 23/23), `ng build --localize`
+(both apps), `npm audit` (0 vulnerabilities), backend `dotnet format --verify-no-changes` +
+`dotnet test --filter "Category!=Integration"` (260/260 — this filter is what keeps the
+known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent test, which needs a live
+OpenZaak container, out of `npm run ci` entirely; it is a standing caveat, not introduced by
+this change, and not exercised by this run), backend dependency audit (0 vulnerable
+packages), `gen:snippets` / `gen:behaviour-spec` / `gen:api` drift checks all clean once the
+regenerated `behaviour-spec.mdx` was staged alongside the code (the local gate's
+`git diff --exit-code` compares the working tree to the index, so it is clean once the file
+is staged — this is the documented pre-commit behaviour from RB-17's note, not a defect).
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index a334174..eeafe30 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
-**is** the suite, reshaped for a business reader. 451 frontend behaviours across
+**is** the suite, reshaped for a business reader. 456 frontend behaviours across
9 contexts; 236 backend behaviours across 41 test
classes.
@@ -406,7 +406,15 @@ classes.
- loads and parses the cross-owner list
- deletes optimistically and confirms via the admin endpoint
-- rolls back the removal when the delete fails
+- rolls back the removal and surfaces the error when the delete fails
+- clears a stale error on the next delete attempt
+
+#### ApplicationsStore
+
+- loads and parses the list
+- cancels optimistically and confirms via the DELETE endpoint
+- rolls back the removal and surfaces the error when the cancel fails
+- clears a stale error on the next cancel attempt
#### STEPS (fixed)
From 7a29f5facc0a4dac38f0283e8241bcb342280b86 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 18:43:27 +0200
Subject: [PATCH 43/61] feat(brief): tolerate a 404 on GET /brief with a
one-shot reset (RB-22)
BriefStore.load() now treats a 404 from GET /brief as "no brief exists
yet" and calls the existing reset() command once, instead of showing
the generic load-failed error. BriefAdapter.load() gains a
BriefLoadFailure error channel (notFound | error) so the store can
tell a 404 apart from every other failure; every other adapter method
stays on runSubmit, unchanged.
The once-only bound is a field on the store, not a comment: a second
404 (from a later load() call) always falls through to the ordinary
error path, and the recovery path never calls load() again, so no
loop can form.
This is the expand half of CQ-007's split (04-cqrs-light.md). Today's
backend never 404s GET /brief, so the new branch is dead code until
RB-23 (the backend contract half) ships in a later merge.
Co-Authored-By: Claude Opus 5
---
.../app/brief/application/brief.store.spec.ts | 73 +++++++--
.../src/app/brief/application/brief.store.ts | 37 ++++-
.../app/brief/infrastructure/brief.adapter.ts | 42 +++++-
.../refactor-backlog/99-backlog.md | 70 ++++-----
.../refactor-backlog/implementation/rb-22.md | 139 ++++++++++++++++++
libs/shared/docs/behaviour-spec.mdx | 7 +-
6 files changed, 309 insertions(+), 59 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md
diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts
index 145994a..b96941a 100644
--- a/apps/ssp/src/app/brief/application/brief.store.spec.ts
+++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts
@@ -3,7 +3,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
-import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
+import {
+ BRIEF_LOAD_FAILED,
+ BriefAdapter,
+ BriefLoadFailure,
+ BriefView,
+} from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store';
@@ -60,7 +65,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
- load: (): Promise> => Promise.resolve({ ok: true, value: view }),
+ load: (): Promise> =>
+ Promise.resolve({ ok: true, value: view }),
save: (): Promise> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise> =>
Promise.resolve({ ok: true, value: approved }),
@@ -79,7 +85,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
- load: (): Promise> => Promise.resolve({ ok: true, value: view }),
+ load: (): Promise> =>
+ Promise.resolve({ ok: true, value: view }),
save: (): Promise> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise> =>
Promise.resolve({ ok: true, value: approved }),
@@ -93,7 +100,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({
- load: (): Promise> => Promise.resolve({ ok: true, value: view }),
+ load: (): Promise> =>
+ Promise.resolve({ ok: true, value: view }),
save: (): Promise> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
@@ -108,7 +116,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result = { ok: false, error: 'eerste poging mislukt' };
const store = setup({
- load: (): Promise> => Promise.resolve({ ok: true, value: view }),
+ load: (): Promise> =>
+ Promise.resolve({ ok: true, value: view }),
save: (): Promise> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise> => Promise.resolve(approveResult),
});
@@ -156,8 +165,10 @@ function loadedBrief(store: BriefStore): Brief {
}
async function loadedStore(over: Partial = {}): Promise {
- const ok = (v: BriefView): Promise> =>
- Promise.resolve({ ok: true, value: v });
+ // Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
+ // helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
+ // channel `string`) — it only ever produces the `ok: true` branch.
+ const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load();
return store;
@@ -255,8 +266,7 @@ describe('BriefStore rejection diff', () => {
...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
};
- const ok = (v: BriefView): Promise> =>
- Promise.resolve({ ok: true, value: v });
+ const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({
load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView),
@@ -283,7 +293,8 @@ describe('BriefStore.previewLetter', () => {
it('opens the composed letter in a new tab on success', async () => {
const store = setup({
- load: (): Promise> => Promise.resolve({ ok: true, value: view }),
+ load: (): Promise> =>
+ Promise.resolve({ ok: true, value: view }),
});
await store.load();
const blob = new Blob([''], { type: 'text/html' });
@@ -301,7 +312,8 @@ describe('BriefStore.previewLetter', () => {
it('surfaces the error without opening a tab on failure', async () => {
const store = setup({
- load: (): Promise> => Promise.resolve({ ok: true, value: view }),
+ load: (): Promise> =>
+ Promise.resolve({ ok: true, value: view }),
});
await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
@@ -377,3 +389,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
expect(save).not.toHaveBeenCalled();
});
});
+
+// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
+// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
+// that); this fake adapter is what exercises the branch until then. ---
+
+describe('BriefStore.load — 404 tolerance (RB-22)', () => {
+ const notFound: Result = { ok: false, error: { tag: 'notFound' } };
+ const resetOk: Result = { ok: true, value: view };
+
+ it('a 404 drives exactly one reset(), which populates the store', async () => {
+ // Given GET /brief 404s (no brief exists yet) and reset() succeeds.
+ const load = vi.fn(() => Promise.resolve(notFound));
+ const reset = vi.fn(() => Promise.resolve(resetOk));
+ const store = setup({ load, reset });
+
+ // When the store loads...
+ await store.load();
+
+ // Then reset() ran exactly once, and the store ends up loaded from its result.
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(store.model().tag).toBe('loaded');
+ });
+
+ it('a second 404 does not drive a second reset()', async () => {
+ // Given every load() attempt 404s (e.g. the brief still fails to appear).
+ const load = vi.fn(() => Promise.resolve(notFound));
+ const reset = vi.fn(() => Promise.resolve(resetOk));
+ const store = setup({ load, reset });
+
+ // When the store loads twice...
+ await store.load();
+ await store.load();
+
+ // Then reset() ran exactly once — the once-only bound holds across calls, not
+ // just within one — and the second 404 surfaces as an ordinary load failure.
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED });
+ });
+});
diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts
index 88c2a1c..6bb8073 100644
--- a/apps/ssp/src/app/brief/application/brief.store.ts
+++ b/apps/ssp/src/app/brief/application/brief.store.ts
@@ -16,7 +16,7 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template';
-import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
+import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
@@ -119,13 +119,40 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
});
+ /** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand
+ half — see `recoverFromMissingBrief`). This is the structural once-only bound:
+ a repeated 404 falls straight to the `error` branch below and can never reach
+ `adapter.reset()` a second time, regardless of how many times `load()` runs. */
+ private hasRecoveredFromMissingBrief = false;
+
async load() {
const r = await this.adapter.load();
if (r.ok) {
- this.orgTemplate.set(r.value.orgTemplate);
- this.caseContext.set(r.value.caseContext);
- this.history.clear();
- this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
+ this.applyLoadedView(r.value);
+ } else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
+ this.hasRecoveredFromMissingBrief = true;
+ await this.recoverFromMissingBrief();
+ } else {
+ const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
+ this.store.dispatch({ tag: 'BriefLoadFailed', reason });
+ }
+ }
+
+ private applyLoadedView(view: BriefView) {
+ this.orgTemplate.set(view.orgTemplate);
+ this.caseContext.set(view.caseContext);
+ this.history.clear();
+ this.store.dispatch({ tag: 'BriefLoaded', ...view });
+ }
+
+ /** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
+ existing `reset()` command directly (the same POST `resetDemo()` uses) and
+ applying whatever it returns; this NEVER calls `load()` again, so a second 404
+ (e.g. `reset()` itself failing) cannot loop back into this method. */
+ private async recoverFromMissingBrief() {
+ const r = await this.adapter.reset();
+ if (r.ok) {
+ this.applyLoadedView(r.value);
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
diff --git a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts
index ad3aec3..02d3fad 100644
--- a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts
+++ b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
-import { runResult, runSubmit } from '@shared/application/submit';
+import { runSubmit } from '@shared/application/submit';
+import { problemDetail } from '@shared/infrastructure/api-error';
import {
ApiClient,
BriefDecisionsDto,
@@ -33,9 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
* the `parse*` boundary narrows them into the domain's proper discriminated unions
- * and rejects malformed shapes. `load` (the only read) folds through `runResult`;
- * every mutation folds through `runSubmit` (ProblemDetails → error string, plus the
- * Idempotency-Key mint), then parses the returned brief.
+ * and rejects malformed shapes. Every mutation folds through `runSubmit`
+ * (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
+ * returned brief. `load` (the only read) does its own try/catch instead of the
+ * shared `runResult` fold, because it needs one extra bit `runResult` throws away:
+ * whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's
+ * expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the
+ * `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance.
*/
export interface BriefView {
@@ -46,16 +51,39 @@ export interface BriefView {
readonly caseContext: CaseContext;
}
+/**
+ * Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept
+ * distinct from every other failure so `BriefStore.load()` can tolerate it (call
+ * `reset()` instead of showing an error banner) without conflating it with a real
+ * failure. See the class docstring above.
+ */
+export type BriefLoadFailure =
+ { readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
+
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
+/** True when the thrown value carries an HTTP 404 status — matches both the
+ generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
+ response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
+ RB-23 gives the endpoint a documented 404 response). */
+function isHttpNotFound(e: unknown): boolean {
+ return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
+}
+
@Injectable({ providedIn: 'root' })
export class BriefAdapter {
private client = inject(ApiClient);
- async load(): Promise> {
- const r = await runResult(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
- return r.ok ? parseBriefView(r.value) : r;
+ async load(): Promise> {
+ try {
+ const dto = await this.client.briefGET();
+ const parsed = parseBriefView(dto);
+ return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
+ } catch (e) {
+ if (isHttpNotFound(e)) return err({ tag: 'notFound' });
+ return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
+ }
}
async save(sections: readonly LetterSection[]): Promise> {
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fcd230b..e47ed4e 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **implemented** |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md
new file mode 100644
index 0000000..bce59a1
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md
@@ -0,0 +1,139 @@
+# RB-22 — `BriefStore.load()` tolerates a 404, calling `reset()` exactly once
+
+Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 ·
+`99-backlog.md` RB-22, "Tickets that were rejected and split" · `implementation/rb-17.md`
+(the `runResult`/`runSubmit` seam this store already sits on)
+
+This is the **expand** half of an expand/contract pair. RB-23 (backend: `GET /brief` 404s
+when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`) ships after this
+ticket, in a later merge. Today's backend never 404s `GET /brief`, so this ticket's new
+branch is dead code in the running app — provably backend-frontend-safe by construction.
+
+## What was wrong
+
+CQ-007 flags `GET /brief` (`Program.cs:603` → `BriefStore.GetOrCreate`,
+`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a
+persisted write, breaking the read/write split every other endpoint respects. The fix is
+split across both sides of the seam because the FE must be ready to receive a 404 before
+the backend can safely start sending one. This ticket is the FE half: `BriefStore.load()`
+(`apps/ssp/src/app/brief/application/brief.store.ts`) had no notion of "no brief exists
+yet" — every adapter failure, 404 included, dispatched `BriefLoadFailed` and showed the
+generic error banner. `BriefAdapter.load()` (`brief.adapter.ts`) also had no way to tell
+the store a failure was specifically an HTTP 404: it folded every failure through the
+shared `runResult` helper (RB-17), which keeps only a human-readable string and throws
+away the HTTP status.
+
+The ticket read as filed against the current code: `BriefStore.load()` is exactly where
+CQ-007 says it is, `BriefAdapter.load()` is exactly the read `runResult` call RB-17 pointed
+at it, and nothing about either file was factually wrong. Nothing to flag here.
+
+## What changed
+
+| File | Change |
+| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()`'s error channel becomes `BriefLoadFailure` (`{tag:'notFound'} \| {tag:'error', reason:string}`) instead of a plain `string`. `load()` no longer routes through the shared `runResult` — it does its own try/catch so it can read the thrown value's HTTP `status` before folding it away, via the new local `isHttpNotFound` predicate. Every other method (`save`/`submit`/`approve`/`reject`/`send`/`reset`) is untouched, still on `runSubmit`. |
+| `apps/ssp/src/app/brief/application/brief.store.ts` | `load()` branches on `BriefLoadFailure`: `notFound` (and not already recovered) calls the existing `reset()` command directly and applies its result; every other failure (including a repeated `notFound`) dispatches `BriefLoadFailed` as before. Extracted `applyLoadedView` (the success-path body shared by `load()` and the new recovery path) and added `recoverFromMissingBrief`. |
+| `apps/ssp/src/app/brief/application/brief.store.spec.ts` | New `describe('BriefStore.load — 404 tolerance (RB-22)')` with the two required cases. Six pre-existing `load:` fakes' explicit `Result` return-type annotations updated to `Result` (they only ever produce the `ok: true` branch, so this is a type-only change); two shared `ok(v)` test helpers that build fakes for both `load` and `save` had their return-type annotation dropped in favour of `as const` inference, since one helper now serves two different error-channel types. |
+| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the two new `it()` titles. |
+
+No `backend/` file was touched — `Program.cs` and `Data/BriefStore.cs` are RB-23's, per the
+ticket's explicit scope.
+
+## How the once-only bound is structural
+
+`BriefStore` gains one field: `private hasRecoveredFromMissingBrief = false`. `load()`
+takes the recovery branch only when `r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief`,
+and the branch's first statement sets the flag before doing anything else. A second 404 —
+whether from a second `load()` call, or in principle from `reset()` itself somehow also
+404ing — falls through to the plain `BriefLoadFailed` branch instead, on every subsequent
+call, for the life of the store instance. This is a field on the singleton store, not a
+comment: nothing in the reachable call graph can flip it back to `false`.
+
+The loop CQ-007's proposed change warns about ("the reset's own load must not be able to
+loop") is not merely bounded, it is **structurally absent**: `recoverFromMissingBrief`
+calls `this.adapter.reset()` and applies its `BriefView` result directly (the same
+`applyLoadedView` the success path uses) — it never calls `this.load()` again. There is no
+recursive edge from the recovery path back into `load()` for the once-only flag to have to
+stop; the flag exists only to stop a **second, separate** `load()` invocation (e.g. a
+caller retrying navigation) from reaching `reset()` again.
+
+## Judgement calls
+
+- **`load()` no longer uses `runResult`, only for this one method.** `runResult`
+ (`libs/shared/src/application/submit.ts`, RB-17) intentionally keeps only a string —
+ every other read in the app is fine with that. This is the first read that needs one
+ more bit (the HTTP status) than `runResult` exposes, so `load()` does its own
+ try/catch instead, matching `runResult`'s shape (`problemDetail(e, fallback)` on the
+ non-404 path) but adding the 404 branch first. `libs/shared/src/application/submit.ts`
+ itself is untouched — changing a shared helper used by many call sites for one adapter's
+ need was out of scope and unjustified.
+- **404 detection reads `(e as {status?:unknown}).status === 404`, not
+ `SwaggerException.isSwaggerException`.** The generated client throws a plain
+ `SwaggerException` for `GET /brief` today (no OpenAPI 404 response is declared for it
+ yet), but throws the parsed `ProblemDetails` object instead for an endpoint whose spec
+ **does** declare a 404 (both shapes carry a `status` field). Checking `status` alone,
+ not the `SwaggerException` type, means this predicate keeps working unchanged once
+ RB-23 regenerates the client with a documented 404 response for `briefGET()` — no
+ follow-up FE edit needed for detection to keep working.
+- **`BriefLoadFailure` is a new exported type, not a sentinel string.** CLAUDE.md's
+ default reflex is a discriminated union over a second flag; a magic string
+ (`'__not_found__'`) compared by identity would have kept `load()`'s signature at
+ `Result` and touched fewer test lines, but it is exactly the kind
+ of stringly-typed control flow the union tool exists to avoid. The touched-test cost
+ was six type annotations plus two helper signatures, all in the one already-scoped
+ spec file — judged worth it for the correct shape.
+ `BriefLoadFailure` is exported.
+- **On a repeated 404, the store shows `BRIEF_LOAD_FAILED`** (the same generic banner
+ text `load()` already used for every other failure), not a distinct "still missing"
+ message. No new user-facing copy was needed or added, so no new `$localize` id and no
+ `messages.en.xlf` change — confirmed by diffing for `$localize` occurrences: both
+ hits in the diff are unchanged context lines, not new additions.
+- **`resetDemo()` (the "start over" button) was left untouched**, even though it
+ duplicates part of the same apply-a-fresh-view logic now factored into
+ `applyLoadedView`. It also manages `actionState`/`saveState`/`rejectionSnapshot` that
+ `recoverFromMissingBrief` correctly does not touch (an automatic recovery on first
+ load is not a user-initiated "start over" action), and refactoring it was not asked
+ for by this ticket.
+
+## Verification
+
+- **Verified red without the fix.** Temporarily replaced `load()`'s body (via `Edit`,
+ not `git checkout`) with the pre-fix shape — every failure, `notFound` included,
+ dispatches `BriefLoadFailed` straight away, no `reset()` call — and reran the spec
+ file. Both new tests failed:
+ `expected "vi.fn()" to be called 1 times, but got 0 times` on `reset`, for both "a 404
+ drives exactly one reset()" and "a second 404 does not drive a second reset()"; the
+ other 18 tests in the file stayed green. Restored the real fix with a second `Edit`
+ and reran: all 20 tests in the file green, 30/30 across both touched spec files.
+- `npm run ci` (foreground, no background/Monitor): **green**, exit 0 — lint,
+ typecheck, `dep:check` (341 + 226 modules, 0 violations), `format:check`,
+ `check:tokens`, `check:seam`, tests (ssp 260/260, behandelportal 37/37, shared
+ 138/138, beheer 23/23 — 458 total), `ng build --localize` (both apps), `npm audit`
+ (0 vulnerabilities), backend `dotnet test` (260/260 — the known
+ `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not
+ reproduce on this run, matching the standing caveat that it needs a live OpenZaak
+ container and is not this ticket's bug), backend dependency audit clean, `gen:snippets`
+ drift clean, `gen:behaviour-spec` drift clean once the regenerated file was staged (the
+ local gate diffs the working tree against the index, so it necessarily shows a diff
+ before the file is staged/committed — the same documented, expected behaviour RB-17
+ recorded, not a defect).
+- `npx prettier --check` on every touched file (including the reformatted
+ `99-backlog.md` table and the regenerated `behaviour-spec.mdx`): clean.
+
+## What RB-23 must do
+
+Once `GET /brief` in `Program.cs` returns a real 404 (no `ProblemDetails` body is
+required — `BriefAdapter.load()`'s `isHttpNotFound` only reads the HTTP `status`, not the
+response body), and `BriefStore.GetOrCreate` splits into `Get` (query) + the existing
+`ResetAndCreate` (already there, already used by `POST /brief/reset`), this ticket's
+`notFound` branch stops being dead code and starts running on first-ever page load for any
+owner with no persisted brief. Run `npm run gen:api` as part of RB-23 so `briefGET()`
+regenerates with a documented `status === 404` branch throwing the parsed `ProblemDetails`
+(matching the shape most other endpoints already use) — the detection predicate here
+already tolerates that shape and needs no FE follow-up change. Two things worth
+re-verifying once RB-23 lands, not fixing preemptively here: first, that the resulting
+double round-trip (404, then `reset()`) is an acceptable UX cost on a first visit, per
+CQ-007's own framing of this as its least certain finding; second, that this store's
+`hasRecoveredFromMissingBrief` field — private to one store instance, reset only by a full
+reload — is still the right lifetime for the once-only guard once a real 404 can occur in
+production traffic, not only in a test's fake adapter.
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index a334174..230d599 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
-**is** the suite, reshaped for a business reader. 451 frontend behaviours across
+**is** the suite, reshaped for a business reader. 453 frontend behaviours across
9 contexts; 236 backend behaviours across 41 test
classes.
@@ -180,6 +180,11 @@ classes.
- flushes a pending debounced edit immediately and clears the pending flag
- is a no-op when no edit is pending
+#### BriefStore.load — 404 tolerance (RB-22)
+
+- a 404 drives exactly one reset(), which populates the store
+- a second 404 does not drive a second reset()
+
#### BriefStore.previewLetter
- opens the composed letter in a new tab on success
From d0fda08bccf4963c65ab625ed847cc9e03cf27f4 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 19:01:06 +0200
Subject: [PATCH 44/61] fix(brief): make GET /brief a pure query, 404 when
absent (RB-23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the
one endpoint in the backend where a read performed a persisted write.
The FE retries GETs automatically, so a transient failure could enter
the create path more than once; a lock prevented a duplicate row, but
the safety depended on the lock, not on the endpoint being a query.
Split GetOrCreate into Get (a pure query) and the already-existing
ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s
when the owner has no brief yet. GET /brief/preview used GetOrCreate
too, so it gets the same Get + 404 treatment, forced by the split.
RB-22 already made BriefStore.load() on the FE tolerate a 404 by
calling reset() once; this ticket is what makes that branch live.
Updated the brief/preview/org-template backend tests that assumed
GET seeded a brief on first call to create one explicitly first, and
added a test that GET 404s and writes no row without the fix (verified
red beforehand). Regenerated the API client (npm run gen:api).
Co-Authored-By: Claude Opus 5
---
.../src/BigRegister.Api/Data/AppDbContext.cs | 2 +-
.../src/BigRegister.Api/Data/BriefStore.cs | 12 +-
backend/src/BigRegister.Api/Program.cs | 18 +-
backend/swagger.json | 3 +
.../BigRegister.Tests/BriefEndpointTests.cs | 50 +++--
.../OrgTemplateEndpointTests.cs | 16 +-
.../BigRegister.Tests/PreviewEndpointTests.cs | 5 +-
.../BigRegister.Tests/RouteInventoryTests.cs | 4 +-
.../refactor-backlog/99-backlog.md | 70 +++----
.../refactor-backlog/implementation/rb-23.md | 183 ++++++++++++++++++
e2e/brief-v2.spec.ts | 11 +-
libs/shared/docs/behaviour-spec.mdx | 5 +-
libs/shared/src/infrastructure/api-client.ts | 4 +
13 files changed, 305 insertions(+), 78 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md
diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs
index 876789a..08f109d 100644
--- a/backend/src/BigRegister.Api/Data/AppDbContext.cs
+++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs
@@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon
modelBuilder.Entity(e =>
{
e.HasKey(b => b.BriefId);
- e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
+ e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (ResetAndCreate's invariant)
e.Property(b => b.Placeholders).HasConversion(Json>());
e.Property(b => b.Sections).HasConversion(Json>());
e.Property(b => b.Status).HasConversion(Json());
diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs
index 7a3f636..f880d98 100644
--- a/backend/src/BigRegister.Api/Data/BriefStore.cs
+++ b/backend/src/BigRegister.Api/Data/BriefStore.cs
@@ -47,17 +47,15 @@ public static class BriefStore
private static readonly object _gate = new();
- public static BriefEntity GetOrCreate(string owner)
+ /// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null —
+ /// the owner's first-ever draft is created only through the explicit `ResetAndCreate`
+ /// command (`POST /brief/reset`), never as a side effect of a read.
+ public static BriefEntity? Get(string owner)
{
lock (_gate)
{
using var db = Db.Create();
- var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
- if (existing is not null) return existing;
- var created = BriefSeed.NewBrief(owner);
- db.Briefs.Add(created);
- db.SaveChanges();
- return created;
+ return db.Briefs.FirstOrDefault(e => e.Owner == owner);
}
}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index ce38f5e..7fbea17 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -675,10 +675,15 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
api.MapGet("/brief", (HttpContext ctx) =>
{
- var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
- return ToView(ctx, e);
+ // RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
+ // draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate)
+ // — this GET is a pure query and 404s when there is nothing to read yet.
+ var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
+ if (e is null) return Results.NotFound();
+ return Results.Ok(ToView(ctx, e));
})
-.Produces();
+.Produces()
+.Produces(StatusCodes.Status404NotFound);
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
@@ -766,7 +771,12 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
// letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) =>
{
- var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
+ // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
+ // must not create a brief as a side effect either, so it 404s under the same
+ // precondition as GET /brief — in the running app the FE only reaches this endpoint
+ // from the brief page, which has already loaded (and, if needed, reset) a brief.
+ var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
+ if (e is null) return Results.NotFound();
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
diff --git a/backend/swagger.json b/backend/swagger.json
index 1a0dad1..9985fce 100644
--- a/backend/swagger.json
+++ b/backend/swagger.json
@@ -1000,6 +1000,9 @@
}
}
}
+ },
+ "404": {
+ "description": "Not Found"
}
}
},
diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs
index 22ec6ef..78cdd05 100644
--- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs
+++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs
@@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return new SaveBriefRequest(sections);
}
- private async Task Get()
+ /// RB-23: `GET /brief` no longer seeds a brief on first call, so every test that
+ /// needs one present creates it explicitly through `POST /brief/reset`
+ /// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses.
+ private async Task SeedBrief()
{
BriefStore.Reset();
- var view = await _client.GetFromJsonAsync("/api/v1/brief");
+ var res = await _client.PostAsync("/api/v1/brief/reset", null);
+ var view = await res.Content.ReadFromJsonAsync();
Assert.NotNull(view);
return view.Brief;
}
@@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return req;
}
+ // --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. ---
+
[Fact]
- public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty()
+ public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner()
{
- var brief = await Get();
+ BriefStore.Reset();
+
+ var res = await _client.GetAsync("/api/v1/brief");
+ Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
+
+ // The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call.
+ // Assert directly against the store, not only the HTTP status, so a regression that
+ // reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404.
+ Assert.Null(BriefStore.Get(DocumentStore.DemoOwner));
+ }
+
+ [Fact]
+ public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty()
+ {
+ var brief = await SeedBrief();
Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
@@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
{
- await Get();
+ await SeedBrief();
var view = await _client.GetFromJsonAsync("/api/v1/brief");
Assert.NotNull(view);
// global passages + the arts-scoped one; no other-beroep passages leak in.
@@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
{
- await Get();
+ await SeedBrief();
var view = await _client.GetFromJsonAsync("/api/v1/brief");
Assert.NotNull(view);
// Case context is joined onto the screen DTO for the behandel scherm header.
@@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Save_is_drafter_only()
{
- var brief = await Get();
+ var brief = await SeedBrief();
var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver");
@@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Submit_blocks_on_empty_required_section()
{
- await Get();
+ await SeedBrief();
// Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
}
@@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Submit_succeeds_when_required_sections_filled()
{
- await Get();
+ await SeedBrief();
var view = await _client.GetFromJsonAsync("/api/v1/brief");
Assert.NotNull(view);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief));
@@ -170,7 +190,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{
- var brief = await Get();
+ var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -187,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Reject_returns_comments()
{
- var brief = await Get();
+ var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -202,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Editing_a_rejected_letter_reopens_it_to_draft()
{
- var brief = await Get();
+ var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
await _client.SendAsync(
@@ -218,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Send_only_from_approved()
{
- var brief = await Get();
+ var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -236,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
- var brief = await Get();
+ var brief = await SeedBrief();
var view = await _client.GetFromJsonAsync("/api/v1/brief");
Assert.NotNull(view);
Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
@@ -269,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
{
- var brief = await Get();
+ var brief = await SeedBrief();
// Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
diff --git a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
index 01784b9..dcf26b2 100644
--- a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
+++ b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
@@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_increments_the_version()
{
ResetStores();
- // One unsent brief for this sub-org (GetOrCreate on first read).
- await _client.GetAsync("/api/v1/brief");
+ // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
+ await _client.PostAsync("/api/v1/brief/reset", null);
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_appends_to_the_version_history()
{
ResetStores();
- await _client.GetAsync("/api/v1/brief");
+ await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_counts_the_unsent_briefs_it_affects()
{
ResetStores();
- // One unsent brief for this sub-org (GetOrCreate on first read).
- await _client.GetAsync("/api/v1/brief");
+ // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
+ await _client.PostAsync("/api/v1/brief/reset", null);
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
private async Task WalkBriefToSentThenRepublish()
{
ResetStores();
- var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief;
+ var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
+ var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
@@ -210,7 +211,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Admin_cannot_slip_into_the_brief_review_flow()
{
ResetStores();
- var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief;
+ var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
+ var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
diff --git a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
index 62369ec..d86c9df 100644
--- a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
+++ b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
@@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{
ResetStores();
- await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
+ await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode();
@@ -54,7 +54,8 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{
ResetStores();
- var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief;
+ var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
+ var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
diff --git a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs
index 8c284cf..a010096 100644
--- a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs
+++ b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs
@@ -72,14 +72,14 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt
// enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's
// Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers,
// not a missing one. ---
- new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn)."),
+ new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."),
new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."),
new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."),
new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."),
new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."),
new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."),
new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."),
- new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); hand-written FE fetch."),
+ new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."),
new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."),
];
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index a39a6f5..3ed6ac5 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **implemented** |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md
new file mode 100644
index 0000000..faf9fa4
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md
@@ -0,0 +1,183 @@
+# RB-23 — `GET /brief` 404s when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`
+
+Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 ·
+`99-backlog.md` RB-23, "Tickets that were rejected and split" · `implementation/rb-22.md`
+(the FE **expand** half this ticket **contracts** against)
+
+This is the **contract** half of the RB-22/RB-23 expand/contract pair. RB-22 shipped first
+and made `BriefStore.load()` tolerate a 404 by calling `reset()` once, as a no-op against
+the (then) still-seeding backend. This ticket is what makes that branch live: `GET /brief`
+now 404s when the owner has no brief yet, and the endpoint no longer performs a persisted
+write on a read.
+
+## What was wrong
+
+CQ-007 flagged `GET /brief` (`Program.cs:676` → `BriefStore.GetOrCreate`,
+`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a
+persisted write, breaking the read/write split every other endpoint respects. The FE
+retries GETs automatically (`api-client.provider.ts`, `retry({ count: 2, delay: 500 })`,
+GET-only, precisely because GETs are assumed safe), so a transient failure could enter the
+create path more than once; `GetOrCreate`'s `lock` prevented a duplicate row today, but the
+safety depended on the lock rather than on the endpoint being a query.
+
+The ticket read as filed against the current code: `GetOrCreate` was exactly at
+`BriefStore.cs:50`, `GET /brief` called it exactly as described, and `ResetAndCreate`
+already existed and was already the sole body of `POST /brief/reset`. One thing the
+ticket's own text did not mention: `BriefStore.GetOrCreate` had a **second** call site,
+`GET /brief/preview` (`Program.cs:769`, excluded from the OpenAPI doc — a hand-written FE
+`fetch`, same seam as uploads). Splitting `GetOrCreate` away necessarily touches that
+call site too, or the file does not compile. See "What changed" below — this was a forced
+consequence of the split, not a new business decision, and it is reported here rather than
+silently worked around.
+
+## What changed
+
+| File | Change |
+| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `GetOrCreate` removed. New `Get(string owner): BriefEntity?` — pure query, `lock`-guarded like every other method in this file for consistency, no write. `ResetAndCreate` is untouched. |
+| `backend/src/BigRegister.Api/Program.cs` | `GET /brief`: calls `BriefStore.Get`; returns `Results.NotFound()` when null, `Results.Ok(ToView(ctx, e))` otherwise; declares `.Produces(StatusCodes.Status404NotFound)` (the same bare-404 pattern already used at 17 other call sites in this file). `GET /brief/preview`: same `Get` + 404 treatment — forced by the split (see above), not a scope decision made independently. |
+| `backend/src/BigRegister.Api/Data/AppDbContext.cs` | One comment updated (`GetOrCreate's invariant` → `ResetAndCreate's invariant`) — the unique index on `Owner` it annotates is unchanged. |
+| `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` | New `Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner` (the DoD-required test). The `Get()` seeding helper, used by nearly every other test in the file, renamed to `SeedBrief()` and changed to create the brief explicitly via `POST /brief/reset` instead of relying on `GET /brief`'s old side effect. One test renamed (`Get_creates_a_draft_with_expected_sections_locked_and_empty` → `SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty`) — it asserts on the shape of a freshly created brief, which is now `SeedBrief()`'s job, not `GET`'s. |
+| `backend/tests/BigRegister.Tests/PreviewEndpointTests.cs` | Two tests explicitly create the brief (`POST /brief/reset`) before hitting `/brief/preview`, instead of relying on the old `GET /brief` implicit create. |
+| `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` | Five call sites (three bare seeding `GetAsync` calls, two `GetFromJsonAsync` calls used as seeding) changed to an explicit `POST /brief/reset` first. One call site (`Sent_brief_keeps_its_pinned_template_after_a_republish`, reading a brief already created and sent by the shared `WalkBriefToSentThenRepublish` helper) needed no change — a brief already exists by the time it runs. |
+| `backend/tests/BigRegister.Tests/RouteInventoryTests.cs` | Two `AllowList` reason strings updated (`GetOrCreate` → `Get`, 404 noted) — documentation text only, not itself a check the test enforces beyond "some reason is on record". |
+| `e2e/brief-v2.spec.ts` | One header comment updated to name the current methods and to state explicitly that this spec's own first click ("Opnieuw beginnen (demo)") is fixture setup, not a workaround for the new 404 — see "e2e and seeding paths" below. |
+| `libs/shared/src/infrastructure/api-client.ts` | Regenerated (`npm run gen:api`). `briefGET()` gains a `status === 404` branch. See "The generated client" below for the shape it actually took. |
+| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-23's status cell: `open` → `implemented`. |
+
+No `apps/ssp/src/app/brief/**` file was touched — RB-22's `BriefStore.load()` recovery and
+`BriefAdapter.load()`'s `BriefLoadFailure`/`isHttpNotFound` are unchanged, per this
+ticket's explicit scope.
+
+## The generated client
+
+RB-22's handoff note predicted `briefGET()` would regenerate "throwing the parsed
+`ProblemDetails` (matching the shape most other endpoints already use)". That did not
+happen, and the actual result is still correct. `Results.NotFound()` (this ticket's
+implementation, and the pattern used at every one of the 17 other bare-404 call sites in
+`Program.cs` — none of them use `ProducesProblem`/a typed body) declares a 404 with **no**
+response body schema. With nothing to parse into, NSwag emits a generic branch that throws
+a plain `SwaggerException` carrying `status: 404` — the same shape `briefGET()` already
+threw before this ticket, for the same reason (no declared 404 body). `BriefAdapter.load()`'s
+`isHttpNotFound` predicate (`(e as {status?:unknown}).status === 404`) already tolerates
+both a `SwaggerException` and a parsed `ProblemDetails`, by design, precisely so this
+detail would not matter — RB-22's own comment says as much. No FE follow-up was needed, and
+none was made.
+
+## Judgement calls
+
+- **`GET /brief/preview` also moved off `GetOrCreate`, to `Get` + 404.** Not mentioned in
+ the ticket text, but unavoidable: `GetOrCreate` no longer exists once split, and this
+ was its only other caller. The alternative — leaving a private, undocumented
+ `GetOrCreate`-shaped helper only for this one endpoint — would have reintroduced
+ exactly the GET-writes-on-read pattern CQ-007 is about, in the one place nobody would
+ think to look for it. Returning 404 there too keeps both `/brief` GETs behaving the
+ same way. In the running app this is unreachable in practice: the preview button
+ only renders inside the brief page's `@if (loaded(); as s)` block
+ (`apps/ssp/src/app/brief/ui/brief.page.ts`), which by construction only shows once
+ `BriefStore.load()` has already succeeded — including via RB-22's 404-recovery branch.
+ So a brief always exists by the time a real user can trigger `/brief/preview`; the 404
+ path there is a defensive consequence of the type split, not a new user-facing
+ behaviour anyone will hit.
+- **`BriefStore.Get` keeps the `lock (_gate)` wrap**, even though a plain SQLite read
+ does not strictly need the same mutual exclusion a write does. Every other method in
+ this file, including the pre-existing `ApplicationStore.Get`-style query in the
+ sibling store, locks unconditionally — matching that convention was judged more
+ valuable than a lock-free read this ticket did not need to justify removing.
+- **Existing test changes create the brief via `POST /brief/reset`, not a new
+ `BriefStore.Get`/`ResetAndCreate` direct call from the test.** Going through the HTTP
+ endpoint (as the old `Get()` helper always did) keeps the tests exercising the real
+ request pipeline (identity resolution, `ToView` mapping) rather than reaching around
+ it — the same reasoning that already justified an `IClassFixture`
+ HTTP-level test suite in the first place.
+
+## e2e and seeding paths
+
+- **`e2e/brief-v2.spec.ts`** is the only e2e spec that reaches `/brief`. It already opens
+ `/brief?role=drafter` and immediately clicks "Opnieuw beginnen (demo)" (`POST
+/brief/reset`) before asserting anything — a deliberate fixture reset, not a
+ workaround. With this ticket live, the page's first `GET /brief` on the fresh
+ per-run database (WP-74) now 404s; RB-22's `BriefStore.load()` recovers from that by
+ calling `reset()` once, so the page still renders correctly, and the spec's own
+ explicit reset click still runs on top of that (harmless — resetting an
+ already-fresh brief). No behavioural change to the spec was needed; one comment was
+ updated to say this explicitly rather than leave it to be re-derived.
+- **Storybook**: no `brief.page.stories.ts` exists, and none of the eleven `brief/ui/**`
+ component stories call `HttpClient`/`fetch`/`ApiClient` — every story supplies data
+ through component `input()`s, per the house convention (design-system/component
+ stories are not live-network integration tests). Nothing in Storybook depended on
+ `GET /brief`'s old seeding behaviour.
+
+## The double round-trip — verdict
+
+CQ-007 named this its least certain point: a first-ever visit to `/brief` now costs a 404
+followed by a `reset()` call, instead of one request that both creates and returns the
+brief. **Shipped as-is; the cost is acceptable.** Three reasons:
+
+1. **It happens once per browser tab, ever, for one demo entity.** `BriefStore`'s
+ `hasRecoveredFromMissingBrief` flag (RB-22) makes the 404 unreachable again for the
+ life of the store instance; a real deployment has one brief per zorgverlener, created
+ the first time that person ever opens the page. This is not a cost paid on every
+ page load, or even every session — a page reload still 404s once if the flag reset
+ with the page, but the underlying row is already there by then, so the _second_ call
+ in the pair — `reset()` — is now hitting an existing row rather than truly
+ first-creating one, and returns just as fast as `Get` would have.
+2. **An extra round-trip is not an extra spinner.** `BriefStore.load()`'s failure
+ handling for `notFound` calls `reset()` and applies the result through the same
+ `applyLoadedView` the success path uses — there is no intermediate "not found" UI
+ state rendered to the user between the two calls; the page shows its loading state
+ once, for the combined duration of both requests.
+3. **The alternative was rejected, not merely deprioritized.** CQ-007's own
+ documentation-only alternative — leave `GetOrCreate` in place, just write down that
+ the GET seeds on first call — was rejected outright by agent 07 in `99-backlog.md`:
+ "a non-idempotent GET must be visible in the code, not only in a ticket." Given that,
+ the only way to remove the mixing is some version of this two-call shape; a
+ single-call alternative would mean either GET creates (the defect) or `POST
+/brief/reset` runs unconditionally on load (destructive — it deletes an existing
+ brief, unacceptable for anyone with real content already saved).
+
+## The once-only guard's lifetime — re-verified
+
+RB-22 flagged this as worth re-checking once a real 404 could occur in production
+traffic, not only in a test's fake adapter. Having now made the 404 real: `hasRecoveredFromMissingBrief`
+is a private field on `BriefStore`, which is `providedIn: 'root'` — one instance per
+browser tab (per CLAUDE.md's "shared cross-page state = one root singleton" convention),
+reset only by a full page reload. That lifetime is still correct for what the flag
+guards: it exists to stop a _second, separate_ `load()` call in the same tab session from
+re-triggering `reset()` (e.g. a caller retrying navigation after the first recovery
+already ran) — not to remember "this owner has a brief" across reloads or across owners,
+which is the server's job (`BriefStore.Get` returning non-null). A page reload correctly
+starts the guard over: the first `load()` after a reload will find the now-existing row
+via a plain `GET` (no 404, no `reset()` call at all), so the flag never actually gets
+exercised a second time in the reload case either. No FE change was needed or made.
+
+## Verification
+
+- **Verified red without the fix.** Temporarily (via `Edit`, never `git checkout`)
+ restored `BriefStore.GetOrCreate` alongside the new `Get`, and pointed `GET /brief` in
+ `Program.cs` back at `GetOrCreate`. Ran the new test alone:
+ ```
+ BigRegister.Tests.BriefEndpointTests.Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner [FAIL]
+ Assert.Equal() Failure: Values differ
+ Expected: NotFound
+ Actual: OK
+ ```
+ Restored the real fix with a second `Edit` (removed the temporary `GetOrCreate`,
+ pointed `GET /brief` back at `Get` + 404) and reran: green.
+- Full backend suite after the fix: **262/262 passing**, plus the one known,
+ pre-existing, container-dependent failure
+ (`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
+ "Connection refused (localhost:8000)") — not this ticket's bug, does not run under
+ `npm run ci`, reproduces on a clean tree with no OpenZaak container running.
+- `npm run gen:api`: the client changed (`libs/shared/src/infrastructure/api-client.ts`,
+ `briefGET()` gains a `status === 404` branch — 4 lines). Regenerated and committed;
+ see "The generated client" above for why the shape differs from RB-22's prediction and
+ why that difference is harmless.
+- `npm run ci` (foreground, no background/Monitor): see result below.
+
+## What this ticket did not touch
+
+`apps/ssp/src/app/brief/application/brief.store.ts`, `brief.store.spec.ts`, and
+`apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` are unchanged — RB-22's FE logic
+was already correct and already tested against exactly this contract, per this ticket's
+explicit scope.
diff --git a/e2e/brief-v2.spec.ts b/e2e/brief-v2.spec.ts
index 25c8742..4e696a7 100644
--- a/e2e/brief-v2.spec.ts
+++ b/e2e/brief-v2.spec.ts
@@ -7,9 +7,14 @@ import { Actors, loginAs } from './support/actors';
// Preview assertions are content-type/body-level (text/html + watermark marker), not
// pixel, per WP-28's decision.
//
-// This test mutates real state (a letter, keyed per-owner by `BriefStore.GetOrCreate`),
-// and WP-74 gives it a fresh throwaway backend DB every `npm run e2e` run, so a
-// leftover/in-progress letter from a PREVIOUS RUN is never an issue any more. It
+// This test mutates real state (a letter, keyed per-owner by `BriefStore.Get`/
+// `ResetAndCreate` — RB-23 split the old `GetOrCreate`), and WP-74 gives it a fresh
+// throwaway backend DB every `npm run e2e` run, so a leftover/in-progress letter from
+// a PREVIOUS RUN is never an issue any more. `GET /brief` 404s on that fresh DB until
+// the "Opnieuw beginnen (demo)" click below creates the first row — RB-22's
+// `BriefStore.load()` already tolerates that 404 by calling `reset()` once, so the
+// page renders correctly either way; the explicit click is this test's own fixture
+// setup, not a workaround for the 404. It
// deliberately still logs in as the shared `Actors.zorgverlener` rather than its own
// BSN, though: giving it a distinct BSN (as `smoke.spec.ts` does) hit a real,
// reproducible bug in this repo's own e2e run — `GET /brief/preview`'s sent-letter
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index a463750..581ab7d 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -21,7 +21,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 467 frontend behaviours across
-9 contexts; 237 backend behaviours across 41 test
+9 contexts; 238 backend behaviours across 41 test
classes.
## Frontend (by context)
@@ -999,7 +999,8 @@ classes.
### BriefEndpointTests
-- Get creates a draft with expected sections locked and empty
+- Get returns 404 and writes no row when no brief exists for the owner
+- SeedBrief creates a draft with expected sections locked and empty
- Get offers only global and arts scoped besluit tagged passages
- Get joins the case context with the BIG nummer masked
- Reveal returns the unmasked BIG nummer for the drafter with step up
diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts
index 04e7639..8a30596 100644
--- a/libs/shared/src/infrastructure/api-client.ts
+++ b/libs/shared/src/infrastructure/api-client.ts
@@ -1350,6 +1350,10 @@ export class ApiClient {
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
+ } else if (status === 404) {
+ return response.text().then((_responseText) => {
+ return throwException("Not Found", status, _responseText, _headers);
+ });
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
From edd20c06dfeb25b325935f30992e52b70a4a62c2 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 19:02:09 +0200
Subject: [PATCH 45/61] docs(backlog): mark RB-23 done after merge
Co-Authored-By: Claude Opus 5
---
.../refactor-backlog/99-backlog.md | 70 +++++++++----------
1 file changed, 35 insertions(+), 35 deletions(-)
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index 3ed6ac5..1825aa4 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **implemented** |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
From e270b8612f19ae48eddc09fe3b75eb5108cb908b Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 19:15:32 +0200
Subject: [PATCH 46/61] refactor(backend): reorder Program.cs sections into
reads-then-writes (RB-19)
CQ-006 found that Program.cs states a reads-then-writes principle at the
top of the file, then abandons it for five feature sections that mix GET
and mutating endpoints in mapping order. This is a pure reorder: within
Document upload, Applications, Admin cases, Brief, and Organization
templates, every GET now precedes every POST/PUT/DELETE, each split by a
`--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already
established for Beoordeling/Besluit.
DELETE /admin/cases/{id} and GET /admin/audit move up beside GET
/admin/cases, closing the 129-line gap CQ-006 measured. GET
/admin/org-template/{subOrgId}/preview moves from the Brief section to
the Organization-templates section it actually belongs to.
No route, signature, DTO, or handler body changed. Every block was cut
by exact line-range slicing, never retyped. The sorted list of mapped
HTTP-method-plus-path strings is byte-identical before and after; every
.Gate(...) count is unchanged; the three routes that moved with a gate
were checked by eye against the wrapper their handler actually calls,
per RB-12's stated limitation that the route-table test only proves a
marker is present, not that it still matches the handler.
npm run gen:api regenerated backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts; both diffs are ordering
only (sorted-file diff is empty), committed alongside per the ticket's
own guidance.
Co-Authored-By: Claude Opus 5
---
backend/src/BigRegister.Api/Program.cs | 181 +++++++-------
backend/swagger.json | 134 +++++------
.../refactor-backlog/99-backlog.md | 70 +++---
.../refactor-backlog/implementation/rb-19.md | 220 ++++++++++++++++++
libs/shared/src/infrastructure/api-client.ts | 176 +++++++-------
5 files changed, 511 insertions(+), 270 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index 7fbea17..f0f6ae8 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -242,36 +242,12 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
// --- Document upload ---
+// --- reads ---
+
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList()));
-// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
-// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
-// and size authoritatively; stores metadata only (no file bytes / PII held).
-api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) =>
-{
- if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
- var form = await request.ReadFormAsync();
- var file = form.Files.GetFile("file");
- string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
- if (file is null || categoryId == "" || localId == "" || wizardId == "")
- return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
-
- var category = DocumentRules.Find(wizardId, categoryId);
- var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
- if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
-
- using var ms = new MemoryStream();
- await file.CopyToAsync(ms);
- // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
- // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
- // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
- var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener());
- return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
-})
-.ExcludeFromDescription();
-
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// for pdf/image (browser renders it), attachment otherwise (download).
// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a
@@ -305,6 +281,34 @@ api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) =>
return new UploadStatusDto(results);
});
+// --- writes ---
+
+// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
+// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
+// and size authoritatively; stores metadata only (no file bytes / PII held).
+api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) =>
+{
+ if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
+ var form = await request.ReadFormAsync();
+ var file = form.Files.GetFile("file");
+ string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
+ if (file is null || categoryId == "" || localId == "" || wizardId == "")
+ return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
+
+ var category = DocumentRules.Find(wizardId, categoryId);
+ var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
+ if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
+
+ using var ms = new MemoryStream();
+ await file.CopyToAsync(ms);
+ // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
+ // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
+ // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
+ var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener());
+ return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
+})
+.ExcludeFromDescription();
+
// User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch
@@ -332,6 +336,8 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
// --- Applications (aanvragen): the system of record the dashboard reads. ---
+// --- reads ---
+
// WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling
// ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
@@ -346,6 +352,8 @@ api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
.Produces()
.Produces(StatusCodes.Status404NotFound);
+// --- writes ---
+
api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
{
// Feature flag (WP-47): self-service registration can be closed by an admin.
@@ -480,12 +488,40 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.Produces(StatusCodes.Status404NotFound);
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
+
+// --- reads ---
+
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Gate("CasesAdmin")
.Produces>()
.ProducesProblem(StatusCodes.Status403Forbidden);
+// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
+// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
+api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
+ Results.Ok(AuthzAuditStore.List()
+ .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
+ .ToList())))
+.Gate("CasesAdmin")
+.Produces>()
+.ProducesProblem(StatusCodes.Status403Forbidden);
+
+// --- writes ---
+
+// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
+// DELETE /applications/{id}. A missing id is a 404.
+api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
+{
+ if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
+ app.Logger.LogInformation("admin case delete id={Id}", id);
+ return Results.NoContent();
+}))
+.Gate("CasesAdmin")
+.Produces(StatusCodes.Status204NoContent)
+.Produces(StatusCodes.Status404NotFound)
+.ProducesProblem(StatusCodes.Status403Forbidden);
+
// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. ---
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`,
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags —
@@ -618,29 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
// /uploads and /brief/reveal-bignummer.
.ExcludeFromDescription();
-// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
-// DELETE /applications/{id}. A missing id is a 404.
-api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
-{
- if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
- app.Logger.LogInformation("admin case delete id={Id}", id);
- return Results.NoContent();
-}))
-.Gate("CasesAdmin")
-.Produces(StatusCodes.Status204NoContent)
-.Produces(StatusCodes.Status404NotFound)
-.ProducesProblem(StatusCodes.Status403Forbidden);
-
-// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
-// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
-api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
- Results.Ok(AuthzAuditStore.List()
- .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
- .ToList())))
-.Gate("CasesAdmin")
-.Produces>()
-.ProducesProblem(StatusCodes.Status403Forbidden);
-
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// tied to a specific brief's live status — see BriefDecisionsDto for that).
// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like
@@ -673,6 +686,8 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real
// identities in this POC. ---
+// --- reads ---
+
api.MapGet("/brief", (HttpContext ctx) =>
{
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
@@ -685,6 +700,27 @@ api.MapGet("/brief", (HttpContext ctx) =>
.Produces()
.Produces(StatusCodes.Status404NotFound);
+// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
+// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
+// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
+// letters serve their frozen archive; anything else renders live with a watermark.
+api.MapGet("/brief/preview", (HttpContext ctx) =>
+{
+ // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
+ // must not create a brief as a side effect either, so it 404s under the same
+ // precondition as GET /brief — in the running app the FE only reaches this endpoint
+ // from the brief page, which has already loaded (and, if needed, reset) a brief.
+ var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
+ if (e is null) return Results.NotFound();
+ if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
+ return Results.Content(archived, "text/html");
+ var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
+ return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
+})
+.ExcludeFromDescription();
+
+// --- writes ---
+
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
@@ -765,37 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
// OpenAPI doc, same seam as /brief/preview and uploads.
.ExcludeFromDescription();
-// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
-// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
-// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
-// letters serve their frozen archive; anything else renders live with a watermark.
-api.MapGet("/brief/preview", (HttpContext ctx) =>
-{
- // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
- // must not create a brief as a side effect either, so it 404s under the same
- // precondition as GET /brief — in the running app the FE only reaches this endpoint
- // from the brief page, which has already loaded (and, if needed, reset) a brief.
- var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
- if (e is null) return Results.NotFound();
- if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
- return Results.Content(archived, "text/html");
- var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
- return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
-})
-.ExcludeFromDescription();
-
-// Proefbrief: the admin's unpublished draft template rendered over a fixture
-// brief, so the appearance can be checked before publishing touches real letters.
-api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
-{
- var view = OrgTemplateStore.AdminView(subOrgId);
- if (view is null) return Results.NotFound();
- var fixture = BriefSeed.NewBrief("proefbrief");
- return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
-}))
-.Gate("OrgAdmin")
-.ExcludeFromDescription();
-
api.MapPost("/brief/reset", (HttpContext ctx) =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
@@ -810,6 +815,8 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
// as drafter/approver); the same Authz check gates every endpoint and feeds the
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
+// --- reads ---
+
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
Results.Ok(OrgTemplateStore.List())))
.Gate("OrgAdmin")
@@ -825,6 +832,20 @@ api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
+// Proefbrief: the admin's unpublished draft template rendered over a fixture
+// brief, so the appearance can be checked before publishing touches real letters.
+api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
+{
+ var view = OrgTemplateStore.AdminView(subOrgId);
+ if (view is null) return Results.NotFound();
+ var fixture = BriefSeed.NewBrief("proefbrief");
+ return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
+}))
+.Gate("OrgAdmin")
+.ExcludeFromDescription();
+
+// --- writes ---
+
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var reject = OrgTemplateRules.RejectDraft(req.Draft);
diff --git a/backend/swagger.json b/backend/swagger.json
index 9985fce..e0ccac2 100644
--- a/backend/swagger.json
+++ b/backend/swagger.json
@@ -686,6 +686,73 @@
}
}
},
+ "/api/v1/admin/audit": {
+ "get": {
+ "tags": [
+ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/AuthzAuditDto"
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/cases/{id}": {
+ "delete": {
+ "tags": [
+ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/werkvoorraad": {
"get": {
"tags": [
@@ -832,73 +899,6 @@
}
}
},
- "/api/v1/admin/cases/{id}": {
- "delete": {
- "tags": [
- "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
- ],
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "responses": {
- "204": {
- "description": "No Content"
- },
- "404": {
- "description": "Not Found"
- },
- "403": {
- "description": "Forbidden",
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ProblemDetails"
- }
- }
- }
- }
- }
- }
- },
- "/api/v1/admin/audit": {
- "get": {
- "tags": [
- "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
- ],
- "responses": {
- "200": {
- "description": "OK",
- "content": {
- "application/json": {
- "schema": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/AuthzAuditDto"
- }
- }
- }
- }
- },
- "403": {
- "description": "Forbidden",
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ProblemDetails"
- }
- }
- }
- }
- }
- }
- },
"/api/v1/me": {
"get": {
"tags": [
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index 1825aa4..b41a430 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **implemented** |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md
new file mode 100644
index 0000000..65afb53
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md
@@ -0,0 +1,220 @@
+# RB-19 — reorder `Program.cs`: reads before writes per section, regroup admin-cases + org-template preview
+
+Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-006 ·
+`99-backlog.md` RB-19 · Depends on `implementation/rb-12.md` (the route-table test this
+ticket leans on as its regression net)
+
+This is a **pure reorder**. No route, signature, DTO, or handler-body text changed. The
+sorted list of `HTTP METHOD + path` mapping calls is byte-identical before and after (see
+"Verification" below) — that identity is the strongest evidence this ticket did what it
+says and nothing else.
+
+## What was wrong
+
+CQ-006, verbatim: `Program.cs` opens by declaring direction as its organising principle
+(a "GET: screen-shaped reads" banner, then a "POST: submits" banner), then from the
+Document-upload section onward switches to feature grouping without saying so, and every
+subsequent section interleaves reads and writes. One feature (Beoordeling/Besluit, WP-65)
+already got the fix — a `:441`/`:464`-style banner pair splitting its query endpoint from
+its command endpoint — and CQ-006 asks for the same treatment on the five sections that
+predate that pattern: Document upload, Applications, Admin cases, Brief, and Organization
+templates. Separately, `DELETE /admin/cases/{id}` sat 129 lines away from `GET
+/admin/cases`, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in
+between; and `GET /admin/org-template/{subOrgId}/preview` was filed under the Brief
+section's banner instead of the Org-templates section it actually belongs to.
+
+## What changed
+
+| Section (banner) | Before | After |
+| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Document upload | categories, **POST /uploads**, content, status, DELETE, admin-DELETE | categories, content, status, `--- reads ---`/`--- writes ---` sub-banners, **POST /uploads** moved after the reads, DELETE, admin-DELETE |
+| Applications | already reads-first (2 GETs, then POST/PUT/DELETE/POST-submit) | unchanged order; sub-banners inserted only |
+| Admin cases | GET /admin/cases, _(werkvoorraad/beoordeling/besluit/zgw-notificaties in between)_, **DELETE /admin/cases/{id}**, **GET /admin/audit** | GET /admin/cases, **GET /admin/audit** (moved up), `--- writes ---`, **DELETE /admin/cases/{id}** (moved up) — all three now contiguous; werkvoorraad/beoordeling/besluit/zgw-notificaties follow, unmoved and unchanged |
+| Brief | GET /brief, PUT, submit, approve, reject, send, reveal-bignummer, **GET /brief/preview**, _(org-template preview)_, POST /reset | GET /brief, **GET /brief/preview** (moved up beside GET /brief), `--- writes ---`, PUT, submit, approve, reject, send, reveal-bignummer, POST /reset — org-template preview removed from this section |
+| Organization templates | list, detail, PUT, publish, rollback | list, detail, **GET /admin/org-template/{subOrgId}/preview** (moved in from Brief), `--- writes ---`, PUT, publish, rollback |
+
+Every section above got a `// --- reads ---` / `// --- writes ---` sub-banner pair
+(matching the short, bare form already used at the file's top-level `:170`/`:236`
+banners) inserted at the reads→writes boundary. Werkvoorraad, Beoordeling and Besluit —
+not named by CQ-006 as mixed, and already correctly split (Beoordeling is the read,
+Besluit is the write, each with its own WP-65 banner) — were left exactly as they were,
+including their absolute position relative to each other; only the block ahead of them
+(admin-cases) grew, pushing their line numbers down without touching their content.
+
+`backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` were
+regenerated (`npm run gen:api`) and are part of this commit — see "The regenerated pair"
+below.
+
+## Design: line-range slicing, not manual retyping
+
+Every moved block was cut with a Python script operating on exact 1-indexed line ranges
+against the file as it stood after merging in `refactor/adr-c-006-shared-route-guards`
+(this branch's actual base — see "Base commit" below), then reassembled in the new order.
+No handler body was retyped by hand. This is the same guarantee the ticket's "cut/paste,
+not retype" instruction asks for, made structural rather than a promise to be careful:
+a line-range slice cannot silently change a character inside a block it does not touch.
+The script is not part of this commit (a one-shot tool, not project code); the diff it
+produced is what is being reviewed.
+
+## Judgement calls
+
+- **Sub-banner wording is bare `// --- reads ---` / `// --- writes ---`, not prose
+ matching WP-65's descriptive style.** The ticket asks for ":441/:464-style" banners;
+ WP-65's actual banners are long, feature-specific paragraphs ("read side only
+ (recording a decision is WP-65's second half)…"). Inventing five more paragraphs like
+ that would mean writing new explanatory prose about code this ticket is not meant to
+ re-explain — CQ-006 is explicit that this is "a structure finding, not a correctness
+ one," and the ticket itself forbids "no fixed comments beyond the banners this ticket
+ adds." The file's own top-level banners (`:170` "GET: screen-shaped reads", `:236`
+ "POST: submits") already establish a bare, label-only banner as a legitimate style in
+ this exact file — the sub-banners here are that same style, nested one level deeper.
+- **Werkvoorraad/Beoordeling/Besluit end up sandwiched between Admin-cases and
+ zgw/notificaties, in that order, unmoved.** Moving `GET /admin/audit` and `DELETE
+/admin/cases/{id}` up next to `GET /admin/cases` (as instructed) necessarily pushes
+ everything that used to sit between them — werkvoorraad, beoordeling, besluit,
+ zgw/notificaties — down, but does not reorder those four relative to each other. They
+ were not named as mixed by CQ-006 and were not touched beyond their line numbers
+ changing.
+- **`GET /brief/preview` and `POST /uploads` are both `.ExcludeFromDescription()`-marked
+ (hand-written FE `fetch`/XHR calls, never through the generated client) — moving them
+ produced zero diff in `swagger.json`.** This is not a coincidence being reported as
+ one: an excluded endpoint has no OpenAPI operation to reorder in the first place, so
+ the regenerated pair's diff below is smaller than "every moved route" might suggest —
+ it only shows the two endpoints that are both documented and reordered relative to
+ each other (`GET /admin/audit`, `DELETE /admin/cases/{id}`).
+- **No handler types, no `Features/` folder, no mediator** — out of mandate per CQ-006's
+ own text (filed separately as OOM-A) and the ticket's explicit "out of scope" section.
+ Nothing beyond comments and mapping order changed.
+
+## Base commit
+
+Step zero's warning matched this worktree's actual starting state: `git log --oneline -8`
+showed `ae7781e` at HEAD, not `edd20c0`, and `edd20c0 docs(backlog): mark RB-23 done after
+merge` was absent from the log entirely — the bad-base lineage named in the ticket. `git
+merge refactor/adr-c-006-shared-route-guards` was run, after which `edd20c0` appeared as
+`HEAD~0`'s direct ancestor and every RB-01..RB-23 commit was present. All work in this
+ticket happened after that merge.
+
+## Verification
+
+**The sorted-route-list diff (the key evidence).** Extracted every `.Map(Get|Post|Put|
+Delete)("...")` call from `Program.cs` before and after, sorted each list, and diffed
+them:
+
+```
+$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs.before-reorder | sort > before.txt
+$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs | sort > after.txt
+$ diff before.txt after.txt
+$ echo "exit=$?"
+exit=0
+$ wc -l before.txt after.txt
+ 47 before.txt
+ 47 after.txt
+```
+
+Empty diff, same count (47 `api.Map*` calls — the two `app.MapGet` health probes are
+outside the `/api/v1` group and were never in scope for this reorder; they were untouched
+either way). The set of routes is provably unchanged.
+
+**`.Gate(...)` count, before/after, by wrapper name:**
+
+```
+ 3 .Gate("Beoordelen")
+ 4 .Gate("CasesAdmin")
+ 1 .Gate("FlagsAdmin")
+ 6 .Gate("OrgAdmin")
+ 2 .Gate("StamdataAdmin")
+```
+
+Identical in both directions — no gate call was added, removed, or renamed.
+
+**Per-route eyeball check of every route that changed position, per RB-12's stated
+limitation** (the route-table test only proves a `.Gate(...)` marker is present, not that
+it still names the wrapper the handler body actually calls):
+
+| Route | Moved | `.Gate(...)` after | Wrapper actually called inside the handler | Match |
+| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------- | ------------------------------------------ | ----- |
+| `GET /admin/audit` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => ...)` | yes |
+| `DELETE /admin/cases/{id}` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => { ... })` | yes |
+| `GET /admin/org-template/{subOrgId}/preview` | Brief section → Org-templates section | `OrgAdmin` | `OrgAdmin(ctx, () => { ... })` | yes |
+| `POST /uploads` | within Document-upload, past the three reads | _(none — allow-listed, ownership-scoped)_ | — | n/a |
+| `GET /brief/preview` | within Brief, up beside `GET /brief` | _(none — allow-listed, ownership-scoped)_ | — | n/a |
+| `GET /uploads/{documentId}/content` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a |
+| `GET /uploads/status` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a |
+
+Five routes were deliberately relocated by this ticket; two more shifted position only as
+a byproduct of `POST /uploads` moving past them (their own order relative to each other
+is unchanged). All three gated routes among these were checked by eye against the
+handler body they wrap, not just against `RouteInventoryTests`' marker check — all three
+match.
+
+**`RouteInventoryTests`:**
+
+```
+Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 770 ms
+```
+
+Both `Every_mapped_route_is_authz_gated_or_on_the_named_allow_list` and
+`Every_gate_marker_names_a_known_admin_wrapper` pass.
+
+**Full backend suite:** `dotnet test --filter "Category!=Integration"` — **262/262
+passing**, plus the one known, pre-existing, container-dependent failure
+(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`),
+which does not run under `npm run ci` and reproduces on a clean tree with no OpenZaak
+container running — not this ticket's bug.
+
+**`dotnet build`** (both projects): 0 warnings, 0 errors. **`dotnet format
+BigRegister.slnx --verify-no-changes`**: clean.
+
+**No new test was added.** Per the ticket's Definition of Done: this is a zero-semantic-
+change commit, and §3c's pre-existing 97.4% line / 84.8% branch coverage of `Program.cs`
+is the regression net CQ-006 itself names. Nothing about this diff needs a new test to be
+trustworthy — a passing pre-existing suite plus an empty sorted-route diff is stronger
+evidence for "nothing changed" than a new test asserting the same thing would be.
+
+## The regenerated pair
+
+`npm run gen:api` was run after the reorder. It produced a diff in both
+`backend/swagger.json` (2 hunks) and `libs/shared/src/infrastructure/api-client.ts` (5
+hunks) — both **pure reordering, zero content change**. Confirmed by sorting every line of
+each file (before vs. after) and diffing the sorted output: empty in both cases. The only
+two OpenAPI paths that moved position in the document are `/api/v1/admin/audit` and
+`/api/v1/admin/cases/{id}` — the two documented (non-`ExcludeFromDescription`) endpoints
+this ticket actually reordered relative to their OpenAPI-document neighbours; the
+generated client's `audit()`/`cases()` methods and their `process*` helpers moved by the
+same amount, unchanged in every other respect (parameters, return types, status-code
+branches, JSDoc). Both regenerated files are committed alongside `Program.cs`, per the
+ticket's explicit instruction: "if the only change is ordering inside swagger.json, say
+so explicitly and commit the regenerated pair rather than leaving CI's drift job to
+fail."
+
+**`npm run ci`**: every job through "backend dependency audit" passed before this
+ticket's files were committed; the one job that legitimately failed pre-commit was "api-
+client drift" (`git diff --exit-code` against the not-yet-committed regenerated files —
+expected, since that step compares the working tree to `HEAD`, and `HEAD` still had the
+pre-reorder client at that point). After committing, `npm run ci` was re-run to confirm a
+clean, fully green result against the committed tree — see the final PASS/exit-code
+reported in this ticket's closing message.
+
+## What a reviewer should check
+
+This diff is too large to read top-to-bottom without guidance. The fastest way to review
+it with confidence:
+
+1. **Trust the sorted-route diff, not a manual read of every hunk.** The "Verification"
+ section above shows the set of `HTTP METHOD + path` strings is byte-identical before
+ and after. If you want to reproduce it yourself: check out this commit's parent,
+ extract the same `grep -oE` pattern from both revisions of `Program.cs`, sort, diff.
+2. **Spot-check the five per-route table entries above**, not the whole file — those are
+ the only routes whose position (and, for three of them, gate-vs-handler match)
+ actually matters for this ticket's correctness claim.
+3. **Diff `git show -- backend/src/BigRegister.Api/Program.cs` with
+ whitespace-insensitive word diff** (`git diff -w --color-words`) if you want to
+ confirm no character inside a moved handler body changed — the line-range-slicing
+ approach in "Design" above makes this a formality rather than a real risk, but it is
+ cheap to re-check.
+4. **Do not expect Werkvoorraad/Beoordeling/Besluit/zgw-notificaties to have moved
+ position relative to each other** — only their absolute line numbers shifted, as a
+ side effect of the admin-cases block growing above them.
+5. **The regenerated `swagger.json`/`api-client.ts` diff is expected and pre-verified as
+ ordering-only** (sorted-file diff is empty) — it does not need a second manual read.
diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts
index 8a30596..835788c 100644
--- a/libs/shared/src/infrastructure/api-client.ts
+++ b/libs/shared/src/infrastructure/api-client.ts
@@ -956,6 +956,94 @@ export class ApiClient {
return Promise.resolve(null as any);
}
+ /**
+ * @return OK
+ */
+ audit(): Promise {
+ let url_ = this.baseUrl + "/api/v1/admin/audit";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processAudit(_response);
+ });
+ }
+
+ protected processAudit(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[];
+ return result200;
+ });
+ } else if (status === 403) {
+ return response.text().then((_responseText) => {
+ let result403: any = null;
+ result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
+ return throwException("Forbidden", status, _responseText, _headers, result403);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return No Content
+ */
+ cases(id: string): Promise {
+ let url_ = this.baseUrl + "/api/v1/admin/cases/{id}";
+ if (id === undefined || id === null)
+ throw new globalThis.Error("The parameter 'id' must be defined.");
+ url_ = url_.replace("{id}", encodeURIComponent("" + id));
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "DELETE",
+ headers: {
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processCases(_response);
+ });
+ }
+
+ protected processCases(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 204) {
+ return response.text().then((_responseText) => {
+ return;
+ });
+ } else if (status === 403) {
+ return response.text().then((_responseText) => {
+ let result403: any = null;
+ result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
+ return throwException("Forbidden", status, _responseText, _headers, result403);
+ });
+ } else if (status === 404) {
+ return response.text().then((_responseText) => {
+ return throwException("Not Found", status, _responseText, _headers);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
/**
* @return OK
*/
@@ -1112,94 +1200,6 @@ export class ApiClient {
return Promise.resolve(null as any);
}
- /**
- * @return No Content
- */
- cases(id: string): Promise {
- let url_ = this.baseUrl + "/api/v1/admin/cases/{id}";
- if (id === undefined || id === null)
- throw new globalThis.Error("The parameter 'id' must be defined.");
- url_ = url_.replace("{id}", encodeURIComponent("" + id));
- url_ = url_.replace(/[?&]$/, "");
-
- let options_: RequestInit = {
- method: "DELETE",
- headers: {
- }
- };
-
- return this.http.fetch(url_, options_).then((_response: Response) => {
- return this.processCases(_response);
- });
- }
-
- protected processCases(response: Response): Promise {
- const status = response.status;
- let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
- if (status === 204) {
- return response.text().then((_responseText) => {
- return;
- });
- } else if (status === 403) {
- return response.text().then((_responseText) => {
- let result403: any = null;
- result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
- return throwException("Forbidden", status, _responseText, _headers, result403);
- });
- } else if (status === 404) {
- return response.text().then((_responseText) => {
- return throwException("Not Found", status, _responseText, _headers);
- });
- } else if (status !== 200 && status !== 204) {
- return response.text().then((_responseText) => {
- return throwException("An unexpected server error occurred.", status, _responseText, _headers);
- });
- }
- return Promise.resolve(null as any);
- }
-
- /**
- * @return OK
- */
- audit(): Promise {
- let url_ = this.baseUrl + "/api/v1/admin/audit";
- url_ = url_.replace(/[?&]$/, "");
-
- let options_: RequestInit = {
- method: "GET",
- headers: {
- "Accept": "application/json"
- }
- };
-
- return this.http.fetch(url_, options_).then((_response: Response) => {
- return this.processAudit(_response);
- });
- }
-
- protected processAudit(response: Response): Promise {
- const status = response.status;
- let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
- if (status === 200) {
- return response.text().then((_responseText) => {
- let result200: any = null;
- result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[];
- return result200;
- });
- } else if (status === 403) {
- return response.text().then((_responseText) => {
- let result403: any = null;
- result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
- return throwException("Forbidden", status, _responseText, _headers, result403);
- });
- } else if (status !== 200 && status !== 204) {
- return response.text().then((_responseText) => {
- return throwException("An unexpected server error occurred.", status, _responseText, _headers);
- });
- }
- return Promise.resolve(null as any);
- }
-
/**
* @return OK
*/
From 424ceb604b514233ce72682c70edfa898ab7b355 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 19:21:01 +0200
Subject: [PATCH 47/61] docs(backlog): CD batch 4 complete
All six tickets RB-18 to RB-23 merged, one commit per ticket. Records the two
incomplete tickets that the agents reported, RB-22's deliberate departure from
the runResult idiom, and how RB-19 was verified as a pure reorder.
Adds five dispatch lessons. The stale worktree base is now the rule at 11 of 13
agent-runs. A spend limit killed four agents mid-flight and a message resumed
each one from its own transcript, so no work was redone.
Co-Authored-By: Claude Opus 5
---
.../refactor-backlog/99-backlog.md | 70 +++++++++----------
.../refactor-backlog/_status.md | 43 +++++++++---
2 files changed, 69 insertions(+), 44 deletions(-)
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index b41a430..fe54364 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
-| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
-| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- |
-| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
-| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
-| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
-| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
-| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
-| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
-| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
-| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **implemented** |
-| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
-| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
-| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
-| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
-| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
-| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
-| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
-| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
+| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
+| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
+| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
+| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
+| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
+| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
+| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
+| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
+| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
+| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** |
+| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
+| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
+| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
+| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
+| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
+| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
+| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
---
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
index e7cb9f0..679cc2e 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md
@@ -14,15 +14,15 @@
## Phase 3 — implementation
-| CD batch | Tickets | Status | Notes |
-| -------- | ------------------------------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
-| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
-| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
-| 4 | RB-18..RB-23 | in progress | Split into three waves to keep the merge order honest, because three of the six tickets touch `Program.cs`. **Wave A (dispatched, parallel):** RB-18, RB-20, RB-21, RB-22 — no file overlap between them. **Wave B:** RB-23, which must merge after RB-22 (expand/contract pair: the FE must tolerate the 404 before the BE returns it). **Wave C:** RB-19 alone and last — it is the only **High**-risk ticket, it reorders all 48 endpoints in `Program.cs`, and landing it last means it reorders the final content instead of conflicting with RB-18's and RB-23's edits to the same file. RB-19 also needs RB-12's route-table test as its safety net; read `rb-12.md` first, including its stated limitation that detection is a declaration, not a derivation. |
-| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. |
-| 6 | RB-31, RB-32, RB-33 | not started | |
-| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. |
+| CD batch | Tickets | Status | Notes |
+| -------- | ------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
+| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
+| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
+| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. |
+| 5 | RB-24..RB-30 | not started | RB-25/26/27 all depend on RB-24. |
+| 6 | RB-31, RB-32, RB-33 | not started | |
+| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. |
**Standing caveat for every batch:** `dotnet test` reports one failure,
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
@@ -76,6 +76,31 @@ least one of these. Put all of it in the prompt.
6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by
ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`.
+7. **The stale base is now the rule, not the exception.** Batch 4 ran six agents; **five were
+ handed a bad base**, three of them the same unrelated `ae7781e` lineage. Across batches 2-4 that
+ is **11 of 13 agent-runs**. Every one self-corrected at step zero. Keep the named-expected-commit
+ check as the first instruction in every prompt — it is the highest-value line in there. Give the
+ _current_ HEAD, not the batch's starting commit, when tickets merge sequentially.
+8. **A spend limit can kill every agent mid-flight; resume, do not restart.** All four wave-A agents
+ died on an org monthly-spend 429, three of them at the final CI re-run. Their worktrees kept the
+ uncommitted work intact. Sending each agent a message resumed it from its own transcript and it
+ finished from exactly where it stopped — nothing was redone. Check `git -C status`
+ before assuming work is lost.
+9. **The `99-backlog.md` conflict is mechanical and has a fixed recipe.** It fired on three of five
+ merges. Cause: the central prettier pass reflows the table's column widths, so the whole table
+ conflicts even though the two sides differ in only one or two status cells. Recipe: take HEAD's
+ table, flip the incoming ticket's cell, re-run prettier. Verify by parsing both sides cell-by-cell
+ and printing only the differing cells before discarding either side — do not eyeball a 33-row
+ table. A ticket's cell reads `**done**` once merged (matching RB-01..RB-17), not `implemented`.
+10. **For a zero-semantic-change commit, ask for evidence CI cannot give.** RB-19's diff is 181 lines
+ in `Program.cs` and no test can prove it changed nothing. The sorted-route-list diff, plus the
+ route/gate/handler-triple comparison, is what actually made it reviewable. Ask for it in the
+ prompt and re-run it centrally before merging.
+11. **`gen:api` is not always a no-op on a pure reorder.** OpenAPI operation order follows mapping
+ order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to
+ prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to
+ commit the regenerated pair, or CI's drift job fails on a correct change.
+
**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong
about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and
RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review
From 9520d6c24e1194d2dd72133655544fe9edd724af Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 20:40:41 +0200
Subject: [PATCH 48/61] refactor(shared): move upload/ into
infrastructure/domain/application (RB-24)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.
Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.
Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.
npm run ci passes; dep:check is clean for both apps with the carve-out gone.
Co-Authored-By: Claude Opus 5
---
.dependency-cruiser.base.js | 4 +-
.../src/app/brief/application/brief.store.ts | 2 +-
.../brief/application/org-template.store.ts | 9 +-
.../brief/domain/org-template.machine.spec.ts | 2 +-
.../app/brief/domain/org-template.machine.ts | 2 +-
.../org-template-editor.component.ts | 2 +-
.../org-template-editor.stories.ts | 2 +-
.../ssp/src/app/brief/ui/org-template.page.ts | 4 +-
.../domain/herregistratie.machine.ts | 2 +-
.../herregistratie-wizard.component.ts | 13 +-
.../herregistratie-wizard.stories.ts | 2 +-
.../domain/registratie-wizard.machine.spec.ts | 2 +-
.../domain/registratie-wizard.machine.ts | 2 +-
.../registratie-wizard.component.ts | 13 +-
.../registratie-wizard.stories.ts | 2 +-
.../refactor-backlog/99-backlog.md | 2 +-
.../refactor-backlog/implementation/rb-24.md | 178 ++++++++++++++++++
docs/reference/architecture/dependencies.md | 2 +-
.../upload-controller.ts | 19 +-
.../upload-shell.service.ts | 9 +-
.../{upload => domain}/upload.machine.spec.ts | 0
.../src/{upload => domain}/upload.machine.ts | 0
.../upload.adapter.ts | 2 +-
.../delivery-channel-toggle.component.ts | 2 +-
.../document-category.component.ts | 2 +-
.../document-category.stories.ts | 2 +-
.../document-chip/document-chip.component.ts | 2 +-
.../document-chip/document-chip.stories.ts | 2 +-
.../document-upload.component.ts | 2 +-
.../document-upload.stories.ts | 2 +-
.../single-upload/single-upload.component.ts | 2 +-
.../single-upload/single-upload.stories.ts | 2 +-
.../upload-status-icon.component.ts | 2 +-
33 files changed, 246 insertions(+), 49 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md
rename libs/shared/src/{upload => application}/upload-controller.ts (88%)
rename libs/shared/src/{upload => application}/upload-shell.service.ts (94%)
rename libs/shared/src/{upload => domain}/upload.machine.spec.ts (100%)
rename libs/shared/src/{upload => domain}/upload.machine.ts (100%)
rename libs/shared/src/{upload => infrastructure}/upload.adapter.ts (99%)
diff --git a/.dependency-cruiser.base.js b/.dependency-cruiser.base.js
index ff945cd..b6f1e0b 100644
--- a/.dependency-cruiser.base.js
+++ b/.dependency-cruiser.base.js
@@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
{
name: 'apiclient-infrastructure-only',
comment:
- 'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
+ 'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
severity: 'error',
- from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
+ from: { pathNot: '/infrastructure/' },
to: {
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts
index 6bb8073..623535a 100644
--- a/apps/ssp/src/app/brief/application/brief.store.ts
+++ b/apps/ssp/src/app/brief/application/brief.store.ts
@@ -19,7 +19,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
-import { uploadContentUrl } from '@shared/upload/upload.adapter';
+import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
/**
diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts
index 6135944..c78213f 100644
--- a/apps/ssp/src/app/brief/application/org-template.store.ts
+++ b/apps/ssp/src/app/brief/application/org-template.store.ts
@@ -3,9 +3,9 @@ import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
-import { UploadAdapter } from '@shared/upload/upload.adapter';
-import { UploadShellService } from '@shared/upload/upload-shell.service';
-import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
+import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
+import { UploadShellService } from '@shared/application/upload-shell.service';
+import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
@@ -72,6 +72,9 @@ export class OrgTemplateStore implements PendingSave {
return id ? this.uploadAdapter.contentUrl(id) : null;
});
+ /** Preview/download link for any completed upload in the editor's document list. */
+ readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId);
+
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
the server re-validates and stays the authority — publish is gated on this. */
readonly draftValid = computed(() => {
diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts
index fb223fa..1b8f24b 100644
--- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts
+++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
-import { DocumentCategory } from '@shared/upload/upload.machine';
+import { DocumentCategory } from '@shared/domain/upload.machine';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts
index de90a36..7932941 100644
--- a/apps/ssp/src/app/brief/domain/org-template.machine.ts
+++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts
@@ -1,6 +1,6 @@
import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
-import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
+import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts
index b9b8c98..2690e0f 100644
--- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts
+++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts
@@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
-import { UploadState } from '@shared/upload/upload.machine';
+import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief';
import {
MARGIN_MAX_MM,
diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts
index 2b4d987..11fca36 100644
--- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts
+++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { OrgTemplateEditorComponent } from './org-template-editor.component';
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
-import { UploadState, initialUpload } from '@shared/upload/upload.machine';
+import { UploadState, initialUpload } from '@shared/domain/upload.machine';
const draft: OrgTemplate = {
subOrgId: 'cibg-registers',
diff --git a/apps/ssp/src/app/brief/ui/org-template.page.ts b/apps/ssp/src/app/brief/ui/org-template.page.ts
index f28b7b4..697f19d 100644
--- a/apps/ssp/src/app/brief/ui/org-template.page.ts
+++ b/apps/ssp/src/app/brief/ui/org-template.page.ts
@@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
-import { UploadAdapter } from '@shared/upload/upload.adapter';
import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
@@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
export class OrgTemplatePage {
protected store = inject(OrgTemplateStore);
protected access = inject(AccessStore);
- private uploadAdapter = inject(UploadAdapter);
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
- protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
+ protected previewUrlFor = this.store.previewUrlFor;
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts
index 2c457a7..ffed921 100644
--- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts
+++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts
@@ -7,7 +7,7 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
-} from '@shared/upload/upload.machine';
+} from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts
index f2cc4ad..986dae0 100644
--- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts
+++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts
@@ -23,9 +23,8 @@ import {
} from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
-import { createUploadController } from '@shared/upload/upload-controller';
-import { UploadAdapter } from '@shared/upload/upload.adapter';
-import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
+import { createUploadController } from '@shared/application/upload-controller';
+import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -149,13 +148,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
- private uploadAdapter = inject(UploadAdapter);
private store = createStore(initial, reduce);
- /** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
- no stored bytes, so they get no link. */
+ /** Preview/download link for a completed upload; delegates to the upload
+ controller (application layer), which knows the dev-simulation `demo-*` ids
+ have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
- documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
+ this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input(initial);
diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts
index f876c5e..4a632a3 100644
--- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts
+++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
-import { initialUpload } from '@shared/upload/upload.machine';
+import { initialUpload } from '@shared/domain/upload.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts
index 695b314..30aac1a 100644
--- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts
+++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
-import { initialUpload } from '@shared/upload/upload.machine';
+import { initialUpload } from '@shared/domain/upload.machine';
import { expectTag } from '@shared/testing/expect-tag';
import {
Draft,
diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts
index 73b96d4..069178a 100644
--- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts
+++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts
@@ -9,7 +9,7 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
-} from '@shared/upload/upload.machine';
+} from '@shared/domain/upload.machine';
/**
* A FIXED 3-step registration wizard. The steps never change in number (always
diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
index aa2b60a..25e3b19 100644
--- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
+++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
@@ -37,9 +37,8 @@ import {
} from '@registratie/domain/registratie-wizard.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
-import { createUploadController } from '@shared/upload/upload-controller';
-import { UploadAdapter } from '@shared/upload/upload.adapter';
-import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
+import { createUploadController } from '@shared/application/upload-controller';
+import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
@@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
})
export class RegistratieWizardComponent {
private lookup = inject(RegistratieLookupStore);
- private uploadAdapter = inject(UploadAdapter);
private store = createStore(initial, reduce);
- /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids
- have no stored bytes, so they get no link. */
+ /** Preview/download link for a completed upload; delegates to the upload
+ controller (application layer), which knows the dev-simulation `demo-*` ids
+ have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
- documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
+ this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input(initial);
diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts
index 5fd9ef4..29f31db 100644
--- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts
+++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts
@@ -8,7 +8,7 @@ import {
RegistratieState,
ValidRegistratie,
} from '@registratie/domain/registratie-wizard.machine';
-import { initialUpload } from '@shared/upload/upload.machine';
+import { initialUpload } from '@shared/domain/upload.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial = {
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fe54364..d55b046 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -125,7 +125,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
-| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md
new file mode 100644
index 0000000..599034b
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md
@@ -0,0 +1,178 @@
+# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted
+
+Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 ·
+`99-backlog.md` RB-24, "Merges" table row for RB-25/26/27
+
+## What was wrong
+
+`libs/shared/src/upload/` held five files outside the folder-per-layer convention every
+other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw
+`XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`.
+`upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/`
+folder. The exception was hard-coded into the enforcement itself:
+`.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read
+`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the
+violation instead of the violation being fixed, which is why the baseline scan reported 0
+violations despite this.
+
+## What changed
+
+| From `libs/shared/src/upload/` | To |
+| -------------------------------- | ----------------------------------------------------- |
+| `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` |
+| `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) |
+| `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` |
+| `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` |
+
+All five moves used `git mv`. `libs/shared/src/upload/` no longer exists.
+
+**Import updates.** 24 consumer files import from `@shared/upload/*` (found with
+`grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated
+`@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths
+were rewritten to the new locations (30 import statements total, some files import more
+than one symbol). No export was renamed, no file was split, no logic changed in any of
+these 24 files beyond the import path string.
+
+**Within the five moved files**, three had relative imports (`./upload.adapter`,
+`./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases:
+`upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine` →
+`@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and
+`upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`;
+`upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it
+and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine`
+import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed
+pure rename.
+
+**The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule:
+`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` → `from: { pathNot: '/infrastructure/' }`,
+comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further
+consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules"
+paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only
+inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since
+leaving it would document a rule that no longer exists.
+
+## A second, real violation the move exposed — fixed, not just reported
+
+Deleting the carve-out did not by itself make `dep:check` pass. A **separate,
+pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import
+`infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because
+before this move the file's path did not contain `/infrastructure/` at all. Three UI
+components were injecting `UploadAdapter` directly:
+`apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`,
+`apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`,
+and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically
+moved into `infrastructure/`, `dep:check` correctly flagged all three:
+
+```
+error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts
+error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts
+error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts
+```
+
+This is judged in-scope to fix, not a second unrelated finding to merely report, for three
+reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the
+deletion, the move is incomplete, so fix the move rather than restoring the clause."
+Second, all three call sites used `UploadAdapter` for exactly one thing —
+`.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported,
+injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no
+injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into
+its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts`
+already used that pure function directly; the three UI files had independently reinvented
+`inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays
+inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27
+target:
+
+- `libs/shared/src/application/upload-controller.ts` — the object `createUploadController`
+ returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure
+ `uploadContentUrl`. Both wizard components already hold a `createUploadController`
+ instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now
+ delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself.
+- `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects
+ `UploadAdapter` legitimately — it's application layer) gained one more computed-style
+ field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now
+ reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`.
+
+No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the
+identical string (the method is a one-line pass-through to the function), and the
+`demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method
+verbatim.
+
+## Verification
+
+- **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as
+ a 0-insertion/0-deletion pure rename — no content changed, including its own imports
+ (both files moved into `domain/` together, so its `./upload.machine` relative import
+ needed no edit). No spec content changed anywhere in this ticket.
+- `npm run dep:check`: **passes for both apps** with the carve-out clause removed —
+ `✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp),
+ `✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal).
+- `npm run lint`: clean.
+- `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing**
+ (ssp / behandelportal / shared / beheer).
+- `npm run build`: both apps build (pre-existing, unrelated warnings about
+ `/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time —
+ present before this ticket, vendored assets resolved at serve/deploy time, not a
+ regression from this move).
+- **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`):
+ the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100%
+ functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002
+ predicted landing in a folder the baseline reported at "0% spec reach across 3 files"
+ (`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which
+ remain unspecced — that gap is pre-existing and out of this ticket's scope).
+
+## Non-TypeScript references to the old path — findings
+
+Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config
+file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js`
+files, and `libs/shared/docs/*.mdx`.
+
+- **Storybook config, angular.json, dependency-cruiser app configs**: no reference to
+ `shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change.
+- **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself,
+ deleted (see above).
+- **`docs/reference/architecture/dependencies.md`**: one prose reference to the same
+ carve-out, corrected in this diff (see above) since it directly describes the rule this
+ ticket edits.
+- **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or
+ `@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and
+ `shared/ui/upload/...` by filename/short-path only, never the full old directory path —
+ both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling
+ folder).
+- **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**:
+ each carries a handful of `src/app/shared/upload/upload.machine.ts`
+ /`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor,
+ informational only (they tell a translator where a string originated; they are not
+ read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is
+ `npm run extract-i18n`'s job for the source-locale file and does not touch the
+ hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the
+ move plus import updates, not a translation-tooling refresh. They will self-correct
+ the next time `extract-i18n` runs for an unrelated reason.
+- **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**:
+ several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`,
+ `06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`)
+ reference the old path — expected, since most of them describe or cite the violation
+ this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell
+ (see below).
+
+## What RB-25/26/27 now find where
+
+- **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`):
+ `KeepaliveTransport` and `UploadShellService` are both now in
+ `libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The
+ token belongs in `application/` alongside them — nothing about the token's shape or
+ location changes because of this move.
+- **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now
+ `libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected`
+ callback — the accept/reject decision RB-26 targets — is in
+ `libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise
+ unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket —
+ see above). RB-26 should extend `upload.machine.ts` in its new location; no import path
+ in that file needs touching beyond what this ticket already did.
+- **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure
+ is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same
+ file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and
+ `genericError` are all still exactly where they were, just under `infrastructure/`.
+
+## `npm run ci`
+
+Result and step count reported in the final answer.
diff --git a/docs/reference/architecture/dependencies.md b/docs/reference/architecture/dependencies.md
index 8b97476..cb878e2 100644
--- a/docs/reference/architecture/dependencies.md
+++ b/docs/reference/architecture/dependencies.md
@@ -35,7 +35,7 @@ reverse. An app may not import the other app's source directly.
**Atomic-layer rules:** `domain/` is framework-free (no Angular); `contracts/` import nothing
(pure wire DTOs, ADR-0001); `ui/` + `layout/` never import `infrastructure/` directly (reach data
through an application store/command — type-only DTO imports are fine); the generated `ApiClient`
-is a value only inside `infrastructure/` (+ `libs/shared/src/upload`). Plus **no circular**
+is a value only inside `infrastructure/`. Plus **no circular**
dependencies. These apply uniformly across an app's tree and both libraries — no debug-state
exception anymore (WP-67 moved the dev panel component out of `libs/shared` into `apps/ssp` since
it's genuinely SSP-specific, coupled to `BigProfileStore`; the shared `ShellComponent` hosts
diff --git a/libs/shared/src/upload/upload-controller.ts b/libs/shared/src/application/upload-controller.ts
similarity index 88%
rename from libs/shared/src/upload/upload-controller.ts
rename to libs/shared/src/application/upload-controller.ts
index cf6cb56..7fa1522 100644
--- a/libs/shared/src/upload/upload-controller.ts
+++ b/libs/shared/src/application/upload-controller.ts
@@ -1,9 +1,19 @@
import { DestroyRef, effect, inject } from '@angular/core';
-import { CategoryParams, UploadAdapter } from './upload.adapter';
+import {
+ CategoryParams,
+ UploadAdapter,
+ uploadContentUrl,
+} from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from './upload-shell.service';
import { problemDetail } from '@shared/infrastructure/api-error';
import { SUBMIT_FAILED } from '@shared/application/submit';
-import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine';
+import {
+ DeliveryChannel,
+ UploadMsg,
+ UploadState,
+ inFlight,
+ rejectReason,
+} from '@shared/domain/upload.machine';
export interface UploadControllerDeps {
wizardId: string;
@@ -59,6 +69,11 @@ export function createUploadController(deps: UploadControllerDeps) {
}
return {
+ /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids
+ have no stored bytes, so they get no link. */
+ previewUrlFor(documentId: string): string | undefined {
+ return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId);
+ },
onFileSelected(categoryId: string, selected: File[]) {
const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId);
if (!cat) return;
diff --git a/libs/shared/src/upload/upload-shell.service.ts b/libs/shared/src/application/upload-shell.service.ts
similarity index 94%
rename from libs/shared/src/upload/upload-shell.service.ts
rename to libs/shared/src/application/upload-shell.service.ts
index 4b88329..bab1e67 100644
--- a/libs/shared/src/upload/upload-shell.service.ts
+++ b/libs/shared/src/application/upload-shell.service.ts
@@ -1,7 +1,12 @@
import { Injectable, inject } from '@angular/core';
-import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED } from './upload.adapter';
+import {
+ UploadAdapter,
+ XhrUploadRequest,
+ XhrUploadHandle,
+ UPLOAD_ABORTED,
+} from '@shared/infrastructure/upload.adapter';
import { problemDetail } from '@shared/infrastructure/api-error';
-import { UploadMsg, Upload } from './upload.machine';
+import { UploadMsg, Upload } from '@shared/domain/upload.machine';
/**
* Transport seam (PRD §6): how upload bytes leave the browser. The shipped impl is
diff --git a/libs/shared/src/upload/upload.machine.spec.ts b/libs/shared/src/domain/upload.machine.spec.ts
similarity index 100%
rename from libs/shared/src/upload/upload.machine.spec.ts
rename to libs/shared/src/domain/upload.machine.spec.ts
diff --git a/libs/shared/src/upload/upload.machine.ts b/libs/shared/src/domain/upload.machine.ts
similarity index 100%
rename from libs/shared/src/upload/upload.machine.ts
rename to libs/shared/src/domain/upload.machine.ts
diff --git a/libs/shared/src/upload/upload.adapter.ts b/libs/shared/src/infrastructure/upload.adapter.ts
similarity index 99%
rename from libs/shared/src/upload/upload.adapter.ts
rename to libs/shared/src/infrastructure/upload.adapter.ts
index a0d1620..0cb0e7e 100644
--- a/libs/shared/src/upload/upload.adapter.ts
+++ b/libs/shared/src/infrastructure/upload.adapter.ts
@@ -8,7 +8,7 @@ import { problemDetail } from '@shared/infrastructure/api-error';
import { currentScenario } from '@shared/infrastructure/scenario';
import { currentSubject } from '@shared/infrastructure/subject';
import { environment } from '@shared/environments/environment';
-import { DocumentCategory } from './upload.machine';
+import { DocumentCategory } from '@shared/domain/upload.machine';
/** Answer-derived query params that affect which categories the server presents. */
export interface CategoryParams {
diff --git a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts
index 41fab35..59d2bc4 100644
--- a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts
+++ b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts
@@ -1,5 +1,5 @@
import { Component, input, output } from '@angular/core';
-import type { DeliveryChannel } from '@shared/upload/upload.machine';
+import type { DeliveryChannel } from '@shared/domain/upload.machine';
/** Atom: choose how a document is delivered — uploaded digitally or sent by post.
Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */
diff --git a/libs/shared/src/ui/upload/document-category/document-category.component.ts b/libs/shared/src/ui/upload/document-category/document-category.component.ts
index 33193bb..6540530 100644
--- a/libs/shared/src/ui/upload/document-category/document-category.component.ts
+++ b/libs/shared/src/ui/upload/document-category/document-category.component.ts
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core';
-import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine';
+import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/domain/upload.machine';
import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component';
import { FileInputComponent } from '../file-input/file-input.component';
import { SingleUploadComponent } from '../single-upload/single-upload.component';
diff --git a/libs/shared/src/ui/upload/document-category/document-category.stories.ts b/libs/shared/src/ui/upload/document-category/document-category.stories.ts
index a82275f..1bc2aed 100644
--- a/libs/shared/src/ui/upload/document-category/document-category.stories.ts
+++ b/libs/shared/src/ui/upload/document-category/document-category.stories.ts
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular';
-import type { DocumentCategory, Upload } from '@shared/upload/upload.machine';
+import type { DocumentCategory, Upload } from '@shared/domain/upload.machine';
import { DocumentCategoryComponent } from './document-category.component';
const meta: Meta = {
diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts
index e3a10b8..b5053e3 100644
--- a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts
+++ b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts
@@ -1,5 +1,5 @@
import { Component, computed, input } from '@angular/core';
-import type { UploadStatus } from '@shared/upload/upload.machine';
+import type { UploadStatus } from '@shared/domain/upload.machine';
import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component';
const STATUS_LABELS: Record = {
diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts
index ac1ff05..4747651 100644
--- a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts
+++ b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular';
-import type { UploadStatus } from '@shared/upload/upload.machine';
+import type { UploadStatus } from '@shared/domain/upload.machine';
import { DocumentChipComponent } from './document-chip.component';
const meta: Meta = {
diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts
index 9aee010..eb024f1 100644
--- a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts
+++ b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts
@@ -1,5 +1,5 @@
import { Component, input, output } from '@angular/core';
-import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine';
+import type { DeliveryChannel, UploadState } from '@shared/domain/upload.machine';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DocumentCategoryComponent } from '../document-category/document-category.component';
diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts
index 578ca75..af6fff8 100644
--- a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts
+++ b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular';
-import type { UploadState } from '@shared/upload/upload.machine';
+import type { UploadState } from '@shared/domain/upload.machine';
import { DocumentUploadComponent } from './document-upload.component';
const meta: Meta = {
diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts
index 25454bd..6fdda98 100644
--- a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts
+++ b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core';
-import type { Upload } from '@shared/upload/upload.machine';
+import type { Upload } from '@shared/domain/upload.machine';
import { DocumentChipComponent } from '../document-chip/document-chip.component';
import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.component';
diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts
index 5d4ee64..f612abe 100644
--- a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts
+++ b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular';
-import type { Upload } from '@shared/upload/upload.machine';
+import type { Upload } from '@shared/domain/upload.machine';
import { SingleUploadComponent } from './single-upload.component';
const meta: Meta = {
diff --git a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts
index b5ac079..a2cdf4a 100644
--- a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts
+++ b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts
@@ -1,5 +1,5 @@
import { Component, computed, input } from '@angular/core';
-import type { UploadStatus } from '@shared/upload/upload.machine';
+import type { UploadStatus } from '@shared/domain/upload.machine';
interface Glyph {
char: string;
From 07bb6277c0fbd47682e7ffe25317421688050493 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 20:42:00 +0200
Subject: [PATCH 49/61] refactor(backend): extract brief guards into
Domain/Letters/BriefRules.cs (RB-30)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BriefStore's five guard decisions (Save, Submit, Send, and the shared
Approve/Reject review path) were pure functions of status tag, actor role,
and entity completeness, but each sat inside a lock-held, DB-opening
method. A spec could not exercise the decision without a booted host and
a real SQLite file.
Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.
Co-Authored-By: Claude Opus 5
---
.../src/BigRegister.Api/Data/BriefStore.cs | 21 +-
.../Domain/Letters/BriefRules.cs | 63 ++++++
.../Domain/BriefRuleTests.cs | 147 ++++++++++++
.../refactor-backlog/99-backlog.md | 2 +-
.../refactor-backlog/implementation/rb-30.md | 210 ++++++++++++++++++
libs/shared/docs/behaviour-spec.mdx | 26 ++-
6 files changed, 456 insertions(+), 13 deletions(-)
create mode 100644 backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs
create mode 100644 backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md
diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs
index f880d98..170b3fd 100644
--- a/backend/src/BigRegister.Api/Data/BriefStore.cs
+++ b/backend/src/BigRegister.Api/Data/BriefStore.cs
@@ -68,10 +68,10 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
- if (!isDrafter) return (Outcome.Forbidden, null);
- if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
+ var outcome = BriefRules.CanSave(e.Status, isDrafter);
+ if (outcome != Outcome.Ok) return (outcome, null);
e.Sections = sections.ToList();
- if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
+ e.Status = BriefRules.StatusAfterSave(e.Status);
db.SaveChanges();
return (Outcome.Ok, e);
}
@@ -84,8 +84,8 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
- if (!isDrafter) return (Outcome.Forbidden, null);
- if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
+ var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections));
+ if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
db.SaveChanges();
return (Outcome.Ok, e);
@@ -107,7 +107,8 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
- if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
+ var outcome = BriefRules.CanSend(e.Status);
+ if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on
// its appearance is frozen — republishing the template touches unsent briefs only.
@@ -150,7 +151,7 @@ public static class BriefStore
// from the drafter (a drafter cannot approve their own letter). The SoD check is
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
- // inline check exactly.
+ // inline check exactly (BriefRules.CanDecide preserves that order).
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func next)
{
lock (_gate)
@@ -158,15 +159,13 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
- if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
- if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
+ var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId);
+ if (outcome != Outcome.Ok) return (outcome, null);
e.Status = next();
db.SaveChanges();
return (Outcome.Ok, e);
}
}
-
- private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
}
/// Seeded template (sections + placeholder fields) and passage library.
diff --git a/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs b/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs
new file mode 100644
index 0000000..a5bea7c
--- /dev/null
+++ b/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs
@@ -0,0 +1,63 @@
+using BigRegister.Api.Contracts;
+using BigRegister.Api.Data;
+using BigRegister.Domain.Authorization;
+
+namespace BigRegister.Domain.Letters;
+
+///
+/// SERVER-OWNED brief state-transition and authorization rules (RB-30, TE-008). Each
+/// method is a pure decision over (status tag, actor role, entity completeness) —
+/// extracted out of 's lock-held, DB-opening methods so the
+/// decision can be unit-tested without a booted host or a real SQLite file. Callers
+/// pass the values the rule needs, never the entity, so this stays pure.
+///
+/// Returns — that type already exists as the domain
+/// concept the whole brief flow reports through (`BriefResult` in Program.cs switches
+/// on it directly), so this reuses it rather than inventing a second result shape.
+///
+public static class BriefRules
+{
+ /// Save is drafter-only, and only while the letter is editable (draft/rejected).
+ /// Order matches the store's original inline check: role before status, so a
+ /// non-drafter always sees Forbidden even against a non-editable status.
+ public static BriefStore.Outcome CanSave(BriefStatusDto status, bool isDrafter)
+ {
+ if (!isDrafter) return BriefStore.Outcome.Forbidden;
+ if (status.Tag is not ("draft" or "rejected")) return BriefStore.Outcome.Conflict;
+ return BriefStore.Outcome.Ok;
+ }
+
+ /// A save on a rejected letter reopens it to draft (mirrors the FE reducer); a save
+ /// on a draft leaves the status untouched.
+ public static BriefStatusDto StatusAfterSave(BriefStatusDto status) =>
+ status.Tag == "rejected" ? new BriefStatusDto("draft") : status;
+
+ /// Every required section needs at least one block before a letter is submittable.
+ public static bool RequiredFilled(IReadOnlyList sections) =>
+ sections.All(s => !s.Required || s.Blocks.Count > 0);
+
+ /// Submit is drafter-only, only from draft, and only once every required section
+ /// is filled.
+ public static BriefStore.Outcome CanSubmit(BriefStatusDto status, bool isDrafter, bool requiredFilled)
+ {
+ if (!isDrafter) return BriefStore.Outcome.Forbidden;
+ if (status.Tag != "draft" || !requiredFilled) return BriefStore.Outcome.Conflict;
+ return BriefStore.Outcome.Ok;
+ }
+
+ /// Send only from approved — sending is a mechanical dispatch step, not role-gated
+ /// (Authz.CanActOn already returns true unconditionally for BriefAction.Send).
+ public static BriefStore.Outcome CanSend(BriefStatusDto status) =>
+ status.Tag == "approved" ? BriefStore.Outcome.Ok : BriefStore.Outcome.Conflict;
+
+ /// Approve/Reject share this guard: the caller must be entitled to act on the letter
+ /// (four-eyes/SoD, via the existing ), and the letter must
+ /// be submitted. The entitlement check runs BEFORE the status check — Forbidden takes
+ /// priority over Conflict, matching the store's original order exactly.
+ public static BriefStore.Outcome CanDecide(BriefAction action, BriefStatusDto status, Principal principal, string drafterId)
+ {
+ if (!Authz.CanActOn(action, principal, drafterId)) return BriefStore.Outcome.Forbidden;
+ if (status.Tag != "submitted") return BriefStore.Outcome.Conflict;
+ return BriefStore.Outcome.Ok;
+ }
+}
diff --git a/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs
new file mode 100644
index 0000000..de01174
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs
@@ -0,0 +1,147 @@
+using BigRegister.Api.Contracts;
+using BigRegister.Api.Data;
+using BigRegister.Domain.Authorization;
+using BigRegister.Domain.Letters;
+
+namespace BigRegister.Tests.Domain;
+
+public class BriefRuleTests
+{
+ private static BriefStatusDto Status(string tag) => new(tag);
+
+ private static readonly Principal Drafter = new(PrincipalRole.Drafter);
+ private static readonly Principal Approver = new(PrincipalRole.Approver);
+
+ // --- CanSave -----------------------------------------------------------------
+
+ [Theory]
+ [InlineData("draft")]
+ [InlineData("rejected")]
+ public void A_drafter_may_save_a_draft_or_rejected_letter(string tag) =>
+ Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSave(Status(tag), isDrafter: true));
+
+ [Theory]
+ [InlineData("submitted")]
+ [InlineData("approved")]
+ [InlineData("sent")]
+ public void A_drafter_may_not_save_a_non_editable_letter(string tag) =>
+ Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSave(Status(tag), isDrafter: true));
+
+ [Theory]
+ [InlineData("draft")]
+ [InlineData("submitted")]
+ public void A_non_drafter_is_forbidden_to_save_regardless_of_status(string tag) =>
+ // Role is checked before status: Forbidden wins even against an otherwise-open status.
+ Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSave(Status(tag), isDrafter: false));
+
+ // --- StatusAfterSave -----------------------------------------------------------
+
+ [Fact]
+ public void Saving_a_rejected_letter_reopens_it_to_draft() =>
+ Assert.Equal("draft", BriefRules.StatusAfterSave(Status("rejected")).Tag);
+
+ [Fact]
+ public void Saving_a_draft_letter_leaves_its_status_unchanged() =>
+ Assert.Equal("draft", BriefRules.StatusAfterSave(Status("draft")).Tag);
+
+ // --- RequiredFilled --------------------------------------------------------------
+
+ private static LetterSectionDto Section(string key, bool required, int blockCount) =>
+ new(key, key, required, Enumerable.Range(0, blockCount)
+ .Select(i => new LetterBlockDto("freeText", $"{key}-{i}", new RichTextBlockDto(Array.Empty())))
+ .ToList());
+
+ [Fact]
+ public void No_required_sections_means_nothing_to_fill() =>
+ Assert.True(BriefRules.RequiredFilled(Array.Empty()));
+
+ [Fact]
+ public void An_optional_empty_section_does_not_block_submission() =>
+ Assert.True(BriefRules.RequiredFilled(new[] { Section("slot", required: false, blockCount: 0) }));
+
+ [Fact]
+ public void A_required_section_with_a_block_is_filled() =>
+ Assert.True(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 1) }));
+
+ [Fact]
+ public void A_required_section_with_no_blocks_is_not_filled() =>
+ Assert.False(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 0) }));
+
+ [Fact]
+ public void One_unfilled_required_section_blocks_submission_even_if_others_are_filled() =>
+ Assert.False(BriefRules.RequiredFilled(new[]
+ {
+ Section("kern", required: true, blockCount: 1),
+ Section("bijlage", required: true, blockCount: 0),
+ }));
+
+ // --- CanSubmit -----------------------------------------------------------------
+
+ [Fact]
+ public void A_drafter_may_submit_a_filled_draft() =>
+ Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: true));
+
+ [Fact]
+ public void A_drafter_may_not_submit_an_unfilled_draft() =>
+ Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: false));
+
+ [Fact]
+ public void A_drafter_may_not_submit_a_letter_that_is_not_a_draft() =>
+ Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("submitted"), isDrafter: true, requiredFilled: true));
+
+ [Fact]
+ public void A_non_drafter_is_forbidden_to_submit_even_a_filled_draft() =>
+ // Role is checked before status/completeness: Forbidden wins over Conflict.
+ Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSubmit(Status("draft"), isDrafter: false, requiredFilled: true));
+
+ // --- CanSend ---------------------------------------------------------------------
+
+ [Fact]
+ public void An_approved_letter_may_be_sent() =>
+ Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSend(Status("approved")));
+
+ [Theory]
+ [InlineData("draft")]
+ [InlineData("submitted")]
+ [InlineData("rejected")]
+ [InlineData("sent")]
+ public void Only_an_approved_letter_may_be_sent(string tag) =>
+ Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSend(Status(tag)));
+
+ // --- CanDecide (Approve/Reject shared guard) --------------------------------------
+
+ [Theory]
+ [InlineData(BriefAction.Approve)]
+ [InlineData(BriefAction.Reject)]
+ public void An_approver_may_decide_a_submitted_letter_drafted_by_someone_else(BriefAction action) =>
+ Assert.Equal(
+ BriefStore.Outcome.Ok,
+ BriefRules.CanDecide(action, Status("submitted"), Approver, drafterId: BriefStore.DrafterId));
+
+ [Fact]
+ public void A_drafter_may_not_approve_or_reject() =>
+ Assert.Equal(
+ BriefStore.Outcome.Forbidden,
+ BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Drafter, drafterId: BriefStore.DrafterId));
+
+ [Fact]
+ public void An_approver_may_not_decide_a_letter_they_drafted_themselves() =>
+ // Four-eyes / SoD: the acting approver id happens to equal the letter's drafterId.
+ Assert.Equal(
+ BriefStore.Outcome.Forbidden,
+ BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Approver, drafterId: BriefStore.ApproverId));
+
+ [Fact]
+ public void An_approver_may_not_decide_a_letter_that_is_not_submitted() =>
+ Assert.Equal(
+ BriefStore.Outcome.Conflict,
+ BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.DrafterId));
+
+ [Fact]
+ public void Entitlement_is_checked_before_status_forbidden_wins_over_conflict() =>
+ // Same actor as drafter AND a non-submitted status: still Forbidden, not Conflict —
+ // matches the store's original check order (Authz.CanActOn before the status guard).
+ Assert.Equal(
+ BriefStore.Outcome.Forbidden,
+ BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.ApproverId));
+}
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
index fe54364..a95d9fa 100644
--- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
@@ -131,7 +131,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
-| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
+| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md
new file mode 100644
index 0000000..207d4f3
--- /dev/null
+++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md
@@ -0,0 +1,210 @@
+# RB-30 — extract `BriefStore`'s guards into `Domain/Letters/BriefRules.cs`
+
+Status: **implemented** · 2026-08-27 · Source finding: `02-testability.md` TE-008 ·
+`99-backlog.md` RB-30
+
+RB-30 moves the brief workflow's five guard decisions out of `BriefStore` (a
+lock-held, DB-opening static store) into a pure `Domain/Letters/BriefRules.cs`, and
+adds a free-running unit test file for them. This is a pure extraction: the store
+keeps its lock, its `Db.Create()`, its static shape, and every method's signature.
+
+## What was wrong
+
+Five guard clusters in `Data/BriefStore.cs` are pure decisions over `(status tag,
+actor role, entity completeness)` — Save, Submit, Send, and the shared Approve/Reject
+review path each start with an `if` cascade that is a function of two enums and a
+bool. But every one of those `if`s sat inside a method that had already done `lock
+(_gate) { using var db = Db.Create(); ... }`, so a spec could not exercise the
+decision without a booted host and a real SQLite file. `Domain/Letters/` held only
+`LetterHtml.cs` and `OrgTemplateRules.cs`; there was no `BriefRules` class, even
+though `Authz.CanActOn` — a pure `Domain/Authorization/` call one line away from
+three of the guards — already proved the pattern worked for this exact file.
+
+## What changed
+
+| File | Change |
+| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs` | New. Five pure statics: `CanSave`, `StatusAfterSave`, `RequiredFilled` + `CanSubmit`, `CanSend`, `CanDecide`. All take `BriefStatusDto`/`bool`/`Principal`/`string`, never `BriefEntity` — no persistence type reaches this file. |
+| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `Save`, `Submit`, `Send`, and the private `Review` (the Approve/Reject shared path) each replace their inline `if` cascade with one call into `BriefRules`, then branch only on the returned `Outcome`. The private `RequiredFilled(BriefEntity e)` helper is deleted — `BriefRules.RequiredFilled(IReadOnlyList)` replaces it. Lock, `Db.Create()`, method signatures, and the public `Outcome` enum are all unchanged. |
+| `backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs` | New. 29 `[Fact]`/`[Theory]` assertions covering every branch of all five rules — see "Tests added" below. |
+| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-30's status cell: `open` → `done`. |
+
+## The surface, as built — and where it differs from TE-008's proposal
+
+TE-008 proposed:
+
+```
+CanSave(BriefStatusDto status, bool isDrafter) → Outcome
+StatusAfterSave(BriefStatusDto) → BriefStatusDto
+CanSubmit(status, isDrafter, bool requiredFilled) → Outcome
+CanSend(status)
+CanDecide(status, Principal, drafterId)
+```
+
+The ticket explicitly names this a proposal, not a specification. What was built
+matches it almost exactly, with two adjustments forced by the real code:
+
+- **`Outcome` is `BriefStore.Outcome`, not a new type.** `BriefStore` already
+ exposes a public `enum Outcome { Ok, Forbidden, Conflict }`, and `Program.cs`'s
+ `BriefResult` switches on it directly across every brief endpoint. TE-008 itself
+ says: "if `Outcome` does not already exist as a domain concept, use whatever the
+ sibling rule classes already return" — it does exist, so `BriefRules` returns it
+ rather than inventing a second result shape. This does mean `Domain/Letters/`
+ references a type nested in `Api.Data`; the same cross-reference already exists in
+ this file's neighbor, `LetterHtml.cs` (`using BigRegister.Api.Data;`, for
+ `BriefEntity`), and in `Authz.cs` (for `BriefStore`'s role-id constants) — both in
+ the same single-assembly project, so this is a namespace convention, not an
+ assembly boundary. `Outcome` itself is a plain three-value enum with no EF/ASP.NET
+ attached, so this does not pull a persistence type into `Domain/`.
+- **`CanDecide` takes an explicit `BriefAction action` parameter**, not just
+ `(status, Principal, drafterId)`. The real guard — `BriefStore.Review` — is one
+ private method shared by both `Approve` and `Reject`, and it calls
+ `Authz.CanActOn(action, principal, drafterId)`, which needs to know which action is
+ being attempted. `BriefRules.CanDecide` composes that existing pure
+ `Authz.CanActOn` call with the status check, rather than re-implementing the SoD
+ logic a second time — so the four-eyes rule still has exactly one source of truth.
+
+The `RequiredFilled` predicate is a sixth pure static, not one of the five guards
+proper — TE-008 names it separately ("plus the `RequiredFilled(e)` predicate") and it
+is built the same way: `RequiredFilled(IReadOnlyList sections) →
+bool`, taking the section list rather than the entity.
+
+## Order and behaviour preserved
+
+Every rule keeps the original check order, which matters because `Outcome.Forbidden`
+must outrank `Outcome.Conflict` (a non-drafter or non-entitled caller sees Forbidden
+even against an otherwise-invalid status):
+
+- `CanSave`: `!isDrafter` (Forbidden) before the status-tag check (Conflict).
+- `CanSubmit`: `!isDrafter` (Forbidden) before `status.Tag != "draft" ||
+!requiredFilled` (Conflict).
+- `CanDecide`: `!Authz.CanActOn(...)` (Forbidden) before `status.Tag != "submitted"`
+ (Conflict) — the exact order the old inline check in `Review` used, per its own
+ comment ("checked BEFORE the status guard").
+- `CanSave`'s entity-not-found branch (`e is null → Conflict`) stays inline in
+ `BriefStore` — it is a persistence fact ("no row for this owner"), not one of the
+ three business axes TE-008 names (status tag, actor role, entity completeness), so
+ it was left where it was rather than forced into a rule that would then need to
+ accept a nullable entity.
+
+## Tests added
+
+`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, 29 assertions, alongside
+the seven Domain test files that already existed:
+
+- **`CanSave`** — drafter saves draft/rejected (Ok, `[Theory]`); drafter saves
+ submitted/approved/sent (Conflict, `[Theory]`); non-drafter saves draft or submitted
+ (Forbidden both times — proves role beats status).
+- **`StatusAfterSave`** — rejected → draft; draft stays draft.
+- **`RequiredFilled`** — no sections; an unfilled optional section; a filled required
+ section; an unfilled required section; one filled + one unfilled required section
+ (proves one bad section blocks the whole letter).
+- **`CanSubmit`** — filled draft (Ok); unfilled draft (Conflict — **the required-filled
+ gate the ticket explicitly asked for**); non-draft status (Conflict); non-drafter
+ on a filled draft (Forbidden — role beats completeness).
+- **`CanSend`** — approved (Ok); draft/submitted/rejected/sent (Conflict, `[Theory]`).
+- **`CanDecide`** — approver decides a submitted letter drafted by someone else, for
+ both Approve and Reject (Ok, `[Theory]`); a drafter attempting to decide (Forbidden
+ — **the non-drafter denial the ticket asked for**); an approver whose acting id
+ equals the drafter id, i.e. self-review (Forbidden — the four-eyes/SoD case); an
+ approver deciding a non-submitted letter (Conflict); an approver who is also the
+ drafter AND the status is non-submitted (Forbidden, not Conflict — proves the
+ priority order survived the extraction).
+
+## Verified red without the fix
+
+Inverted `CanSubmit`'s completeness check (`!requiredFilled` → `requiredFilled`) with
+an `Edit`, ran `BriefRuleTests` alone:
+
+```
+[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_not_submit_an_unfilled_draft [FAIL]
+ Assert.Equal() Failure: Values differ
+Expected: Conflict
+Actual: Ok
+[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_submit_a_filled_draft [FAIL]
+ Assert.Equal() Failure: Values differ
+Expected: Ok
+Actual: Conflict
+
+Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29
+```
+
+Reverted with a second `Edit` (never `git checkout` — that would have discarded the
+whole file). Reran: 29/29 green.
+
+## Existing tests — unchanged
+
+`BriefEndpointTests.cs`, `PreviewEndpointTests.cs`, and `OrgTemplateEndpointTests.cs`
+(the three host-booting suites that exercise the brief endpoints) needed **no
+changes**. Ran together: 32/32 passing, proving the extraction preserved every HTTP
+outcome (`Save_is_drafter_only`, `Submit_blocks_on_empty_required_section`,
+`Submit_succeeds_when_required_sections_filled`,
+`Drafter_cannot_approve_own_letter_but_a_different_reviewer_can`,
+`Reject_returns_comments`, `Editing_a_rejected_letter_reopens_it_to_draft`,
+`Send_only_from_approved`, and the rest, all unmodified).
+
+## The metric TE-008 cares about: host-booting brief-rule assertions
+
+Before this ticket, the five guard decisions had **zero** free-running unit
+assertions. Every branch of every guard was reachable only through the seven
+host-booting endpoint test methods above (six of them containing an explicit
+`Assert.Equal(HttpStatusCode.Forbidden/Conflict, ...)`, each paying a full
+`TestWebApplicationFactory` host boot plus a real SQLite round-trip, run serially
+process-wide because of `[assembly: DisableTestParallelization]`).
+
+After this ticket:
+
+- **0 → 29** free-running unit assertions covering these branches
+ (`BriefRuleTests.cs`, `dotnet test --filter FullyQualifiedName~BriefRuleTests`
+ completes in **~120 ms**, no host, no SQLite file).
+- **7 → 7** host-booting endpoint tests, unchanged. They stay — they are now the
+ proof that `BriefStore` wires `BriefRules`'s answer to the right HTTP status, not
+ the only place the business decision itself is checked. That split (wiring proven
+ at the integration layer, decision logic proven at the unit layer) is the seam
+ TE-008 argued for.
+- New branches this ticket made assertable that the endpoint suite never covered
+ directly: the SoD self-review case (`An_approver_may_not_decide_a_letter_they_drafted_themselves`)
+ and the Forbidden-beats-Conflict priority ordering for both `CanSave`/`CanSubmit`
+ (role checked first) and `CanDecide` (entitlement checked first) — these existed as
+ implicit behaviour in the original `if` cascades but had no assertion pinning them
+ before RB-30.
+
+## What was not extracted
+
+Nothing — all five guards named in TE-008, plus the `RequiredFilled` predicate, moved
+cleanly. None needed the `DbContext`: each was already a function of values already
+resident on the in-memory `BriefEntity` (its `Status`, `Sections`, `DrafterId`), never
+of a query against the database itself.
+
+## Scope respected
+
+- `Domain/Letters/LetterHtml.cs` was not touched (a concurrent agent owns it).
+- `BriefEntity.ToDto()` was not touched — its CC 16 is a separate, out-of-scope
+ finding per the ticket.
+- `Data/Db.cs`'s static-store decision and `TestWebApplicationFactory`'s serialized-test
+ position were not challenged; the store's lock, `Db.Create()`, and public shape are
+ byte-for-byte the same as before this ticket, other than the `if` cascades moving
+ out.
+
+## Verification
+
+- `dotnet build`: 0 warnings, 0 errors.
+- `dotnet test --filter FullyQualifiedName~BriefRuleTests`: 29/29, ~120 ms.
+- `dotnet test --filter FullyQualifiedName~BriefEndpointTests|...PreviewEndpointTests|...OrgTemplateEndpointTests`:
+ 32/32, unchanged.
+- Full backend suite: **291/292 passing**, plus the one known, pre-existing,
+ container-dependent failure
+ (`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
+ "Connection refused (localhost:8000)") — not this ticket's bug, does not run under
+ `npm run ci`, reproduces on a clean tree with no OpenZaak container running.
+- `npm run ci` (foreground, no background/Monitor): see the commit message / session
+ report for the exit code and step count.
+
+## What this ticket did not touch
+
+No frontend file was touched — the brief workflow's status machine is server-
+authoritative, and the FE's own pure reducer (mirroring these same transitions for
+UX) was already out of this ticket's scope. No file outside `backend/Data/BriefStore.cs`,
+`backend/Domain/Letters/BriefRules.cs`,
+`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, and `99-backlog.md` was
+changed.
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
index 581ab7d..ff45045 100644
--- a/libs/shared/docs/behaviour-spec.mdx
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -21,7 +21,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 467 frontend behaviours across
-9 contexts; 238 backend behaviours across 41 test
+9 contexts; 259 backend behaviours across 42 test
classes.
## Frontend (by context)
@@ -1017,6 +1017,30 @@ classes.
- Me returns no capabilities for drafter and the brief set for approver
- Reset recreates a fresh draft with locked prefilled sections
+### BriefRuleTests
+
+- A drafter may save a draft or rejected letter
+- A drafter may not save a non editable letter
+- A non drafter is forbidden to save regardless of status
+- Saving a rejected letter reopens it to draft
+- Saving a draft letter leaves its status unchanged
+- No required sections means nothing to fill
+- An optional empty section does not block submission
+- A required section with a block is filled
+- A required section with no blocks is not filled
+- One unfilled required section blocks submission even if others are filled
+- A drafter may submit a filled draft
+- A drafter may not submit an unfilled draft
+- A drafter may not submit a letter that is not a draft
+- A non drafter is forbidden to submit even a filled draft
+- An approved letter may be sent
+- Only an approved letter may be sent
+- An approver may decide a submitted letter drafted by someone else
+- A drafter may not approve or reject
+- An approver may not decide a letter they drafted themselves
+- An approver may not decide a letter that is not submitted
+- Entitlement is checked before status forbidden wins over conflict
+
### DiplomaRuleTests
- Profession is derived from program
From ddd02f65bced1fe2dc340bb03d6fac3461fc1089 Mon Sep 17 00:00:00 2001
From: Edwin van den Houdt
Date: Thu, 27 Aug 2026 20:42:58 +0200
Subject: [PATCH 50/61] fix(backend): resolve the body datum placeholder from
at, not UtcNow (RB-29)
LetterHtml.Render already receives the letter's instant and uses it
for the letterhead date. The body's "datum" placeholder resolved
through ResolveAuto, which ignored that instant and read the wall
clock instead. This is not a shipped bug today, because every current
caller passes Now() at render time. It becomes one the moment Render
runs with a historical instant (an archive re-render, a back-dated
letter): the letterhead and the body would then disagree within one
document.
Thread the existing "at" parameter down through RenderParagraphs and
RenderNode into ResolveAuto's "datum" case. Render's own signature,
and every call site, stays unchanged.
Add two tests with a fixed historical "at": one pins the body's
rendered date to the expected Dutch string, the other asserts the
letterhead date and the body date agree. Both fail red against the
old code, showing today's date instead of the pinned one.
Co-Authored-By: Claude Opus 5
---
.../Domain/Letters/LetterHtml.cs | 15 ++-
.../BigRegister.Tests/LetterHtmlTests.cs | 61 +++++++++
.../refactor-backlog/99-backlog.md | 70 +++++-----
.../refactor-backlog/implementation/rb-29.md | 125 ++++++++++++++++++
4 files changed, 229 insertions(+), 42 deletions(-)
create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md
diff --git a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
index fffb309..8ddc879 100644
--- a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
+++ b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
@@ -56,7 +56,7 @@ public static class LetterHtml
{
sb.Append("