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;
|
namespace BigRegister.Domain.Intake;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
|
/// Config value (ADR-0001's "config value" shape). Below this many NL work-hours the
|
||||||
/// is required. The frontend receives this value and applies it for instant UX
|
/// scholing question is required. The frontend receives this value
|
||||||
/// feedback, but the backend re-validates on submit as the authority.
|
/// (<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>
|
/// </summary>
|
||||||
public static class IntakePolicy
|
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 = "Goedkeuren" }),
|
||||||
PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" }));
|
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);
|
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
|
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.
|
for its existing violations, so every WP ends green.
|
||||||
|
|
||||||
| WP | Title | Phase | Status |
|
| WP | Title | Phase | Status |
|
||||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ----------- |
|
||||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
| [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-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 |
|
| [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-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-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-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);
|
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
|
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)
|
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
|
||||||
|
|
||||||
Status: todo
|
Status: in progress
|
||||||
Phase: 12 — DDD hardening
|
Phase: 12 — DDD hardening
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -84,18 +84,39 @@ Pre-made — do not relitigate.
|
|||||||
### F3 — the status type
|
### F3 — the status type
|
||||||
|
|
||||||
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
|
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
|
||||||
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`). Move
|
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`).
|
||||||
`ApplicationStore.ProcessingWindow` there too — `StatusAt` needs it, and `Data → Domain` is
|
**`ApplicationStore.ProcessingWindow` stays where it is.** The original text here said to
|
||||||
the legal direction.
|
move it too "because `StatusAt` needs it" — but `StatusAt` is an instance method on
|
||||||
2. **Add `Concept` as the first member of `AanvraagStatusTag`.** Keep `Ingediend` even though
|
`Aanvraag`, itself defined in `ApplicationStore.cs`, so it already sits in the same file/
|
||||||
nothing produces it today (verified: neither `ToStatusDto` nor `ZgwZaakMapper` emits it) —
|
namespace as `ProcessingWindow` and can reference it directly with no cross-namespace
|
||||||
`BeoordelingRules.CanDecide` accepts it, the FE's `BeoordelingStatus` union declares it,
|
issue. Moving it would have been motion without a reason, and — found only once
|
||||||
`statusLabel` has a `$localize` id for it, and `Only_open_statuses_are_decidable` tests it.
|
implementation started — `ApplicationTests.cs` references `ApplicationStore.ProcessingWindow`
|
||||||
Deleting it would ripple into `messages.en.xlf`. Mark it reserved with a comment instead.
|
directly in two tests this WP's own acceptance criteria require to stay **unmodified**;
|
||||||
3. **New `Domain/Applications/AanvraagStatus.cs`**: a `sealed record` carrying
|
moving the constant would have forced a choice between breaking that criterion or adding a
|
||||||
`AanvraagStatusTag Tag` plus the same optional payload fields the DTO has
|
forwarding shim for no gain. Leave it.
|
||||||
(`StepIndex`, `StepCount`, `Referentie`, `Manual`, `Reden`), constructed **only** via static
|
2. **`AanvraagStatusTag` is NOT given a `Concept` member — implemented differently, deliberately.**
|
||||||
factories — `Concept(stepIndex, stepCount)`, `InBehandeling(referentie, manual)`,
|
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)`,
|
`Goedgekeurd(referentie)`, `Afgewezen(referentie, reden)`,
|
||||||
`MeerInfoGevraagd(referentie, reden)`.
|
`MeerInfoGevraagd(referentie, reden)`.
|
||||||
**Rejected: a full abstract-record union** (one subrecord per tag). It is the purer
|
**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
|
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
|
logic currently in `ToStatusDto` **verbatim**, including the "a recorded decision wins over
|
||||||
the auto-approve computation" ordering.
|
the auto-approve computation" ordering.
|
||||||
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`:
|
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`, via a shared
|
||||||
`new(s.Tag.ToString(), s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
|
`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
|
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
|
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.
|
change, no `messages.en.xlf` change.** Do not "improve" the DTO in this WP.
|
||||||
@@ -196,23 +219,41 @@ member names are unchanged).
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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
|
rejected with 400, and the other citizen's document remains deletable
|
||||||
(`DeleteResult.Ok`).
|
(`DeleteResult.Ok`). (`Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner`,
|
||||||
- [ ] `AanvraagStatusTag` contains `Concept`; no code compares a status against a string
|
`Draft_sync_with_a_foreign_documentId_is_rejected`.)
|
||||||
literal. `grep -rn '"Concept"' backend/src` returns no comparison sites.
|
- [x] `AanvraagStatusTag` does NOT contain `Concept` — implemented instead as
|
||||||
- [ ] `Enum.Parse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at the
|
`AanvraagStatus.Tag` being `AanvraagStatusTag?`, null exactly for Concept (see Decisions
|
||||||
`IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown tag.
|
§F3.2 for why this replaced the original "add Concept to the enum" instruction). No
|
||||||
- [ ] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
|
_internal domain_ code compares a status against the `"Concept"` string; the one
|
||||||
- [ ] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
|
remaining comparison (`Program.cs`'s beoordeling GET, against `IZaakSource`'s wire DTO)
|
||||||
- [ ] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
|
is the deliberate wire-boundary exception, paired with the one allowed `Enum.TryParse`
|
||||||
`libs/shared/src/infrastructure/api-client.ts` (proof F3 changed no wire shape).
|
below.
|
||||||
- [ ] Two concurrent `POST /beoordeling/{id}/besluit` against an already-terminal aanvraag yield
|
- [x] `Enum.Parse`/`TryParse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at
|
||||||
exactly one 200 and one 409; the recorded besluit is the first one.
|
the `IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown
|
||||||
- [ ] `BeoordelingRules.RequiresToelichting` exists, is unit-tested, and is the only place the
|
tag (`Enum.TryParse` there, not `Enum.Parse`).
|
||||||
rule lives.
|
- [x] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
|
||||||
- [ ] A `[Theory]` covers the (status × besluit) transition table at domain level.
|
- [x] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
|
||||||
- [ ] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists.
|
- [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
|
## 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