Files
atomic-design-poc/docs/project/archive/backlog/WP-68-ddd-aggregate-hardening.md
T
ehoandClaude Opus 5 12f17d9d73 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>
2026-09-08 23:00:38 +02:00

338 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
Status: done (a394950..472a49f)
Phase: 12 — DDD hardening
## Why
An architecture review on 2026-08-05 (bounded contexts, aggregates, CQRS, DDD/BDD test
alignment, measured against this repo's own documented pattern) found the context boundaries,
the FP/TEA idioms and the read/write separation to be sound — and found four defects clustered
in one place: **the backend's aggregate roots do not guard their own invariants, and the
aanvraag status lifecycle is a computed string living in the contracts layer.**
The four in this WP, in dependency order:
- **F1 — `submit` links client-supplied `documentId`s with no ownership check.**
`Program.cs:353-356` takes document ids straight from the request body and hands them to
`ApplicationStore.Submit` and `documents.LinkToZaak`; `DocumentStore.Link` has no `owner`
parameter and performs no check (`DocumentStore.cs:113-125`). Same for `SyncDraft`
(`Program.cs:317`). A caller who knows a foreign document GUID can attach another citizen's
upload to their own aanvraag — where it appears on the behandelaar's beoordeling screen with
its filename (`Program.cs:434`) and is POSTed to OpenZaak as a zaakinformatieobject on
_their_ zaak — and flips the victim's `Linked = true`, which permanently blocks the victim's
own delete (`DeleteOwned``DeleteResult.Linked`). ADR-0001 is explicit that the FE holds
no authority; this trusts it anyway.
- **F3 — the aanvraag status lifecycle is a computed string in `Contracts/`.** Three
compounding facts: the status is derived in `Contracts/Mappers.ToStatusDto`
(`Mappers.cs:44-63`), not in the domain; `Concept` is **not** a member of
`AanvraagStatusTag` (`ApplicationStore.cs:14`) but a magic string the mapper emits; and the
write path reads its own guard back out of the read DTO —
`a.ToStatusDto(now).Tag` → compare `"Concept"``Enum.Parse<AanvraagStatusTag>`
(`Program.cs:466-468`). This violates the repo's non-negotiable #3 ("make illegal states
unrepresentable") on the backend's most important type: the status is
`enum + one string that is not in the enum`, so `Enum.Parse` is a runtime throw waiting for
a new tag. It is also the one genuine CQRS symptom in the codebase — a command deriving its
invariant from a read projection — and it is _why_ F2 exists: there is no domain object that
could have owned the guard.
- **F2 — the besluit invariant is checked outside the write transaction.**
`Program.cs:469` calls `BeoordelingRules.CanDecide`; the write happens later in
`ApplicationStore.RecordBesluit` (`ApplicationStore.cs:278-291`), which takes the lock and
assigns unconditionally. Two concurrent besluiten both pass the check and both write, so the
second silently overwrites a terminal decision the rule exists to freeze. The codebase
already documents the correct pattern three methods earlier — `CreateConcept`: _"Race-free:
the existence check and the insert share the single write gate."_ This is an internal
inconsistency, not a missing concept.
- **F6 — a besluit rule with no home in `Domain/`.** "Toelichting verplicht bij Afwijzen /
MeerInfoOpvragen" lives inline at `Program.cs:473`, although `BeoordelingRules`' own
doc-comment says the decision-recording rules were meant to land there. It therefore has no
unit test, only the endpoint test `Afwijzen_requires_a_toelichting`.
Plus one documentation correction (**F5**, see Decisions — the enforcement itself is deferred
to WP-69, because it needs a wire change).
The review's remaining findings are listed under "Follow-ups" and are **not** this WP's scope.
## Read first
- `CLAUDE.md` §"The decisions" #3 (make illegal states unrepresentable) and #4 (BFF-lite)
- [ADR-0001 — BFF-lite + decision DTOs](../../../reference/architecture/0001-bff-lite-decision-dtos.md)
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (the `Aanvraag` entity, the store's
lock discipline, `AanvraagStatusTag`, `RecordBesluit`)
- `backend/src/BigRegister.Api/Contracts/Mappers.cs` (`ToStatusDto` — the logic to move)
- `backend/src/BigRegister.Api/Program.cs` lines 300-500 (draft sync, submit, beoordeling GET,
besluit POST)
- `backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs` (**the second producer of the status
DTO** — easy to miss)
- `backend/src/BigRegister.Api/Data/DocumentStore.cs` (`Link`, `DeleteOwned`, the existing
`DeleteResult` enum this WP copies)
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`
## Prerequisite
**Commit or stash the working tree first.** At review time it carried the WP-66 id-mismatch fix
across 11 modified files plus the untracked `backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs`.
Do not start a cross-cutting refactor on top of uncommitted work.
## Decisions
Pre-made — do not relitigate.
### F3 — the status type
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`).
**`ApplicationStore.ProcessingWindow` stays where it is.** The original text here said to
move it too "because `StatusAt` needs it" — but `StatusAt` is an instance method on
`Aanvraag`, itself defined in `ApplicationStore.cs`, so it already sits in the same file/
namespace as `ProcessingWindow` and can reference it directly with no cross-namespace
issue. Moving it would have been motion without a reason, and — found only once
implementation started — `ApplicationTests.cs` references `ApplicationStore.ProcessingWindow`
directly in two tests this WP's own acceptance criteria require to stay **unmodified**;
moving the constant would have forced a choice between breaking that criterion or adding a
forwarding shim for no gain. Leave it.
2. **`AanvraagStatusTag` is NOT given a `Concept` member — implemented differently, deliberately.**
The original text said to add `Concept` as the first member. That directly conflicts with
this WP's own acceptance criterion that `AanvraagStatusTag_covers_the_published_lifecycle`
(which asserts `Enum.GetNames<AanvraagStatusTag>()` equals exactly the five published-lifecycle
names) passes **unmodified** — adding a sixth name breaks it. Found only once implementation
started; resolved in favor of the harder constraint (the regression-net test) and a cleaner
design: **`AanvraagStatus.Tag` is `AanvraagStatusTag?`, null exactly for Concept.** This
still closes the actual finding (a magic string with no corresponding enum member,
round-tripped through the DTO and `Enum.Parse`d) without touching the enum the test pins,
and without the reduce-only "boolean + tag" shape rule #3 warns against — a nullable
discriminator is the standard two-case union, not a second boolean bolted on. `Ingediend`
is unaffected by this and is still kept reserved (see below).
Keep `Ingediend` even though nothing produces it today (verified: neither `ToStatusDto` nor
`ZgwZaakMapper` emits it) — `BeoordelingRules.CanDecide` accepts it, the FE's
`BeoordelingStatus` union declares it, `statusLabel` has a `$localize` id for it, and
`Only_open_statuses_are_decidable` tests it. Deleting it would ripple into
`messages.en.xlf`. Mark it reserved with a comment instead.
3. **New `Domain/Applications/AanvraagStatus.cs`**: a `sealed class` (not a `record` — no
external mutation via `with` is wanted, and record value-equality/`ToString` boilerplate
buys nothing for a short-lived read model) carrying `AanvraagStatusTag? Tag` (null =
Concept) plus the same optional payload fields the DTO has (`StepIndex`, `StepCount`,
`Referentie`, `Manual`, `Reden`), constructed **only** via static factories —
`Concept(stepIndex, stepCount)`, `InBehandeling(referentie, manual)`,
`Goedgekeurd(referentie)`, `Afgewezen(referentie, reden)`,
`MeerInfoGevraagd(referentie, reden)`.
**Rejected: a full abstract-record union** (one subrecord per tag). It is the purer
modelling, but it forces exhaustive switches at four call sites and a per-case mapper for a
marginal gain over "the factories are the only construction path". Not worth the diff here.
4. **`Aanvraag.StatusAt(DateTimeOffset now)`** — an instance method on the entity carrying the
logic currently in `ToStatusDto` **verbatim**, including the "a recorded decision wins over
the auto-approve computation" ordering.
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`, via a shared
`Mappers.ToDto(this AanvraagStatus s)` extension (also used by `ZgwZaakMapper` — see below,
point 7 — so both status producers agree on one projection):
`new(s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
6. **`AanvraagStatusDto` is unchanged — `Tag` stays a `string`.** This is the safety property
that makes F3 an internal refactor: **no wire change, no `gen:api` drift, no frontend
change, no `messages.en.xlf` change.** Do not "improve" the DTO in this WP.
7. **`ZgwZaakMapper` is the second producer** and must be converted too, or the string literals
survive: `ToSummaryDto` and `ToCreatedStatusDto` build `AanvraagStatus` via the factories and
project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →
`InBehandeling` with `Manual: true`; closed → `Goedgekeurd`) — `ZgwZaakMapperTests` is the net.
8. **The besluit endpoint stops going through the DTO**: `var status = a.StatusAt(now);`
compare `status.Tag == AanvraagStatusTag.Concept`, pass `status.Tag` to `CanDecide`. The
`Enum.Parse` at `Program.cs:468` is deleted.
9. **One `Enum.Parse` may remain** — the beoordeling GET at `Program.cs:438`, which parses a tag
off a DTO returned by the `IZaakSource` seam. That is a genuine wire→domain trust boundary,
not a smell. Keep exactly one, make it non-throwing for an unknown tag, and comment it as the
seam boundary. **Changing `IZaakSource` to return domain types is out of scope.**
### F2 — the besluit guard
`ApplicationStore.RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)`
returns `(RecordBesluitOutcome Outcome, Aanvraag? Aanvraag)` with
`enum RecordBesluitOutcome { Ok, NotFound, Conflict }` — mirroring the existing
`DocumentStore.DeleteResult` precedent rather than inventing a new result idiom. Inside the
lock: find, `StatusAt(now)`, `CanDecide``Conflict` if refused, then write. **The endpoint
drops its own pre-check** and maps the outcome to 200/404/409, so there is one source of truth
for the transition. The endpoint keeps its id-resolution and its `Concept` → 404 (both need the
`IZaakSource` lookup the store cannot see).
### F1 — document ownership
New `DocumentStore.ForeignIds(IEnumerable<string> ids, string owner)` returning the ids that do
**not** resolve to a document owned by `owner` (returning the offending ids, not a bool, so the
ProblemDetails can name them). Called in `POST /applications/{id}/submit` **before** any write,
and in the draft-sync endpoint (`Program.cs:317`); non-empty → 400 ProblemDetails.
Endpoint-level check only. `IDocumentSource.LinkToZaak` keeps its current signature (two
implementations, and the endpoint has now validated its input) — add a comment saying so.
"A document already linked to a different aanvraag of the same owner" is **not** covered here;
note it as a follow-up, do not build it.
### F5 — narrowed to a doc fix
`IntakePolicy`'s XML doc-comment claims _"the backend re-validates on submit as the
authority"_. It does not: the constant's only consumer is `Program.cs:155`, which echoes it, and
both submit paths apply `SubmissionRules.RejectZeroUren` only. Verified cause: **neither
`SubmitApplicationRequest(DiplomaHerkomst, Uren, Documents)` nor `IntakeRequest(int Uren)`
carries a scholing answer at all**, so the server cannot re-validate without a contract change,
and the wizard's answers (`scholingGevolgd`, `punten``intake.machine.ts:26,37`) never reach
it. Reading them out of the opaque `Draft` JSON is rejected: the backend's documented posture is
that the draft is opaque (`AppDbContext` header comment).
**In this WP: correct the doc-comment to state the gap, and nothing else.** The enforcement is
WP-69 (a real FE+BE slice: request fields, `IntakePolicy.RejectMissingScholing`, wizard payload,
`gen:api`).
## Files
- `Domain/Applications/AanvraagStatus.cs` (new — tag enum, `Besluit`, `ProcessingWindow`, the
status record + factories)
- `Data/ApplicationStore.cs` (`Aanvraag.StatusAt`, `RecordBesluit` signature + in-lock guard,
enums moved out)
- `Contracts/Mappers.cs` (`ToStatusDto` reduced to a projection)
- `Zgw/ZgwZaakMapper.cs` (both producers converted)
- `Data/DocumentStore.cs` (`ForeignIds`)
- `Domain/Beoordeling/BeoordelingRules.cs` (`RequiresToelichting`)
- `Domain/Intake/IntakePolicy.cs` (doc-comment only)
- `Program.cs` (submit + draft-sync ownership checks; besluit endpoint simplified)
- `tests/BigRegister.Tests/``RuleTests.cs` (new `AanvraagStatusTests` nested class +
`RequiresToelichting`), `ApplicationTests.cs` (ownership), `BeoordelingTests.cs` (concurrency)
No migration: no persisted column changes (`BesluitStatus` already stores `Besluit`, whose
member names are unchanged).
## Steps
1. Commit/stash the WP-66 working tree (see Prerequisite).
2. **F1**`DocumentStore.ForeignIds` + the two endpoint checks + tests. Independent of the
rest; land it first so the correctness fix is not blocked by the refactor.
3. **F3** — the status type, in Decisions order 1→9. `dotnet test` green with
`AanvraagStatusTag_covers_the_published_lifecycle`,
`AutoApprovable_flips_to_goedgekeurd_after_the_window` and `ZgwZaakMapperTests` **unchanged**
— those three are the regression net for the refactor.
4. **F2**`RecordBesluitOutcome`, guard moved inside the lock, endpoint maps the outcome.
5. **F6**`BeoordelingRules.RequiresToelichting` + unit test; endpoint calls it.
6. **T3** — the lifecycle spec that F3 makes expressible: one `[Theory]` over
(status × besluit) → allowed/denied, asserting among others that Afgewezen → Goedgekeurd is
refused as a _domain_ statement, not only at the endpoint.
7. **F5** — correct the `IntakePolicy` doc-comment; open WP-69 for the enforcement.
8. Run the full gate (see Verification).
## Acceptance criteria
- [x] Submitting (or draft-syncing) an aanvraag with a `documentId` owned by another citizen is
rejected with 400, and the other citizen's document remains deletable
(`DeleteResult.Ok`). (`Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner`,
`Draft_sync_with_a_foreign_documentId_is_rejected`.)
- [x] `AanvraagStatusTag` does NOT contain `Concept` — implemented instead as
`AanvraagStatus.Tag` being `AanvraagStatusTag?`, null exactly for Concept (see Decisions
§F3.2 for why this replaced the original "add Concept to the enum" instruction). No
_internal domain_ code compares a status against the `"Concept"` string; the one
remaining comparison (`Program.cs`'s beoordeling GET, against `IZaakSource`'s wire DTO)
is the deliberate wire-boundary exception, paired with the one allowed `Enum.TryParse`
below.
- [x] `Enum.Parse`/`TryParse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at
the `IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown
tag (`Enum.TryParse` there, not `Enum.Parse`).
- [x] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
- [x] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
- [x] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
`libs/shared/src/infrastructure/api-client.ts` beyond F1's new 400 responses (verified —
the only diff after F3 is the two `.ProducesProblem(400)` blocks F1 added; proof F3
changed no wire shape).
- [x] Two concurrent `POST /beoordeling/{id}/besluit` racing on the same still-open aanvraag
yield exactly one 200 and one 409; the persisted status matches whichever request won
(`Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success`, stable across 5
repeated runs).
- [x] `BeoordelingRules.RequiresToelichting` exists, is unit-tested
(`Only_a_non_approval_requires_a_toelichting`), and is the only place the rule lives.
- [x] A `[Theory]`/aggregate-level test covers the transition table
(`A_terminal_decision_refuses_any_further_besluit`,
`MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal` — via
`Aanvraag.StatusAt` + `BeoordelingRules.CanDecide`, not just a bare-tag `[Theory]`, since
`CanDecide` doesn't vary by which besluit is attempted — see Decisions for why a literal
status×besluit cross-product theory would have been redundant with
`Only_open_statuses_are_decidable`).
- [x] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists
(`docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md`).
## Verification
```bash
cd backend && dotnet test # while iterating
npm run gen:api && git diff --exit-code backend/swagger.json libs/shared/src/infrastructure/api-client.ts
npm run ci # the full gate before pushing
npm run e2e # after F1/F2/F3 — needs the backend + `npm start` running
```
The three existing tests named in step 3 must pass **unmodified**; if a refactor step needs one
of them changed, the refactor changed behaviour and is wrong.
**Result:** `npm run ci` passed fully green — lint, format:check, check:tokens, all four test
suites, both localized builds, `npm audit`, backend `dotnet format`+`dotnet test` (216 passing,
up from 207 at the start of this WP), snippet-generator drift, and API-client drift (only F1's
new 400 responses; F3 shows zero additional wire diff, per acceptance criteria). `npm run e2e`
could **not** be verified in this session: port 4200 was already occupied by an unrelated
container (`team-monitor-web-1`, a different repo) that Playwright's local `reuseExistingServer`
reused as if it were this app, so every test timed out waiting for a `BSN` field that container
doesn't have — a pre-existing local port collision, not a regression (nothing in this WP touches
ports/docker), and per CLAUDE.md's GREEN definition `npm run e2e` isn't part of the local GREEN
gate regardless. Free port 4200 (or set `E2E_BASE_URL`) and re-run `npm run e2e` to close this
out if end-to-end confirmation is wanted.
## Out of scope
Deliberately excluded — each is a separate WP if wanted:
- **F4** — backend layer enforcement. `Domain/Beoordeling/BeoordelingRules.cs` and
`Domain/Authorization/Authz.cs` import `BigRegister.Api.Data` (and `Authz` also
`.Contracts`, returning `BriefDecisionsDto`), with nothing in CI checking direction — the FE
has `dep:check`, the backend has only `dotnet format` + `dotnet test`. This WP's step 3
removes the `BeoordelingRules` violation as a side effect; the `Authz` one and the ~6-line
reflection convention test are WP-70.
- **F5 enforcement** → WP-69 (see Decisions).
- **F7** — `ApplicationStore.Submit` and `DocumentStore.Link` take separate locks with no
transaction and no compensation; a link failure leaves a submitted aanvraag whose documents
are still deletable. Same failure class WP-60 closed for ZGW and left open locally. Fix is to
route it through the existing divergence flag + audit row, not to merge the aggregates.
- **F8** — pushing invariants from the static stores onto `Aanvraag` as instance methods
(`TryRecordBesluit`). This WP does the two that matter; the general move can wait.
- **F9** — `Authz` spans five contexts and its four admin gates are byte-identical
`role == Admin` checks with **no direct unit test** and no test denying `Approver`.
- **F10** — splitting `Program.cs` (917 lines, 50 endpoints). **Deliberately deferred and
flagged as risky:** `OrgAdmin`, `StamdataAdmin`, `Beoordelen`, `Submit` and `AuditAuthz` are
non-static **local functions** (`Program.cs:756+`) that every endpoint lambda closes over, so
splitting means converting all of them to static helpers with explicit dependencies across
all 50 registrations — with the deliberate authz ordering (Forbidden before Conflict) as the
thing that breaks silently. Lowest value of the review's findings; do it alone, with tests as
the net, or not at all.
- **F11** — three FE adapter fetch idioms; two loaders `throw` instead of returning `Result`;
`runSubmit` (which mints an `Idempotency-Key`) is used for **reads** in `brief.adapter.ts:56`,
`org-template.adapter.ts:39,51`, `stamdata.adapter.ts:27,42`. Fix is `runQuery`/`runCommand`
over one shared try/catch, ~10 lines.
- **T2** — ~54 FE `it()` titles are named after `Msg` tags (`'SetField updates the draft'`,
`'SubmitConfirmed maps Submitting to Submitted'`), against `bdd.mdx` rule 3. Titles only.
- **T5** — named coverage gaps: `OrgTemplateRules.RejectDraft` (both identity branches, no
margin boundary test), the four `Authz` admin gates, `DocumentRules.CategoriesFor`'s
`herregistratie`/`org-template` branches, `SubmissionRules.NewReference`, FE
`isStatusConsistent` (tested on the backend, never on the FE), the FE herregistratie window
boundary, and the FE/BE margin constants which mirror each other with no contract test.
- **T6** — trust-boundary `describe` naming has three dialects; 7 `parse*` specs use none.
- **ADR-0006 "CQS without CQRS"** — the review's learning deliverable: the read/write
separation already present, why the emit-and-enforce rule (one function feeding both the
decision flag and the enforcement) makes a read/write stack split actively harmful here, and
WP-60's deferred outbox as the documented trigger that would change the answer. Prose only,
no runtime code.
- Anything CQRS-mechanical: MediatR, handler classes, a separate read store, event sourcing,
repositories/unit-of-work, Gherkin/Reqnroll. All explicitly rejected by the review.
## Risks
- **Scope creep on F3.** The temptation is to "fix" `AanvraagStatusDto` into a proper wire union
while in there. That turns a zero-diff internal refactor into an FE + `messages.en.xlf` +
`gen:api` change. The acceptance criterion "`gen:api` leaves no diff" exists to catch it.
- **Missing the second producer.** `ZgwZaakMapper` is easy to overlook because it lives under
`Zgw/`, not `Contracts/`. If it is missed, the string literals survive and the finding is only
half fixed.
- **Over-modelling.** A full abstract-record status union, or a repository/unit-of-work layer to
"properly" own the aggregate, would be a bigger diff than the defects justify — see Decisions.