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:
eho
2026-08-05 15:42:21 +02:00
co-authored by Claude Opus 5
parent 31d4aa1848
commit 472a49f19f
5 changed files with 211 additions and 105 deletions
@@ -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.