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>
22 KiB
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 —
submitlinks client-supplieddocumentIds with no ownership check.Program.cs:353-356takes document ids straight from the request body and hands them toApplicationStore.Submitanddocuments.LinkToZaak;DocumentStore.Linkhas noownerparameter and performs no check (DocumentStore.cs:113-125). Same forSyncDraft(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'sLinked = 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 inContracts/Mappers.ToStatusDto(Mappers.cs:44-63), not in the domain;Conceptis not a member ofAanvraagStatusTag(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 isenum + one string that is not in the enum, soEnum.Parseis 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:469callsBeoordelingRules.CanDecide; the write happens later inApplicationStore.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 atProgram.cs:473, althoughBeoordelingRules' own doc-comment says the decision-recording rules were meant to land there. It therefore has no unit test, only the endpoint testAfwijzen_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
backend/src/BigRegister.Api/Data/ApplicationStore.cs(theAanvraagentity, the store's lock discipline,AanvraagStatusTag,RecordBesluit)backend/src/BigRegister.Api/Contracts/Mappers.cs(ToStatusDto— the logic to move)backend/src/BigRegister.Api/Program.cslines 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 existingDeleteResultenum 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
- Move
AanvraagStatusTagandBesluitout ofData/ApplicationStore.csintoDomain/Applications/(namespaceBigRegister.Domain.Applications).ApplicationStore.ProcessingWindowstays where it is. The original text here said to move it too "becauseStatusAtneeds it" — butStatusAtis an instance method onAanvraag, itself defined inApplicationStore.cs, so it already sits in the same file/ namespace asProcessingWindowand 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.csreferencesApplicationStore.ProcessingWindowdirectly 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. AanvraagStatusTagis NOT given aConceptmember — implemented differently, deliberately. The original text said to addConceptas the first member. That directly conflicts with this WP's own acceptance criterion thatAanvraagStatusTag_covers_the_published_lifecycle(which assertsEnum.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.TagisAanvraagStatusTag?, null exactly for Concept. This still closes the actual finding (a magic string with no corresponding enum member, round-tripped through the DTO andEnum.Parsed) 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.Ingediendis unaffected by this and is still kept reserved (see below). KeepIngediendeven though nothing produces it today (verified: neitherToStatusDtonorZgwZaakMapperemits it) —BeoordelingRules.CanDecideaccepts it, the FE'sBeoordelingStatusunion declares it,statusLabelhas a$localizeid for it, andOnly_open_statuses_are_decidabletests it. Deleting it would ripple intomessages.en.xlf. Mark it reserved with a comment instead.- New
Domain/Applications/AanvraagStatus.cs: asealed class(not arecord— no external mutation viawithis wanted, and record value-equality/ToStringboilerplate buys nothing for a short-lived read model) carryingAanvraagStatusTag? 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. Aanvraag.StatusAt(DateTimeOffset now)— an instance method on the entity carrying the logic currently inToStatusDtoverbatim, including the "a recorded decision wins over the auto-approve computation" ordering.Mappers.ToStatusDtobecomes a one-line projection ofa.StatusAt(now), via a sharedMappers.ToDto(this AanvraagStatus s)extension (also used byZgwZaakMapper— 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).AanvraagStatusDtois unchanged —Tagstays astring. This is the safety property that makes F3 an internal refactor: no wire change, nogen:apidrift, no frontend change, nomessages.en.xlfchange. Do not "improve" the DTO in this WP.ZgwZaakMapperis the second producer and must be converted too, or the string literals survive:ToSummaryDtoandToCreatedStatusDtobuildAanvraagStatusvia the factories and project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →InBehandelingwithManual: true; closed →Goedgekeurd) —ZgwZaakMapperTestsis the net.- The besluit endpoint stops going through the DTO:
var status = a.StatusAt(now);comparestatus.Tag == AanvraagStatusTag.Concept, passstatus.TagtoCanDecide. TheEnum.ParseatProgram.cs:468is deleted. - One
Enum.Parsemay remain — the beoordeling GET atProgram.cs:438, which parses a tag off a DTO returned by theIZaakSourceseam. 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. ChangingIZaakSourceto 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,RecordBesluitsignature + in-lock guard, enums moved out)Contracts/Mappers.cs(ToStatusDtoreduced 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(newAanvraagStatusTestsnested class +RequiresToelichting),ApplicationTests.cs(ownership),BeoordelingTests.cs(concurrency)
No migration: no persisted column changes (BesluitStatus already stores Besluit, whose
member names are unchanged).
Steps
- Commit/stash the WP-66 working tree (see Prerequisite).
- 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. - F3 — the status type, in Decisions order 1→9.
dotnet testgreen withAanvraagStatusTag_covers_the_published_lifecycle,AutoApprovable_flips_to_goedgekeurd_after_the_windowandZgwZaakMapperTestsunchanged — those three are the regression net for the refactor. - F2 —
RecordBesluitOutcome, guard moved inside the lock, endpoint maps the outcome. - F6 —
BeoordelingRules.RequiresToelichting+ unit test; endpoint calls it. - 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. - F5 — correct the
IntakePolicydoc-comment; open WP-69 for the enforcement. - Run the full gate (see Verification).
Acceptance criteria
- Submitting (or draft-syncing) an aanvraag with a
documentIdowned 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.) AanvraagStatusTagdoes NOT containConcept— implemented instead asAanvraagStatus.TagbeingAanvraagStatusTag?, 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, againstIZaakSource's wire DTO) is the deliberate wire-boundary exception, paired with the one allowedEnum.TryParsebelow.Enum.Parse/TryParse<AanvraagStatusTag>appears at most once inbackend/src, at theIZaakSourceseam (Program.csbeoordeling GET), and does not throw on an unknown tag (Enum.TryParsethere, notEnum.Parse).Mappers.ToStatusDtocontains no lifecycle logic — it projectsAanvraag.StatusAt(now).ZgwZaakMapperconstructs noAanvraagStatusDtofrom string literals.npm run gen:apileaves no diff inbackend/swagger.jsonorlibs/shared/src/infrastructure/api-client.tsbeyond 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).- Two concurrent
POST /beoordeling/{id}/besluitracing 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). BeoordelingRules.RequiresToelichtingexists, is unit-tested (Only_a_non_approval_requires_a_toelichting), and is the only place the rule lives.- 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— viaAanvraag.StatusAt+BeoordelingRules.CanDecide, not just a bare-tag[Theory], sinceCanDecidedoesn't vary by which besluit is attempted — see Decisions for why a literal status×besluit cross-product theory would have been redundant withOnly_open_statuses_are_decidable). 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
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.csandDomain/Authorization/Authz.csimportBigRegister.Api.Data(andAuthzalso.Contracts, returningBriefDecisionsDto), with nothing in CI checking direction — the FE hasdep:check, the backend has onlydotnet format+dotnet test. This WP's step 3 removes theBeoordelingRulesviolation as a side effect; theAuthzone and the ~6-line reflection convention test are WP-70. - F5 enforcement → WP-69 (see Decisions).
- F7 —
ApplicationStore.SubmitandDocumentStore.Linktake 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
Aanvraagas instance methods (TryRecordBesluit). This WP does the two that matter; the general move can wait. - F9 —
Authzspans five contexts and its four admin gates are byte-identicalrole == Adminchecks with no direct unit test and no test denyingApprover. - F10 — splitting
Program.cs(917 lines, 50 endpoints). Deliberately deferred and flagged as risky:OrgAdmin,StamdataAdmin,Beoordelen,SubmitandAuditAuthzare 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
throwinstead of returningResult;runSubmit(which mints anIdempotency-Key) is used for reads inbrief.adapter.ts:56,org-template.adapter.ts:39,51,stamdata.adapter.ts:27,42. Fix isrunQuery/runCommandover one shared try/catch, ~10 lines. - T2 — ~54 FE
it()titles are named afterMsgtags ('SetField updates the draft','SubmitConfirmed maps Submitting to Submitted'), againstbdd.mdxrule 3. Titles only. - T5 — named coverage gaps:
OrgTemplateRules.RejectDraft(both identity branches, no margin boundary test), the fourAuthzadmin gates,DocumentRules.CategoriesFor'sherregistratie/org-templatebranches,SubmissionRules.NewReference, FEisStatusConsistent(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
describenaming has three dialects; 7parse*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"
AanvraagStatusDtointo a proper wire union while in there. That turns a zero-diff internal refactor into an FE +messages.en.xlf+gen:apichange. The acceptance criterion "gen:apileaves no diff" exists to catch it. - Missing the second producer.
ZgwZaakMapperis easy to overlook because it lives underZgw/, notContracts/. 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.