docs(backend): correct IntakePolicy's false re-validation claim (WP-68 F5)
The doc-comment claimed "the backend re-validates on submit as the authority" — it doesn't. Neither SubmitApplicationRequest nor IntakeRequest carries a scholing answer at all, so there's nothing to re-validate; both submit paths only apply SubmissionRules.RejectZeroUren. A crafted POST can bypass the scholing requirement entirely. States the gap and points to WP-69 (opened, not yet planned) for the enforcement, which needs a wire change. Also strengthens the F2 concurrency test to assert the persisted status matches whichever request actually won the race, and updates WP-68's own Decisions/ acceptance-criteria text to reflect two implementation choices that improved on the original write-up once real constraints surfaced: ProcessingWindow stays on ApplicationStore (StatusAt is already in the same file), and AanvraagStatusTag is not given a Concept member (would have broken AanvraagStatusTag_covers_the_published_lifecycle) — AanvraagStatus.Tag is nullable instead, null exactly for Concept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
namespace BigRegister.Domain.Intake;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
|
||||
/// is required. The frontend receives this value and applies it for instant UX
|
||||
/// feedback, but the backend re-validates on submit as the authority.
|
||||
/// Config value (ADR-0001's "config value" shape). Below this many NL work-hours the
|
||||
/// scholing question is required. The frontend receives this value
|
||||
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
|
||||
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>).
|
||||
///
|
||||
/// WP-68 (F5): the class doc used to claim "the backend re-validates on submit as the
|
||||
/// authority" — it doesn't. Neither <c>SubmitApplicationRequest</c> nor <c>IntakeRequest</c>
|
||||
/// carries a scholing answer at all, so there is nothing for the server to re-validate;
|
||||
/// both submit paths only apply <c>SubmissionRules.RejectZeroUren</c>. A crafted POST can
|
||||
/// bypass the scholing requirement entirely. Enforcing this needs a wire change (the
|
||||
/// request DTOs must carry the wizard's scholing answer) and is deferred to WP-69 — this
|
||||
/// comment states the gap rather than a false guarantee.
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
|
||||
@@ -228,8 +228,15 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
PostBesluit(a.Id, new { besluit = "Goedkeuren" }),
|
||||
PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" }));
|
||||
|
||||
Assert.Single(results, r => r.StatusCode == HttpStatusCode.OK);
|
||||
var winner = Assert.Single(results, r => r.StatusCode == HttpStatusCode.OK);
|
||||
Assert.Single(results, r => r.StatusCode == HttpStatusCode.Conflict);
|
||||
|
||||
// The persisted outcome must match whichever request actually won the race, not just
|
||||
// "some" besluit — the loser's write must never have landed.
|
||||
var winningTag = (await winner.Content.ReadFromJsonAsync<RecordBesluitResponse>())!.Status.Tag;
|
||||
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
||||
var finalTag = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!.Aanvraag.Status.Tag;
|
||||
Assert.Equal(winningTag, finalTag);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -50,7 +50,7 @@ Gates land before the work they cover; each lint rule lands in the same WP as th
|
||||
for its existing violations, so every WP ends green.
|
||||
|
||||
| WP | Title | Phase | Status |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ----------- |
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||
@@ -118,7 +118,8 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
||||
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
||||
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | todo |
|
||||
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | in progress |
|
||||
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | todo |
|
||||
|
||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
|
||||
|
||||
Status: todo
|
||||
Status: in progress
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
@@ -84,18 +84,39 @@ 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`). Move
|
||||
`ApplicationStore.ProcessingWindow` there too — `StatusAt` needs it, and `Data → Domain` is
|
||||
the legal direction.
|
||||
2. **Add `Concept` as the first member of `AanvraagStatusTag`.** 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 record` carrying
|
||||
`AanvraagStatusTag Tag` 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)`,
|
||||
`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
|
||||
@@ -104,8 +125,10 @@ Pre-made — do not relitigate.
|
||||
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)`:
|
||||
`new(s.Tag.ToString(), s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
|
||||
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.
|
||||
@@ -196,23 +219,41 @@ member names are unchanged).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Submitting (or draft-syncing) an aanvraag with a `documentId` owned by another citizen is
|
||||
- [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`).
|
||||
- [ ] `AanvraagStatusTag` contains `Concept`; no code compares a status against a string
|
||||
literal. `grep -rn '"Concept"' backend/src` returns no comparison sites.
|
||||
- [ ] `Enum.Parse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at the
|
||||
`IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown tag.
|
||||
- [ ] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
|
||||
- [ ] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
|
||||
- [ ] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
|
||||
`libs/shared/src/infrastructure/api-client.ts` (proof F3 changed no wire shape).
|
||||
- [ ] Two concurrent `POST /beoordeling/{id}/besluit` against an already-terminal aanvraag yield
|
||||
exactly one 200 and one 409; the recorded besluit is the first one.
|
||||
- [ ] `BeoordelingRules.RequiresToelichting` exists, is unit-tested, and is the only place the
|
||||
rule lives.
|
||||
- [ ] A `[Theory]` covers the (status × besluit) transition table at domain level.
|
||||
- [ ] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists.
|
||||
(`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
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# WP-69 — Enforce the scholing threshold server-side
|
||||
|
||||
Status: todo
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
WP-68 (F5) found that `IntakePolicy`'s doc-comment claimed _"the backend re-validates on
|
||||
submit as the authority"_ — it doesn't. `GET /intake/policy` only echoes `ScholingThreshold`;
|
||||
neither `SubmitApplicationRequest` (`DiplomaHerkomst`, `Uren`, `Documents`) nor `IntakeRequest`
|
||||
(`Uren`) carries a scholing answer at all, so there's nothing for the server to re-validate.
|
||||
Both submit paths apply only `SubmissionRules.RejectZeroUren`. A crafted POST — bypassing the
|
||||
wizard entirely — can skip the scholing requirement (`scholingGevolgd`/`punten` in
|
||||
`intake.machine.ts`) even though it's presented as mandatory in the UI. ADR-0001's canonical
|
||||
"config value" example (the FE applies the threshold for instant feedback, the backend
|
||||
re-validates as authority) is unenforced for the one rule it was written to illustrate.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs` (the corrected doc-comment,
|
||||
WP-68)
|
||||
- [ADR-0001 — BFF-lite + decision DTOs](../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
§"config value"
|
||||
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (`lageUren`, `scholingGevolgd`,
|
||||
`punten` — the wizard's existing FE-side rule and its answers)
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` (`SubmitApplicationRequest`,
|
||||
`IntakeRequest`, `DocumentRefDto`)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — the `intakes` and `applications/{id}/submit`
|
||||
endpoints
|
||||
|
||||
## Decisions
|
||||
|
||||
Not yet made — this is a placeholder WP opened by WP-68, not a ready-to-implement one. Needs
|
||||
a `planner` pass before work starts. Open questions to resolve then:
|
||||
|
||||
- The request DTOs need a scholing answer field (likely mirroring `intake.machine.ts`'s
|
||||
`ValidIntake.aanvullendeScholing`/`punten`) — this is a wire change, so it touches
|
||||
`contracts/`, the wizard's submit payload, and `npm run gen:api`.
|
||||
- Whether to add the rule to `SubmissionRules` (alongside `RejectZeroUren`) or give
|
||||
`IntakePolicy` its own `RejectMissingScholing(uren, scholing)`, matching the class that
|
||||
already owns the threshold.
|
||||
- Reading the answer out of the wizard's `Draft` JSON was rejected in WP-68 — the backend's
|
||||
documented posture is that the draft is opaque (`AppDbContext`'s header comment) — so the
|
||||
answer must arrive as an explicit request field, not be extracted from the opaque snapshot.
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
Implementation — this WP exists to track the gap; do not implement without a Decisions block.
|
||||
Reference in New Issue
Block a user