docs: archive the finished backlogs (RD-30)

Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-08 23:00:38 +02:00
co-authored by Claude Opus 5
parent 097e8468e0
commit 12f17d9d73
161 changed files with 154 additions and 24 deletions
@@ -0,0 +1,543 @@
## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata)
## Status: complete
## Last updated: 2026-08-26
## Depends on: 00-baseline.md
## ---
# 04 — CQRS-light: command/query separation at the application-service level
**Mandate reminder, applied literally.** This agent may only _extend_ CQRS-light where
baseline §7 records it already exists. It may not introduce it. Every finding below names
the concrete existing artifact it extends. Three things the baseline flagged as tempting
are therefore **not** filed as tickets — they are in "Out of mandate (pattern absent)" at
the end.
**What "the pattern" concretely is in this repo** (from §7 + CLAUDE.md §3, so later
sections can just point at it):
| Side | The existing artifact |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| FE query | `resource({ loader })` in an `infrastructure/*.adapter.ts` + a `parse*` boundary + a `providedIn:'root'` read store exposing `RemoteData` |
| FE command | `application/submit-*.ts` command factory → `runSubmit(fn, fallback)` → `Result<string,T>` → the caller dispatches a Msg |
| FE fold | `libs/shared/src/application/submit.ts` — the single try/catch + ProblemDetails → error-string fold, **and** the Idempotency-Key mint point |
| BE | `Contracts/Dtos.cs` direction split (`*Request` in / `*Dto`+`*Response` out); read/write comment banners in `Program.cs` |
| BE read-mdl | `ToDetailDto(now)` / `ToDto(now)` — the read side _projects_ status from timestamps rather than the write side storing it |
**The cleanest module in the repo is `bhp/behandeling`**, and it is worth naming up front
because three findings below propose making another module look like it: it splits its
query adapter (`beoordeling.adapter.ts` `get`, `werkvoorraad.adapter.ts` `list`) from its
command adapter (`besluit.adapter.ts` `besluit`) into **separate files**, wraps only the
command in a command factory (`application/submit-besluit.ts`), and keeps the read store
(`beoordeling.store.ts`, `werkvoorraad.store.ts`) write-free. Its backend counterpart does
the same: `Program.cs:441` "read side only" banner, `Program.cs:464` the write banner. That
is the target shape, and it is already in the tree.
---
## apps/ssp — auth
**No findings.**
`SessionStore.login` (`apps/ssp/src/app/auth/application/session.store.ts:58`) is a write
and `session`/`isAuthenticated` are reads, but they share one three-field aggregate with no
network read path at all — `DigidAdapter.authenticate` is the only I/O and it is a command.
There is nothing to separate. §7 lists no command factory or read adapter in this context to
extend. BL-002 is agent 06's, not this agent's.
---
## apps/ssp — registratie
### CQ-001 — `createDraftSync` is registered as a command factory but owns three query paths
- **Module / file:line** — `apps/ssp/src/app/registratie/application/draft-sync.ts:50-236`
(queries at `:141 load`, `:160 findConcept`, `:179 resume`; commands at `:63 ensureId`,
`:100 flush`, `:213 submit`, `:221 reset`)
- **Extends** — the command-factory idiom itself. §7 counts `draft-sync.ts` as one of the
repo's **3 command factories**, alongside `submit-change-request.ts` (18 lines, one
command, zero reads) and `submit-besluit.ts` (16 lines, one command, zero reads). This
finding asks the third member of that set to look like the other two.
- **Baseline citation** — §7 Frontend, "Command factories (write side) | 3"; §4a metric
row **`createDraftSync` 143 lines** (the largest function in the codebase, tied to
`reduceUpload`'s 109 only in the two-member `fn>75` population); §9 threshold "TS
function > 40 lines".
- **The mixing, concretely** — the factory returns four members. `resume()` is pure query
orchestration: read `?aanvraag`, `adapter.detail(linked)`, else `findConcept()` →
`adapter.list()` → `parseApplications`. `submit()`/`reset()`/the debounce `effect` are
writes. They are entangled through three pieces of shared mutable closure state — `id`,
`ensuring`, and `resumeGate` (`:56-61`) — where `resumeGate` exists _only_ so the write
path (`ensureId`) can wait for the read path (`resume`) to finish. That coupling is
genuine and load-bearing, which is exactly why it is worth naming rather than leaving as
an unexplained 143-line function.
- **Proposed change, minimal** — extract the read half into
`application/find-concept.ts`: `findConcept(adapter, type)` and `loadConcept(adapter, id)`
as free functions taking the adapter (no `inject`, so they get a direct spec —
`draft-sync.spec.ts` already exists and would shrink). `createDraftSync` keeps `resumeGate`
and the write path and calls them. This is a move, not a redesign; the closure state stays
where it is.
- **Effort** — M. Independently shippable in one deploy (no wire change, no DTO change).
### CQ-002 — two read stores perform writes that bypass the `runSubmit` fold
- **Module / file:line** — `apps/ssp/src/app/registratie/application/applications.store.ts:54-64`
(`cancel`) and `apps/ssp/src/app/registratie/application/admin-cases.store.ts:46-56`
(`delete`); the adapter methods are
`infrastructure/applications.adapter.ts:60 cancel` and `:41 deleteAny`.
- **Extends** — `runSubmit` + `SUBMIT_FAILED` (`libs/shared/src/application/submit.ts:15,28`),
the fold that all 16 other mutations in the repo pass through, and the
`createSubmitChangeRequest` command factory that lives _in this same folder_
(`registratie/application/submit-change-request.ts`) and does exactly this for the other
registratie write.
- **Baseline citation** — **BL-007** ("the FE write side is inconsistently placed");
§7 Frontend, "Command factories | 3" vs "Mutations living inline in adapters | ~13".
Note these two are a _fourteenth and fifteenth_ case BL-007 did not enumerate: they are
worse than the ~13, because those at least reach `runSubmit` inside the adapter — these
reach the raw `ApiClient` and never produce a `Result` at all.
- **The mixing, concretely** — both stores own a `RemoteData` read signal _and_ a write, and
the write's failure path is `catch { this.state.set(before); }` — a bare rollback with no
error channel. A failed cancel makes the row silently reappear with no message, no
`ActionState`, no ProblemDetails `detail`. `BriefStore`/`OrgTemplateStore` in the sibling
context both hold an `ActionState` + `lastError` for exactly this. The bare
`adapter.cancel()` also means the `Idempotency-Key` on the wire is a fresh UUID minted per
HTTP attempt by `api-client.provider.ts:58`, not the per-logical-submit key `runSubmit`
promises at `submit.ts:11-13` — that invariant's docstring is currently false for these
two calls (harmless today: `Program.cs` only honours the header inside the `Submit` helper,
see CQ-005's note).
- **Proposed change, minimal** — route both through `runSubmit` and surface the error.
Two options, pick one and apply to both stores identically:
(a) smallest — `const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
if (!r.ok) { this.state.set(before); this.error.set(r.error); }` plus one `error` signal;
(b) fuller — a `application/cancel-application.ts` command factory mirroring
`submit-change-request.ts`, which the store injects. (a) is enough to close the finding.
- **Effort** — S. Independently shippable; (a) touches 2 files plus a UI line each to render
the error.
### Not filed — `applications.adapter.ts` mixes 3 reads and 5 writes in one file
`infrastructure/applications.adapter.ts:31-66` holds `list`/`listAll`/`detail` next to
`create`/`syncDraft`/`cancel`/`deleteAny`/`submit`, where `bhp/behandeling` splits the
equivalent into `beoordeling.adapter.ts` + `besluit.adapter.ts`. The split would be the
structural enabler for CQ-002(b). On its own, though, it moves 8 thin one-line
`this.client.x()` wrappers between files and changes nothing observable — file placement is
agent 03's axis, not a read/write-mixing defect. Noted here so it is a deliberate omission
rather than a miss; fold it into CQ-002 if that ticket takes option (b).
### Not filed — `BigProfileStore`
`application/big-profile.store.ts` is the reference implementation of the split and needs no
change: reads are two resources projected through `parseDashboardView`, and the only
write-adjacent members are the `beginHerregistratie`/`confirmHerregistratie`/
`rollbackHerregistratie` invalidation hooks (`:65-74`) — the store never performs the write
itself, `createDraftSync.submit` does. ADR-0001's "Out of scope" section already records the
optimistic-flag race; that is a correctness note, not a CQRS-light one.
---
## apps/ssp — herregistratie
**No findings.**
`IntakePolicyStore` (`application/intake-policy.store.ts`) is a pure query facade over one
`resource()`. The context has no write of its own — its submit is `createDraftSync.submit`,
owned by `registratie` and covered by CQ-001. `intake.machine.ts` / `herregistratie.machine.ts`
are reducers; §3's "side effects stay out of the reducer" is honoured (verified: no `inject`,
no adapter import in either).
---
## apps/ssp — brief
### CQ-003 — `runSubmit` (the write-side fold, incl. the Idempotency-Key mint) is used for reads
- **Module / file:line** — `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:56`
(`load` → `briefGET`), `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:39`
(`list` → `orgTemplates`) and `:51` (`load` → `orgTemplateGET`). Same defect in
libs/beheer — see that section; one ticket should fix all five call sites.
- **Extends** — the two halves already present in `libs/shared/src/application/submit.ts`:
the ProblemDetails→`Result` **fold** (which reads legitimately want) and the
**`withIdempotencyKey` wrapper** (which is write-only by construction). The read idiom it
should join is the one the other 6 read adapters use — `resource({ loader })` +
`parse*` — or, for these imperative reads, the fold alone.
- **Baseline citation** — **BL-007**; §7 Frontend "Infrastructure adapters (read side) | 20"
and "Mutations living inline in adapters | ~13" — these three GETs are counted in the
wrong column of that inventory, because the code cannot tell them apart from the writes
beside them.
- **The mixing, concretely** — `submit.ts:11-13` states `runSubmit` is "the one place a
logical submit's Idempotency-Key is minted — once per `runSubmit` call". Each of these
reads therefore mints a UUID and assigns the module-level `pendingIdempotencyKey`
(`api-client.provider.ts:21-26`) for the duration of a GET. It is inert today: the header
is attached only when `method !== 'GET'` (`api-client.provider.ts:58`). But
`api-client.provider.ts:15-19` explicitly documents that the module-level variable "holds
up because every submit command calls its adapter synchronously (no await before reaching
this file)". Reads have no such discipline — `BriefStore.load()` and
`OrgTemplateStore.load()` are awaited across `await`s — so routing reads through the write
helper quietly widens the assumption that comment relies on. Reading `brief.adapter.ts:55-92`
it is also simply impossible to see which of the seven methods are commands: all seven are
`await runSubmit(...) → parseBriefView`.
- **Proposed change, minimal** — split `submit.ts` in place, no new concept:
`runResult(fn, fallback)` = the existing try/catch + `problemDetail` fold;
`runSubmit(fn, fallback)` = `runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback)`.
Point the 5 reads at `runResult`. Zero behaviour change, and afterwards the keyword at each
call site states the side. `submit.spec.ts` already exists and covers the fold.
- **Effort** — S. Independently shippable in one deploy; 1 shared file + 3 adapters (+2 in
libs/beheer).
### Not filed — `BriefStore` and `OrgTemplateStore` own both the read path and the write commands
`application/brief.store.ts` holds `load()` alongside `save`/`submit`/`approve`/`reject`/
`send`/`resetDemo`/`revealBigNummer`; `application/org-template.store.ts` holds
`load`/`selectSubOrg` alongside `flushSave`/`confirmPublish`/`rollback`. BL-007 points at
these as "mutations living inline in adapters", and it is tempting to file them.
They are deliberately not filed, and the reason matters for whoever reads this next.
CLAUDE.md §3 defines a command as "does the HTTP, then dispatches a message describing the
outcome" — and that is precisely what `BriefStore.transition()` (`:248-259`) and
`OrgTemplateStore.confirmPublish()` (`:175-189`) do: `ActionState → Busy`, cancel the
debounce, call the adapter, then `store.dispatch(...)` or `actionState.set(Failed)`. The
reducer stays pure. These stores _are_ the command layer; they are not a store that
accidentally grew writes. Both also implement the write→invalidate-read handoff correctly
(`confirmPublish` reloads via `selectSubOrg`, mirroring `BigProfileStore.confirmHerregistratie`'s
`viewRes.reload()`). Extracting six `createSubmitX()` factories out of `BriefStore` would move
code without changing which layer performs which effect. **The real defect in these two files
is CQ-003, and that is filed.**
---
## apps/ssp — showcase, shell, root
**No findings.** `showcase/concepts.page.ts`, `app.ts`, `app.config.ts`, `app.routes.ts`
contain no application services, no adapter calls and no state writes — routing, providers
and a teaching page. §7 lists no pattern here to extend. (Their 0% spec reach in §3b is
agent 02's; their `bhp/root` duplication in §5 is agent 01's.)
---
## apps/behandelportal — auth
**No findings.** Identical to ssp/auth by BL-002; the same reasoning applies.
---
## apps/behandelportal — behandeling
**No findings — this module is the reference implementation.**
Stated positively so later phases do not "clean it up" into something worse:
`werkvoorraad.adapter.ts` (`list`) and `beoordeling.adapter.ts` (`get`) are query-only files;
`besluit.adapter.ts` (`besluit`) is a command-only file; `submit-besluit.ts` is the command
factory; `werkvoorraad.store.ts` and `beoordeling.store.ts` contain no writes at all — not
even a rollback. `besluit-form.component.ts:88` holds the command
(`private submit = createSubmitBesluit()`), never the adapter. §3a records the highest FE
line coverage of any feature context here (91.6%), which is consistent with the split: the
read stores are trivially testable because nothing writes through them.
---
## apps/behandelportal — shell, root
**No findings.** Same as ssp/shell+root.
---
## libs/shared — application
### CQ-004 — `FeatureFlagStore.set` writes without the fold and drops the error entirely
- **Module / file:line** — `libs/shared/src/application/feature-flags.store.ts:53-59`;
adapter at `libs/shared/src/infrastructure/feature-flags.adapter.ts:18`; caller at
`libs/beheer/src/ui/feature-flags.page.ts:93`.
- **Extends** — `runSubmit`/`SUBMIT_FAILED` (`libs/shared/src/application/submit.ts`), which
lives in this very folder — the store sits three files away from the fold it skips.
- **Baseline citation** — **BL-007**; §7 Frontend "Mutations living inline in adapters | ~13"
(this is another case not in BL-007's enumeration, which named only `brief.adapter.ts`,
`org-template.adapter.ts` and `stamdata.adapter.ts`).
- **The mixing, concretely** — the store owns the read (`load`, `flags`, `all`, `enabled`)
and the admin write. The write is `try { await this.adapter.set(...) } finally { await this.load() }`
— **no `catch`**. The rejection propagates out of `set()`; the caller is
`void this.store.set(key, enabled)` (`feature-flags.page.ts:93`), so a failed toggle
becomes an unhandled promise rejection. The admin sees the switch flick back after the
`finally`'s reload with no explanation and no ProblemDetails `detail`, on a write gated by
`flags:manage` that is exactly the kind an operator needs confirmation of. Every other
write in the repo that goes through `runSubmit` gets `problemDetail(e, fallback)`.
- **Proposed change, minimal** — `const r = await runSubmit(() => this.adapter.set(key, enabled), SUBMIT_FAILED);
await this.load(); if (!r.ok) this.error.set(r.error);` with one `error` signal rendered by
`feature-flags.page.ts`. Keep the reload unconditional (it is the read-side invalidation and
is correct).
- **Effort** — S. Independently shippable; 2 files.
### Not filed — the rest of the layer
`access.store.ts` (query-only over `/me`), `remote-data.ts`, `store.ts`, `action-state.ts`,
`debounced-save.ts`, `history.ts`, `machine-remote-data.ts`, `pending-saves.ts`,
`session.port.ts` are the read/state kit itself, not services. `submit.ts` is the subject of
CQ-003 rather than a finding of its own.
---
## libs/shared — infrastructure
**No findings.** `me.adapter.ts` and `feature-flags.adapter.ts` are correctly
direction-labelled (`list` vs `set`); `api-client.provider.ts` is the single HTTP seam and
already gates the Idempotency-Key on `method !== 'GET'`, i.e. the _transport_ layer honours
the command/query split that CQ-003 shows the _application_ layer blurring. The CC-19 `fetch`
there is BL-001's "outside the idiom" case and belongs to agent 01.
---
## libs/shared — upload
**No findings.**
`upload.adapter.ts` mixes reads (`categoriesResource`, `status`) and writes (`xhrUpload`,
`deleteDocument`), and `upload-shell.service.ts` mixes `upload`/`delete`/`cancel` (commands)
with `pollReturning` (a query). It is tempting to file, and it is deliberately not: **BL-010**
records that this whole folder sits outside the layer convention by design and is carved out
by name in the `apiclient-infrastructure-only` dependency-cruiser rule. §7 lists no adapter
or command factory here to extend — `upload.adapter.ts` is explicitly footnoted as the
adapter that is _not_ in an `infrastructure/` folder. Resolving BL-010 (agent 03's call) has
to come first; a read/write split layered on top of an already-exceptional layout would
entrench the exception. Worth noting for whoever takes BL-010: `UploadShellService` is
otherwise the FE's most complete command implementation — every method takes a `Dispatch` and
reports its outcome as a Msg, which is the CLAUDE.md §3 shape done exactly right.
---
## libs/shared — domain, contracts, kernel, ui, layout, testing, environments
**No findings.** No application services, no adapters, no writes. `kernel/fp.ts`'s `Result`
is the return type the command side is built on, not a service.
---
## libs/beheer
### CQ-005 — both stamdata reads run through `runSubmit`, in a file that documents itself as write-free
- **Module / file:line** — `libs/beheer/src/infrastructure/stamdata.adapter.ts:27` (`list`)
and `:42` (`load`).
- **Extends** — the same `submit.ts` split proposed in CQ-003. **Fix them in one ticket**;
they are listed separately only because the module scope requires it.
- **Baseline citation** — **BL-007** (which names `stamdata.adapter.ts` explicitly among the
~13 "mutations living inline in adapters"); §7 Frontend "Infrastructure adapters (read
side) | 20".
- **The mixing, concretely** — sharper here than anywhere else in the repo, because the
file's own docstring (`:16-20`) says: _"Both endpoints are reads … There is no write method
— the edit is downloaded and lands as a PR."_ Both nevertheless call `runSubmit`. BL-007
counts this adapter on the write side of the inventory on the strength of that call, when
the module is in fact the repo's only genuinely CQRS-clean context: `StamdataStore` has no
write command at all (`download()` at `:137` is a local `Blob` + anchor click, zero
network), and `AuditStore` is read-only. The tooling and the baseline both mis-classify
this module purely because of the helper name.
- **Proposed change, minimal** — point both at `runResult` per CQ-003. Nothing else in this
library changes.
- **Effort** — S. Ships with CQ-003 in the same deploy.
### Not filed — `libs/beheer` application/domain/ui/contracts otherwise
`stamdata.store.ts` and `audit.store.ts` are query-only (see above). `stamdata-editor.machine.ts`
is a pure reducer. The `ui/` layer holds no adapter calls.
---
## backend — Program.cs
### CQ-006 — the read/write banner split is established, honoured once, then abandoned for 5 of 7 feature sections
- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs`. Banners at `:133`
("GET: screen-shaped reads"), `:185` ("POST: submits"), `:441` ("read side only") and
`:464` ("record a behandelaar's decision"). Mixed sections: `:197` Document upload,
`:275` Applications, `:598` Brief, `:721` Organization templates, and admin-cases split
non-contiguously across `:424`, `:554`, `:566`.
- **Extends** — the banner convention _inside this file_, specifically the WP-65 pair at
`:441`/`:464`, which already splits one feature's query endpoints from its command endpoint
under two banners. This proposal applies the `:441`/`:464` treatment to the five sections
that predate it. **No handler types, no mediator, no `Features/` folders** — see the
out-of-mandate section for why that larger move is not proposed here.
- **Baseline citation** — **BL-003** (940 lines, 48 endpoints, file CC 78 vs next-highest 27,
"read/write separated only by comment banner"); §7 Backend CQRS-light row ("Read/write
split exists as _comment banners_"); §3c `Program.cs` 97.4% line / 84.8% branch.
- **The mixing, concretely** — the file opens by declaring direction as its organising
principle (`:133` reads, `:185` writes), then from `:197` switches to feature grouping
without saying so, and every subsequent section interleaves:
| Section | Line | Reads | Writes |
| -------------------------------------------------------------------------------------------- | ------------: | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Document upload | 197 | `GET /uploads/categories`, `GET /uploads/{id}/content`, `GET /uploads/status` | `POST /uploads`, `DELETE /uploads/{id}`, `DELETE /admin/uploads/{id}` |
| Applications | 275 | `GET /applications`, `GET /applications/{id}` | `POST`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/submit` |
| Admin cases | 424, 554, 566 | `GET /admin/cases`, `GET /admin/audit` | `DELETE /admin/cases/{id}` — **129 lines away** from its list, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in between |
| Brief | 598 | `GET /brief`, `GET /brief/preview` | `PUT /brief`, `POST submit/approve/reject/send/reveal-bignummer/reset` |
| Org templates | 721 | `GET /admin/org-templates`, `GET /admin/org-template/{id}` | `PUT /{id}`, `POST /{id}/publish`, `POST /{id}/rollback` |
| `GET /admin/org-template/{subOrgId}/preview` (`:703`) is additionally filed under the Brief |
| banner rather than the Org-templates one. The consequence is not a bug — §3c confirms the |
| file is well tested, and this is a **structure finding, not a correctness one** — it is that |
| a reader cannot answer "what can mutate state here?" without reading all 940 lines, and that |
| the two cross-cutting write wrappers (`Submit`'s idempotency replay, `RecordZgwDivergence`) |
| have no visible scope. |
- **Proposed change, minimal** — within each existing feature section, order reads first then
writes and insert the `:441`/`:464`-style sub-banners; move `DELETE /admin/cases/{id}`
(`:554`) and `GET /admin/audit` (`:566`) up beside `GET /admin/cases` (`:424`); move the
org-template preview from the Brief section to the org-template one. **Pure reordering and
comments** — no signature, route, DTO or behaviour change, so §3c's 97.4%/84.8% coverage is
the regression net and the diff is reviewable line-for-line.
- **Effort** — S. Independently shippable in one deploy. One caveat for whoever schedules it:
it is a large-diff/zero-semantic-change commit, so land it alone, never mixed with a
behaviour change.
### CQ-007 — `GET /brief` creates a brief, though the explicit create command already exists
- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs:603`
(`api.MapGet("/brief", …)`) → `backend/src/BigRegister.Api/Data/BriefStore.cs:50`
(`GetOrCreate` — `db.Briefs.Add(created); db.SaveChanges();`).
- **Extends** — the command/query direction split that `Contracts/Dtos.cs` encodes
(`*Request` in / `*Dto` out) and that the banners at `:133`/`:185` state as the file's
premise; and concretely, **`POST /brief/reset` (`:712` → `BriefStore.ResetAndCreate`)** —
the create-a-fresh-brief command already exists as a POST. The write half of `GetOrCreate`
has a command counterpart; the query does not need to duplicate it.
- **Baseline citation** — **BL-003**; §7 Backend CQRS-light row.
- **The mixing, concretely** — this is the only endpoint in the backend where a GET performs
a persisted write. Everything else respects the direction, and notably the read side goes
out of its way to _avoid_ writing: `ToDetailDto(DateTimeOffset.UtcNow)` /
`ToDto(now)` project Concept → InBehandeling → Goedgekeurd from stored timestamps on every
read rather than mutating a status column (`Program.cs:284`, `Data/AanvraagMapper.cs`), which
is a textbook CQRS read-model projection and the strongest evidence the convention is
intended. `GET /brief` breaks it: a plain read is non-idempotent on first call, allocates a
row, and — since the FE retries GETs automatically (`api-client.provider.ts:66`,
`retry({ count: 2, delay: 500 })`, GET-only, precisely because GETs are assumed safe) —
a transient failure can enter the create path more than once. `BriefStore.GetOrCreate` is
`lock`-guarded so no duplicate row results today; the objection is that the safety depends
on the lock rather than on the endpoint being a query.
- **Proposed change, minimal** — `GET /brief` returns 404 when no brief exists for the owner;
`BriefStore.GetOrCreate` splits into `Get` (query) and the existing `ResetAndCreate` (already
there). `BriefStore.load()` on the FE (`brief.adapter.ts:55`) treats 404 by calling the
existing `reset()` command once. **This is the least certain finding in this file** and the
only one with a behaviour change: it costs one extra round-trip on a first visit and touches
the brief tests. If the demo-seeding convenience is judged to outweigh the principle, the
acceptable alternative is to leave the code alone and add one line at `:603` saying the GET
seeds on first call — the defect is as much that it is undocumented as that it exists.
- **Effort** — M. Independently shippable, but FE and BE must land together (the 404 contract),
so it is the one finding here that is not a single-side deploy.
---
## backend — Domain
**No findings.** `Domain/` is static classes of pure functions (`SubmissionRules`,
`IntakePolicy`, `BeoordelingRules`, `HerregistratieRule`, `OrgTemplateRules`, `Authz`,
`FeatureFlags`, `LetterHtml`, `DiplomaRules`, `DocumentRules`) with no persistence and no I/O —
verified EF-free and ASP-free per §7. A pure decision function has no read/write axis to
separate. `Domain/Applications/Aanvraag.cs`'s `Concept`/`Submitted`/`Decided` tagged union is
the write model; §3c records 94.2% line coverage.
---
## backend — Data
**No findings within mandate.** The seven static stores (`ApplicationStore`, `DocumentStore`,
`BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`)
each expose reads and writes on one type — `ApplicationStore` alone has 6 reads
(`Get`/`List`/`GetAny`/`GetByReferentie`/`ListAll` + `ToDetailDto`) and 7 writes. That is
ordinary repository design, and §7 records these as "Not behind any port … Deliberate,
documented in `Data/Db.cs`". Splitting them into read/write repositories would be
_introducing_ the pattern into a module where §7 records it absent — out of mandate. See the
out-of-mandate section. One observation to hand on rather than file: `AanvraagMapper` /
`ToDetailDto(now)` is a real read-model projection and is cited approvingly in CQ-007; do not
let a future ticket "simplify" it into a stored status column.
---
## backend — Zgw
**No findings.** `OpenZaakZaakSource` / `OpenZaakDocumentSource` implement ports whose
interfaces (`IZaakSource`, `IDocumentSource`) already separate by operation
(`ListMyCases`/`ListCases` vs `CreateZaak`), and §7 records the ACL as "Fully built" under
ADR-0005. §3c gives it the second-highest branch coverage in the backend (85.5%). Nothing to
extend.
---
## backend — Contracts
**No findings.** `Contracts/Dtos.cs`'s 65 records split by direction is the backend's
strongest CQRS-light artifact and is cited as the pattern several findings above extend.
§3c's 65.0% branch coverage (the backend's weakest, per **BL-005**) is agent 02's axis, not
this one — a DTO record has no read/write mixing to fix.
---
## backend — Stamdata
**No findings.** Config-as-code tables (ADR-0004), validated at build by
`StamdataValidationTests`, never runtime-editable — read-only by definition. Its only
endpoints (`Program.cs:164`, `:173`) are both GETs behind the `StamdataAdmin` gate, correctly
placed under the reads banner at `:158`. Together with `libs/beheer` (CQ-005) this is the
cleanest end-to-end query slice in the repo.
---
## Out of mandate (pattern absent)
Filed here rather than as tickets, per the "name the pattern you extend or don't file it"
rule. Agent 08 or a human decides whether any of these becomes a ticket.
**OOM-A — extracting `Program.cs` into `Features/` folders with handler types.**
This is the change BL-003 most obviously invites: 940 lines, 48 endpoints, file CC 78 against
a next-highest of 27. It is out of mandate because §7 is explicit that the backend has
**"No handler types, no mediator, no `Features/` folders"**, and the local helpers (`Submit`,
`StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) are authorization and
idempotency wrappers, not handlers — verified by reading them at `Program.cs:772-940`. There
is no CQRS-light structure here to extend, only one to introduce. CQ-006 is the largest step
available _within_ the mandate, and it deliberately stops at ordering and comments. Note also
that CQ-006 is a strict prerequisite for OOM-A should it ever be taken: you cannot cut a
940-line file into vertical slices while five of its seven sections interleave directions.
**OOM-B — read/write repository split in `backend/Data`.**
`ApplicationStore` (file CC 27, the highest in `Data`) and the six sibling stores each mix
reads and writes. Splitting them into query and command repositories would introduce the
pattern where §7 records it absent. It would also collide with the documented static/no-DI/
`Db.Create()`-per-call design (§7: "Deliberate, documented in `Data/Db.cs` and
`Program.cs:40-45`"), which agent 06 may have views on.
**OOM-C — no read model, no event sourcing, and none proposed.**
Stated explicitly so a later phase does not read this file as a step toward one. Baseline §7
records no separate read model or event store, so per the role definition neither is in scope.
The `ToDetailDto(now)` status projection is a read-side _derivation_, not a materialised read
model, and CQ-007 argues it should stay that way.
**OOM-D — BL-011 affects every acceptance criterion here.**
Not a finding, a scheduling note: the FE suite is flaky under parallel load, so "CI green"
alone does not verify CQ-001..005. Verify against §3a/§4a numbers, per **BL-009** (no
coverage threshold is enforced anywhere, so nothing ratchets).
---
## Summary
| ID | Module | Title | Extends | Baseline | Effort | One deploy? |
| ------ | ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | -------------- | ------ | ----------------------- |
| CQ-001 | ssp/registratie | `createDraftSync` is a command factory owning 3 query paths | the command-factory idiom (`submit-change-request.ts`) | §7, §4a, §9 | M | yes |
| CQ-002 | ssp/registratie | 2 read stores write without the `runSubmit` fold; errors dropped | `runSubmit` + `createSubmitChangeRequest` | BL-007, §7 | S | yes |
| CQ-003 | ssp/brief | `runSubmit` (write fold + idempotency mint) used for 3 reads | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes |
| CQ-004 | libs/shared/application | `FeatureFlagStore.set` skips the fold, drops the error entirely | `runSubmit`/`SUBMIT_FAILED` (same folder) | BL-007, §7 | S | yes |
| CQ-005 | libs/beheer | 2 stamdata reads run through `runSubmit`; ship with CQ-003 | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes (with CQ-003) |
| CQ-006 | backend/Program.cs | read/write banner split abandoned in 5 of 7 feature sections | the `:441`/`:464` WP-65 banner pair, in-file | BL-003, §7,§3c | S | yes — land it alone |
| CQ-007 | backend/Program.cs | `GET /brief` creates; `POST /brief/reset` already exists | direction split in `Contracts/Dtos.cs`; `POST /brief/reset` | BL-003, §7 | M | **no** — FE+BE together |
**Modules with no findings:** ssp/auth · ssp/herregistratie · ssp/showcase+shell+root ·
bhp/auth · bhp/behandeling (the reference implementation) · bhp/shell+root ·
libs/shared/{infrastructure, upload, domain, contracts, kernel, ui, layout, testing,
environments} · backend/{Domain, Data, Zgw, Contracts, Stamdata}.
**Suggested sequencing.** CQ-003 + CQ-005 are one ticket (one shared-file split, five call
sites) and should go first — they make the direction legible at every call site, which is
what CQ-002 and CQ-004 then apply consistently. CQ-006 is independent and can run in
parallel on the backend. CQ-001 is the only FE ticket with real design content. CQ-007 is
the only one needing a coordinated deploy and the only one whose premise is arguable —
schedule it last, or take its documentation-only alternative.
**Honest scale.** Six of seven findings are S/M and none is a correctness defect except
CQ-004's dropped error and CQ-002's silent rollback. Baseline §6 (0 dependency violations,
textbook instability gradient) and §3c (97.6% backend line coverage) are accurate: this is a
well-structured codebase, and the CQRS-light work available is consistency work, not repair.