diff --git a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs index a396438..c6aaa62 100644 --- a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs +++ b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs @@ -1,9 +1,18 @@ namespace BigRegister.Domain.Intake; /// -/// 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 +/// (GET /intake/policy) and applies it for instant UX feedback +/// (intake.machine.ts's lageUren). +/// +/// WP-68 (F5): the class doc used to claim "the backend re-validates on submit as the +/// authority" — it doesn't. Neither SubmitApplicationRequest nor IntakeRequest +/// carries a scholing answer at all, so there is nothing for the server to re-validate; +/// both submit paths only apply SubmissionRules.RejectZeroUren. 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. /// public static class IntakePolicy { diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs index e38427e..49b235d 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -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())!.Status.Tag; + var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}")); + var finalTag = (await detail.Content.ReadFromJsonAsync())!.Aanvraag.Status.Tag; + Assert.Equal(winningTag, finalTag); } finally { diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index a570932..bc27011 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -49,76 +49,77 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other Gates land before the work they cover; each lint rule lands in the same WP as the fixes 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 | -| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | -| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | -| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | -| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | -| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | -| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | -| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | -| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | -| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | -| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | -| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | -| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | -| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | -| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | -| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | -| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | -| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | -| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | -| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | -| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | -| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | -| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done | -| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | -| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done | -| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done | -| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | -| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done | -| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done | -| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done | -| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done | -| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done | -| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | -| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done | -| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done | -| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done | -| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done | -| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done | -| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done | -| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done | -| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done | -| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done | -| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done | -| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | -| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | -| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done | -| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done | -| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done | -| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done | -| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done | -| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done | -| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done | -| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done | -| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done | -| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done | -| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done | -| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done | -| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done | -| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done | -| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done | -| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done | -| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) 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-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 | 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 | +| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | +| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | +| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | +| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | +| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | +| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | +| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | +| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | +| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | +| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | +| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | +| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | +| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | +| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | +| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | +| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | +| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | +| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | +| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | +| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | +| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | +| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done | +| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | +| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done | +| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done | +| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | +| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done | +| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done | +| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done | +| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done | +| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done | +| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | +| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done | +| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done | +| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done | +| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done | +| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done | +| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done | +| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done | +| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done | +| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done | +| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done | +| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | +| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | +| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done | +| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done | +| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done | +| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done | +| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done | +| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done | +| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done | +| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done | +| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done | +| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done | +| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done | +| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done | +| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done | +| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done | +| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done | +| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done | +| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) 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-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 | 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 diff --git a/docs/project/backlog/WP-68-ddd-aggregate-hardening.md b/docs/project/backlog/WP-68-ddd-aggregate-hardening.md index 65b88c7..9ac1e6b 100644 --- a/docs/project/backlog/WP-68-ddd-aggregate-hardening.md +++ b/docs/project/backlog/WP-68-ddd-aggregate-hardening.md @@ -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()` 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` 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` 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 diff --git a/docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md b/docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md new file mode 100644 index 0000000..99d80b1 --- /dev/null +++ b/docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md @@ -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.