Compare commits

...
8 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 868fb55783 docs: close WP-68, record verification result
CI / changes (push) Successful in 9s
CI / lint (push) Successful in 1m0s
CI / frontend (push) Successful in 2m43s
CI / backend (push) Successful in 2m14s
CI / e2e (push) Successful in 3m22s
CI / semgrep (push) Successful in 1m12s
CI / api-client-drift (push) Successful in 2m4s
CI / storybook-a11y (push) Successful in 11m46s
npm run ci passed fully green (lint, format, tokens, all four test suites, both
localized builds, audit, backend dotnet test at 216 passing, snippet + api-client
drift checks). npm run e2e could not be verified this session: port 4200 was
occupied by an unrelated container from a different repo, so Playwright reused it
instead of starting this app — a pre-existing local port collision, not a
regression, and not part of the local GREEN gate per CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:47:35 +02:00
ehoandClaude Opus 5 472a49f19f 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>
2026-08-05 15:42:21 +02:00
ehoandClaude Opus 5 31d4aa1848 test(backend): cover WP-68 F2/F6/T3 (besluit concurrency, toelichting rule, transitions)
- Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success: races two
  besluiten on the same open aanvraag, asserts exactly one 200 and one 409 — the
  behavior F2's in-lock guard exists to guarantee.
- Only_a_non_approval_requires_a_toelichting: unit test for
  BeoordelingRules.RequiresToelichting (F6).
- A_terminal_decision_refuses_any_further_besluit /
  MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal: the transition
  table at the aggregate level (T3) — an Aanvraag whose BesluitStatus already records
  a decision computes a terminal StatusAt, and CanDecide refuses a further besluit,
  independent of the endpoint-level equivalent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:38:30 +02:00
ehoandClaude Opus 5 fc6e73806a refactor(backend): move aanvraag status lifecycle into the domain (WP-68 F3)
The status was derived in Contracts/Mappers.ToStatusDto, not the domain; Concept was
a magic "Concept" string with no AanvraagStatusTag member; and the besluit endpoint
re-derived its own guard by reading the status back out of the DTO and Enum.Parse-ing
it. New Domain/Applications/AanvraagStatus.cs models the full status (Concept
included, via a null Tag rather than a sixth enum member) as a closed type,
constructible only through its factories. Aanvraag.StatusAt(now) carries the logic
verbatim; Mappers.ToStatusDto and ZgwZaakMapper's two status producers become
one-line projections onto the same wire DTO, so the wire shape is unchanged (gen:api
shows zero diff beyond F1's). The one remaining Enum.Parse (the beoordeling GET,
which crosses the IZaakSource wire boundary) is now non-throwing on an unrecognised
tag.

Also, WP-68 F2: the besluit transition-legality check now runs inside
ApplicationStore.RecordBesluit's write lock instead of in the endpoint beforehand —
two concurrent besluiten used to both pass the check before either wrote, letting
the second silently overwrite a terminal decision. RecordBesluit returns an
Ok/NotFound/Conflict outcome, mirroring DocumentStore.DeleteResult.

Also, WP-68 F6: the "toelichting required" rule moves from an inline endpoint check
into BeoordelingRules.RequiresToelichting, alongside CanDecide.

The three tests naming this refactor's regression net
(AanvraagStatusTag_covers_the_published_lifecycle,
AutoApprovable_flips_to_goedgekeurd_after_the_window, ZgwZaakMapperTests) pass
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:34:10 +02:00
ehoandClaude Opus 5 fd04221d2f chore(api): regenerate client for F1's new 400 responses
npm run gen:api after WP-68 F1 — draft-sync and submit now document their
document-ownership 400 ProblemDetails response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:50 +02:00
ehoandClaude Opus 5 a394950a1d fix(backend): reject foreign documentIds on submit and draft-sync (WP-68 F1)
submit and draft-sync took document ids straight from the request body with no
ownership check: a caller who knew a foreign document's id could attach another
citizen's upload to their own aanvraag (surfacing on the behandelaar's beoordeling
screen, POSTed to OpenZaak as their zaakinformatieobject) and permanently block the
victim's own delete by flipping Linked=true. ADR-0001 holds the FE has no authority;
this trusted it anyway.

Adds DocumentStore.ForeignIds(ids, owner) and calls it from both write paths before
any write, 400 ProblemDetails on a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:32 +02:00
ehoandClaude Opus 5 6a4a0ad435 docs: add WP-68, aggregate invariants + status modelling
Architecture review found the context boundaries, FP/TEA idioms and read/write
separation sound, and rejected explicit CQRS as the fix for anything found. It
located four real defects clustered in one place: the backend's aggregate roots
don't guard their own invariants, and the aanvraag status lifecycle is a computed
string living in the contracts layer instead of the domain. Full Decisions block
pre-made so implementation can proceed without re-litigating scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:55 +02:00
ehoandClaude Opus 5 6cfd70eeeb fix(backend): resolve besluit endpoint's id via Referentie, not local PK
POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:36 +02:00
28 changed files with 1122 additions and 213 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ dotnet test --filter Category=Integration
`OpenZaakIntegrationTests.cs` points a `WebApplicationFactory<Program>` at `OpenZaakIntegrationTests.cs` points a `WebApplicationFactory<Program>` at
`Zgw:Enabled=true` + `http://localhost:8000` with the harness's credentials, hits `Zgw:Enabled=true` + `http://localhost:8000` with the harness's credentials, hits
`GET /api/v1/admin/cases`, and asserts the seeded zaak comes back — through the real HTTP + `GET /api/v1/admin/cases`, and asserts the seeded zaak comes back — through the real HTTP +
JWT + Catalogi-label-resolution path, not a mock. This test is tagged `Category=Integration` JWT + zaaktype→aanvraag-type mapping path, not a mock. This test is tagged `Category=Integration`
and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`, and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness `scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
up, so it never runs where the harness doesn't exist. up, so it never runs where the harness doesn't exist.
+2 -2
View File
@@ -168,8 +168,8 @@ print(json.dumps({
echo " created: $zaaktype_url" echo " created: $zaaktype_url"
fi fi
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses..." echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for WP-66's besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..."
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"]' \ grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \
"zaaktype=\"$zaaktype_url\"" \ "zaaktype=\"$zaaktype_url\"" \
'max_vertrouwelijkheidaanduiding="openbaar"' 'max_vertrouwelijkheidaanduiding="openbaar"'
@@ -1,4 +1,5 @@
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents; using BigRegister.Domain.Documents;
using BigRegister.Domain.People; using BigRegister.Domain.People;
@@ -36,31 +37,17 @@ public static class Mappers
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new( public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery); c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports /// <summary>Wire projection of an <see cref="AanvraagStatus"/> — the null "Concept" case
// Goedgekeurd once past the processing window, else In behandeling; a manual case /// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire
// stays In behandeling until a behandelaar records a decision (WP-65b — before that /// convention's magic string here at the boundary rather than living inside the domain type.
// WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure — /// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers
// testable by passing different `now` values without waiting for the wall clock. /// agree on the projection (WP-68 F3).</summary>
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
{ s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount); // Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line
if (a.Reden is not null) // projection of that domain method onto the wire DTO (WP-68 F3).
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden); public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
// behandelaar's explicit besluit is authoritative once made.
if (a.BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
};
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
}
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
@@ -1,31 +1,15 @@
using System.Text.Json; using System.Text.Json;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
using BigRegister.Domain.Submissions; using BigRegister.Domain.Submissions;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary>
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
/// the wizard draft) isn't part of this enum — see <see cref="Aanvraag.Submitted"/>.
/// <see cref="Ingediend"/> and <see cref="MeerInfoGevraagd"/> are not reachable yet: no
/// endpoint sets them (that's WP-65's behandelaar-facing mutation) — modelled here so the
/// contract is ready when it does.
/// </summary>
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
/// <summary>
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
/// offers, each advancing <see cref="Aanvraag.BesluitStatus"/> and (via
/// <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/>) the published
/// <see cref="AanvraagStatusTag"/> the FE renders.
/// </summary>
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
/// <summary> /// <summary>
/// An application (aanvraag) — the system of record the dashboard reads. A wizard /// An application (aanvraag) — the system of record the dashboard reads. A wizard
/// creates one as a Concept on its first step, syncs its draft snapshot per step, /// creates one as a Concept on its first step, syncs its draft snapshot per step,
/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen /// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen
/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see Mappers.ToStatusDto) so /// lifecycle (ADR-0002). Status is COMPUTED ON READ (see <see cref="StatusAt"/>) so
/// auto-approval is purely a function of stored timestamps — no timers, no jobs. /// auto-approval is purely a function of stored timestamps — no timers, no jobs.
/// </summary> /// </summary>
public sealed class Aanvraag public sealed class Aanvraag
@@ -69,6 +53,26 @@ public sealed class Aanvraag
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes /// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
/// the published status's Reden), optional for Goedkeuren.</summary> /// the published status's Reden), optional for Goedkeuren.</summary>
public string? BesluitToelichting { get; set; } public string? BesluitToelichting { get; set; }
/// <summary>The status at a point in time (WP-68 F3) — moved here from
/// <c>Contracts.Mappers.ToStatusDto</c>, which is now a one-line projection of this. A
/// recorded decision wins over the auto-approve computation below.</summary>
public AanvraagStatus StatusAt(DateTimeOffset now)
{
if (!Submitted) return AanvraagStatus.Concept(StepIndex, StepCount);
if (Reden is not null) return AanvraagStatus.Afgewezen(Referentie!, Reden);
if (BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => AanvraagStatus.Goedgekeurd(Referentie!),
Besluit.Afwijzen => AanvraagStatus.Afgewezen(Referentie!, BesluitToelichting),
Besluit.MeerInfoOpvragen => AanvraagStatus.MeerInfoGevraagd(Referentie!, BesluitToelichting),
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
};
if (AutoApprovable && now > SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return AanvraagStatus.Goedgekeurd(Referentie!);
return AanvraagStatus.InBehandeling(Referentie!, manual: !AutoApprovable);
}
} }
/// <summary> /// <summary>
@@ -134,6 +138,21 @@ public static class ApplicationStore
} }
} }
/// Cross-owner lookup by Referentie — real bug fix (WP-66): the behandelaar besluit
/// endpoint receives the FE-facing case id from <c>IZaakSource.ListCases</c>, which under
/// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only
/// <c>LocalZaakSource</c>'s id happens to already be the Aanvraag.Id — every besluit 404'd
/// against a real OpenZaak). Referentie is the one identifier stable across both sources —
/// it's also what <c>CreateZaak</c> sent OpenZaak as <c>identificatie</c>.
public static Aanvraag? GetByReferentie(string referentie)
{
lock (_gate)
{
using var db = Db.Create();
return db.Applications.FirstOrDefault(a => a.Referentie == referentie);
}
}
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this /// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint. /// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
public static IReadOnlyList<Aanvraag> ListAll() public static IReadOnlyList<Aanvraag> ListAll()
@@ -255,23 +274,30 @@ public static class ApplicationStore
} }
} }
/// <summary>Record a behandelaar's decision (WP-65b). The endpoint has already checked public enum RecordBesluitOutcome { Ok, NotFound, Conflict }
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/> against the
/// freshly-read status before calling this — cross-owner like <see cref="DeleteAny"/>, /// <summary>Record a behandelaar's decision (WP-65b) — cross-owner like
/// since a behandelaar decides on any citizen's case. Returns null only if the aanvraag /// <see cref="DeleteAny"/>, since a behandelaar decides on any citizen's case.
/// is gone (shouldn't happen — this runs right after the endpoint's own read found it).</summary> /// WP-68 (F2): the transition-legality check (<see cref="BeoordelingRules.CanDecide"/>)
public static Aanvraag? RecordBesluit(string id, Besluit besluit, string? toelichting) /// now runs INSIDE this lock, against a status read fresh under the lock, rather than in
/// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's
/// check before either wrote, letting the second silently overwrite a terminal decision.
/// </summary>
public static (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
{ {
lock (_gate) lock (_gate)
{ {
using var db = Db.Create(); using var db = Db.Create();
var a = db.Applications.Find(id); var a = db.Applications.Find(id);
if (a is null) return null; if (a is null) return (RecordBesluitOutcome.NotFound, null);
var current = a.StatusAt(now).Tag;
if (current is null || !BeoordelingRules.CanDecide(current.Value))
return (RecordBesluitOutcome.Conflict, null);
a.BesluitStatus = besluit; a.BesluitStatus = besluit;
a.BesluitToelichting = toelichting; a.BesluitToelichting = toelichting;
a.UpdatedAt = DateTimeOffset.UtcNow; a.UpdatedAt = DateTimeOffset.UtcNow;
db.SaveChanges(); db.SaveChanges();
return a; return (RecordBesluitOutcome.Ok, a);
} }
} }
} }
@@ -96,6 +96,22 @@ public static class DocumentStore
} }
} }
/// <summary>Which of the given ids do NOT resolve to a document owned by <paramref name="owner"/>
/// (unknown id or owned by someone else) — named for what it returns (the offending ids), so a
/// caller can 400 with the specific ids rather than a bare boolean. Guards submit/draft-sync
/// against a citizen attaching another citizen's upload to their own aanvraag.</summary>
public static IReadOnlyList<string> ForeignIds(IEnumerable<string> documentIds, string owner)
{
var ids = documentIds.ToList();
lock (_gate)
{
using var db = Db.Create();
var owned = db.Documents.Where(d => ids.Contains(d.DocumentId) && d.Owner == owner)
.Select(d => d.DocumentId).ToHashSet();
return ids.Where(id => !owned.Contains(id)).ToList();
}
}
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary> /// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl) public static void SetDrcUrl(string documentId, string drcUrl)
{ {
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -0,0 +1,62 @@
namespace BigRegister.Domain.Applications;
/// <summary>
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
/// the wizard draft) is deliberately NOT a member here — see <see cref="AanvraagStatus.Tag"/>,
/// which is null exactly when the aanvraag hasn't been submitted yet, instead of a sixth
/// "magic string" tag with no enum member to match it (WP-68 F3).
/// <see cref="Ingediend"/> is reserved: no endpoint sets it yet (there is no state between
/// "just submitted" and "in behandeling" in this POC) — kept because the FE's status union
/// and $localize catalogue already declare it, and removing it would ripple into both.
/// </summary>
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
/// <summary>
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
/// offers, each advancing an aanvraag's <see cref="AanvraagStatus"/>.
/// </summary>
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
/// <summary>
/// The domain projection of an aanvraag's status at a point in time (WP-68 F3) — the type
/// <c>Aanvraag.StatusAt(now)</c> returns, replacing the logic that used to live directly in
/// <c>Contracts.Mappers.ToStatusDto</c>. Constructible only via the factories below, so a
/// caller can never build e.g. a Referentie-less Goedgekeurd. <see cref="Tag"/> is null only
/// for <see cref="Concept"/> — the one construction path that used to be a bare "Concept"
/// string with no corresponding <see cref="AanvraagStatusTag"/> member.
/// </summary>
public sealed class AanvraagStatus
{
public AanvraagStatusTag? Tag { get; }
public int? StepIndex { get; }
public int? StepCount { get; }
public string? Referentie { get; }
public bool? Manual { get; }
public string? Reden { get; }
private AanvraagStatus(AanvraagStatusTag? tag, int? stepIndex, int? stepCount, string? referentie, bool? manual, string? reden)
{
Tag = tag;
StepIndex = stepIndex;
StepCount = stepCount;
Referentie = referentie;
Manual = manual;
Reden = reden;
}
public static AanvraagStatus Concept(int stepIndex, int stepCount) =>
new(null, stepIndex, stepCount, null, null, null);
public static AanvraagStatus InBehandeling(string referentie, bool manual) =>
new(AanvraagStatusTag.InBehandeling, null, null, referentie, manual, null);
public static AanvraagStatus Goedgekeurd(string referentie) =>
new(AanvraagStatusTag.Goedgekeurd, null, null, referentie, null, null);
public static AanvraagStatus Afgewezen(string referentie, string? reden) =>
new(AanvraagStatusTag.Afgewezen, null, null, referentie, null, reden);
public static AanvraagStatus MeerInfoGevraagd(string referentie, string? reden) =>
new(AanvraagStatusTag.MeerInfoGevraagd, null, null, referentie, null, reden);
}
@@ -1,19 +1,26 @@
using BigRegister.Api.Data; using BigRegister.Domain.Applications;
namespace BigRegister.Domain.Beoordeling; namespace BigRegister.Domain.Beoordeling;
/// <summary> /// <summary>
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side /// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Used from
/// today (<see cref="CanDecide"/> only, backing the beoordeling detail screen's decision /// both the beoordeling read side (<see cref="CanDecide"/> backs the `canBesluiten` decision
/// flag) — the decision-recording rules (which besluit is legal, whether it needs a /// flag) and the besluit write side (the SAME `CanDecide` gates the mutation, and — since
/// toelichting) land alongside the mutation endpoint in this WP's second half. /// WP-68 F2 — runs inside the write lock, so the two can never drift and a concurrent besluit
/// can't race past the check).
/// </summary> /// </summary>
public static class BeoordelingRules public static class BeoordelingRules
{ {
/// A behandelaar may record a decision while the aanvraag is in an open, non-terminal /// A behandelaar may record a decision while the aanvraag is in an open, non-terminal
/// status. Concept never reaches here (the endpoint 404s it before calling this); a case /// status. Concept never reaches here (a null <see cref="AanvraagStatus.Tag"/> is checked
/// already `Goedgekeurd`/`Afgewezen` is final. /// separately by callers); a case already `Goedgekeurd`/`Afgewezen` is final.
public static bool CanDecide(AanvraagStatusTag current) => public static bool CanDecide(AanvraagStatusTag current) =>
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
or AanvraagStatusTag.MeerInfoGevraagd; or AanvraagStatusTag.MeerInfoGevraagd;
/// <summary>WP-68 F6: moved here from an inline check in the besluit endpoint. The
/// toelichting (behandelaar's explanation) is required for every besluit except an
/// approval — Afwijzen/MeerInfoOpvragen must justify why (becomes the published status's
/// Reden).</summary>
public static bool RequiresToelichting(Besluit besluit) => besluit != Besluit.Goedkeuren;
} }
@@ -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
{ {
+49 -19
View File
@@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
using BigRegister.Domain.Beoordeling; using BigRegister.Domain.Beoordeling;
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
@@ -314,9 +315,19 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty). // Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) {
? Results.NoContent() : Results.NotFound()) var owner = ctx.Zorgverlener().Bsn;
// A citizen may only reference their own uploads in a draft — reject before the sync
// writes a foreign document id into the aanvraag (ADR-0001: the FE holds no authority).
if (req.DocumentIds is { } ids && DocumentStore.ForeignIds(ids, owner) is { Count: > 0 } foreign)
return Results.Problem(
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreign)}.",
statusCode: StatusCodes.Status400BadRequest);
return ApplicationStore.SyncDraft(id, owner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
? Results.NoContent() : Results.NotFound();
})
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
@@ -353,6 +364,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
var docs = req.Documents; var docs = req.Documents;
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
// A citizen may only submit their own uploads — reject before the submit writes a
// foreign document id onto the aanvraag (ADR-0001: the FE holds no authority).
if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds)
return Results.Problem(
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreignIds)}.",
statusCode: StatusCodes.Status400BadRequest);
var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds); var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict(); if (submitted is null) return Results.Conflict();
@@ -401,6 +419,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
return Results.Ok(new SubmitApplicationResponse(referentie, status)); return Results.Ok(new SubmitApplicationResponse(referentie, status));
}) })
.Produces<SubmitApplicationResponse>() .Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -434,8 +453,10 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
var docs = DocumentStore.ByIds(c.DocumentIds) var docs = DocumentStore.ByIds(c.DocumentIds)
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); .Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
var masked = c with { Owner = MaskTail(c.Owner!, 3) }; var masked = c with { Owner = MaskTail(c.Owner!, 3) };
var decisions = new BeoordelingDecisionsDto( // WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an
BeoordelingRules.CanDecide(Enum.Parse<AanvraagStatusTag>(c.Status.Tag))); // unrecognised tag degrades to "cannot decide" instead of a 500.
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
var decisions = new BeoordelingDecisionsDto(canBesluiten);
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions)); return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
})) }))
.Produces<BeoordelingViewDto>() .Produces<BeoordelingViewDto>()
@@ -446,43 +467,52 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource // lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
// seam) — same reasoning as the GET above. The transition-legality check // seam) — same reasoning as the GET above. The transition-legality check
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses, // (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
// so the two can never drift. WP-66: once the local decision has committed, IZaakSource // so the two can never drift — and (WP-68 F2) it now runs inside ApplicationStore.RecordBesluit's
// also gets a chance to advance the ZGW-side zaak status — LocalZaakSource no-ops, // write lock rather than here, so two concurrent besluiten can't both pass it before either
// OpenZaakZaakSource POSTs a new Statussen entry (see its RecordBesluit). // writes. WP-66: once the local decision has committed, IZaakSource also gets a chance to
// advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new
// Statussen entry (see its RecordBesluit).
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) => api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
Beoordelen(ctx, $"aanvraag/{id}/besluit", () => Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
{ {
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit)) if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest); return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
// WP-68 (F6): moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable.
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting))
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var a = ApplicationStore.GetAny(id); // Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under
var statusTag = a?.ToStatusDto(now).Tag; // OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a
if (a is null || statusTag == "Concept") return Results.NotFound(); // ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above
var current = Enum.Parse<AanvraagStatusTag>(statusTag!); // uses), so resolve the case first and go to the local Aanvraag via its Referentie
if (!BeoordelingRules.CanDecide(current)) // (see ApplicationStore.GetByReferentie).
var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id);
var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null;
if (a is null) return Results.NotFound();
var (outcome, updated) = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting, now);
if (outcome == ApplicationStore.RecordBesluitOutcome.NotFound) return Results.NotFound();
if (outcome == ApplicationStore.RecordBesluitOutcome.Conflict)
return Results.Problem( return Results.Problem(
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.", detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
statusCode: StatusCodes.Status409Conflict); statusCode: StatusCodes.Status409Conflict);
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!; app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
// WP-60: the local decision above already committed — a ZGW failure here is caught and // WP-60: the local decision above already committed — a ZGW failure here is caught and
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak // flagged rather than allowed to diverge silently, same handling as submit's create-zaak
// and document-link writes. // and document-link writes.
try try
{ {
zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller()); zaken.RecordBesluit(updated!, besluit, req.Toelichting, now, ctx.Caller());
} }
catch (Exception ex) catch (Exception ex)
{ {
RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex); RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex);
} }
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now))); return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
})) }))
.Produces<RecordBesluitResponse>() .Produces<RecordBesluitResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest)
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
@@ -15,14 +16,17 @@ public sealed record ZgwPage<T>(
/// <summary> /// <summary>
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 /// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50
/// write). Reads zaken (following pagination), resolves each zaaktype's human label from the /// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
/// Catalogi API (cached), and maps into <see cref="ApplicationSummaryDto"/> via /// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
/// <see cref="ZgwZaakMapper"/>. Creates a zaak + status + rol for a just-submitted aanvraag. /// human zaaktype label, which isn't a value <see cref="ApplicationSummaryDto.Type"/>'s
/// Selected only when <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalZaakSource"/>. /// contract accepts; see <see cref="AanvraagTypeFor"/>), and maps into
/// <see cref="ApplicationSummaryDto"/> via <see cref="ZgwZaakMapper"/>. Creates a zaak +
/// status + rol for a just-submitted aanvraag. Selected only when <c>Zgw:Enabled=true</c>;
/// the default stays <see cref="LocalZaakSource"/>.
/// ///
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization /// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization
/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution); /// header. Creating/deciding a zaak needs read scope on Catalogi too (statustype/resultaattype/
/// creating one additionally needs write scope on Zaken. /// roltype resolution) in addition to write scope on Zaken; a plain read does not.
/// </summary> /// </summary>
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
{ {
@@ -47,17 +51,22 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
if (bsn is not null) if (bsn is not null)
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}"; url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
var zaken = await GetAllAsync<ZgwZaak>(url, caller); var zaken = await GetAllAsync<ZgwZaak>(url, caller);
var labels = new Dictionary<string, string>(); return zaken.Select(z => ZgwZaakMapper.ToSummaryDto(z, AanvraagTypeFor(z.Zaaktype))).ToList();
var result = new List<ApplicationSummaryDto>(zaken.Count);
foreach (var z in zaken)
{
if (!labels.TryGetValue(z.Zaaktype, out var label))
labels[z.Zaaktype] = label = await ZaaktypeLabelAsync(z.Zaaktype);
result.Add(ZgwZaakMapper.ToSummaryDto(z, label));
}
return result;
} }
/// <summary>Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse):
/// <c>ApplicationSummaryDto.Type</c>'s contract is the internal aanvraag-type key (e.g.
/// "herregistratie" — what <see cref="LocalZaakSource"/>/<c>Mappers.ToSummaryDto</c> send,
/// and what the FE's <c>AANVRAAG_TYPES</c> trust boundary accepts), NOT OpenZaak's human
/// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round
/// trip — every case failed the FE's parse boundary as soon as a real OpenZaak backed this
/// seam. A zaak's zaaktype URL round-trips back to that key via the same
/// <c>Zgw:ZaaktypeUrls</c> config <see cref="CreateZaakAsync"/> goes the other way with —
/// no Catalogi call needed, and no label cache either.</summary>
private string AanvraagTypeFor(string zaaktypeUrl) =>
options.ZaaktypeUrls.FirstOrDefault(kv => kv.Value == zaaktypeUrl).Key
?? throw new InvalidOperationException($"No aanvraag type configured for zaaktype {zaaktypeUrl}.");
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary> /// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null) private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
{ {
@@ -72,13 +81,6 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return all; return all;
} }
/// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary>
private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl)
{
var zt = await zgw.GetAsync<Zaaktype>(zaaktypeUrl);
return zt.Omschrijving;
}
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------ // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the /// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
@@ -152,7 +154,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// WP-60: no compensating transaction here either — the local decision already committed /// WP-60: no compensating transaction here either — the local decision already committed
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here /// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>), /// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
/// the same way the submit endpoint's create-zaak/document writes are.</summary> /// the same way the submit endpoint's create-zaak/document writes are.
///
/// ZGW requires a zaak to have a Resultaat before it can reach an eindstatus (OpenZaak 400s
/// "Zaak has no resultaat" otherwise — confirmed against a real instance) — so this posts one
/// first, same "existence-only, take the first" resolution as the statustype above (the
/// harness's catalogus provisions exactly one resultaattype per zaaktype, not one per besluit
/// outcome; a real deployment mapping besluit → resultaattype is future work).</summary>
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) => public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult(); RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
@@ -163,12 +171,25 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
throw new InvalidOperationException( throw new InvalidOperationException(
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'."); $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
var resultaattypeUrl = await FirstResultaattypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/resultaten",
new CreateResultaatRequest(aanvraag.ZaakUrl, resultaattypeUrl), caller);
var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl); var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl);
var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}"; var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest( await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller); aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
} }
private async Task<string> FirstResultaattypeUrlAsync(string zaaktypeUrl)
{
var page = await zgw.GetAsync<ZgwPage<Resultaattype>>(
$"{options.ZtcBaseUrl}/resultaattypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
var first = page.Results.FirstOrDefault()
?? throw new InvalidOperationException($"No resultaattype found for zaaktype {zaaktypeUrl}.");
return first.Url;
}
/// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer /// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer
/// (the eind status) rather than lowest.</summary> /// (the eind status) rather than lowest.</summary>
private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl) private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl)
@@ -189,14 +210,14 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return first.Url; return first.Url;
} }
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
private sealed record Statustype( private sealed record Statustype(
[property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer); [property: JsonPropertyName("volgnummer")] int Volgnummer);
private sealed record Roltype([property: JsonPropertyName("url")] string Url); private sealed record Roltype([property: JsonPropertyName("url")] string Url);
private sealed record Resultaattype([property: JsonPropertyName("url")] string Url);
private sealed record CreateZaakRequest( private sealed record CreateZaakRequest(
[property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
@@ -210,6 +231,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet, [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
[property: JsonPropertyName("statustoelichting")] string Statustoelichting = ""); [property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
private sealed record CreateResultaatRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("resultaattype")] string Resultaattype);
private sealed record CreateRolRequest( private sealed record CreateRolRequest(
[property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType, [property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Applications;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
@@ -33,9 +34,9 @@ public static class ZgwZaakMapper
// ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed // ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
// one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the // one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
// Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end. // Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end.
var status = z.Einddatum is null var status = (z.Einddatum is null
? new AanvraagStatusDto("InBehandeling", Referentie: z.Identificatie, Manual: true) ? AanvraagStatus.InBehandeling(z.Identificatie, manual: true)
: new AanvraagStatusDto("Goedgekeurd", Referentie: z.Identificatie); : AanvraagStatus.Goedgekeurd(z.Identificatie)).ToDto();
var created = Iso(z.Registratiedatum ?? z.Startdatum); var created = Iso(z.Registratiedatum ?? z.Startdatum);
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum); var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
@@ -59,5 +60,5 @@ public static class ZgwZaakMapper
/// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling /// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling
/// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary> /// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary>
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) => public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
new("InBehandeling", Referentie: identificatie, Manual: true); AanvraagStatus.InBehandeling(identificatie, manual: true).ToDto();
} }
+20
View File
@@ -643,6 +643,16 @@
"204": { "204": {
"description": "No Content" "description": "No Content"
}, },
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": { "404": {
"description": "Not Found" "description": "Not Found"
} }
@@ -718,6 +728,16 @@
} }
} }
}, },
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": { "409": {
"description": "Conflict", "description": "Conflict",
"content": { "content": {
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -172,6 +173,51 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
} }
} }
// --- WP-68 (F1): a citizen may only reference their own uploads — submit/draft-sync must
// reject a foreign documentId rather than silently attaching it. ---
private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId)
{
var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(file, "file", "d.pdf");
content.Add(new StringContent("diploma"), "categoryId");
content.Add(new StringContent(localId), "localId");
content.Add(new StringContent("registratie"), "wizardId");
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/uploads") { Content = content, Headers = { { "X-Subject", owner } } };
var res = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
}
[Fact]
public async Task Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner()
{
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
// The rejected submit must not have flipped the foreign document's Linked flag — its
// owner can still delete it.
var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/uploads/{foreignDoc.DocumentId}") { Headers = { { "X-Subject", "999888777" } } };
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(deleteReq)).StatusCode);
}
[Fact]
public async Task Draft_sync_with_a_foreign_documentId_is_rejected()
{
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie");
var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
// --- Auto-approval is computed on read: exercise the window boundary without waiting. --- // --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new() private static Aanvraag Accepted(bool autoApprovable) => new()
@@ -0,0 +1,88 @@
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests;
/// <summary>Wraps <see cref="LocalZaakSource"/> but returns a DIFFERENT case id than the
/// underlying Aanvraag.Id — reproduces exactly what <c>OpenZaakZaakSource</c> does in
/// production (the FE-facing case id from <c>ListCases</c> is the ZGW zaak's own uuid, not
/// <c>ApplicationStore</c>'s primary key) without needing a live OpenZaak, so the besluit
/// endpoint's Referentie-based resolution (the fix below) gets coverage on every push.</summary>
file sealed class IdMismatchZaakSource : IZaakSource
{
private readonly LocalZaakSource inner = new();
private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" };
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
inner.ListCases(now).Select(Rekey).ToList();
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
inner.ListMyCases(caller, now).Select(Rekey).ToList();
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(
Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller);
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller);
}
/// <summary>
/// Regression for a real, live-repro'd bug: recording a besluit from the behandelportal always
/// 404'd against a real OpenZaak. Root cause — <c>POST /beoordeling/{id}/besluit</c> looked
/// <c>id</c> up directly in <c>ApplicationStore</c> (its own primary key), but <c>id</c> is
/// whatever <c>IZaakSource.ListCases</c> handed the FE; under <c>OpenZaakZaakSource</c> that's
/// the ZGW zaak's own uuid, a different value entirely. Fixed by resolving the case through
/// the same <c>ListCases</c> seak the GET sibling (<see cref="BeoordelingTests"/>) already uses,
/// then to the local <c>Aanvraag</c> via its Referentie (<c>ApplicationStore.GetByReferentie</c>)
/// — the one identifier stable across both sources. <see cref="IdMismatchZaakSource"/>
/// reproduces the id divergence without a live OpenZaak.
/// </summary>
public class BeoordelingIdMismatchTests
{
private static WebApplicationFactory<Program> Factory()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-id-mismatch-{Guid.NewGuid():N}.db");
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
.UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}")
.ConfigureTestServices(services => services.AddSingleton<IZaakSource, IdMismatchZaakSource>()));
}
private static HttpRequestMessage Behandelaar(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Add("X-Medewerker", "medewerker-1");
if (body is not null) req.Content = JsonContent.Create(body);
return req;
}
[Fact]
public async Task Besluit_resolves_by_referentie_when_the_case_id_differs_from_the_local_aanvraag_id()
{
using var factory = Factory();
using var client = factory.CreateClient();
var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var app = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" });
submit.EnsureSuccessStatusCode();
var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad"));
var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var caseId = Assert.Single(items).Id;
// Sanity: the id divergence this test exists for is real, not accidentally absent.
Assert.NotEqual(app.Id, caseId);
var res = await client.SendAsync(Behandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{caseId}/besluit",
new { besluit = "Afwijzen", toelichting = "onvolledig" }));
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
}
@@ -215,6 +215,35 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
} }
} }
// WP-68 (F2): the transition-legality check now runs inside RecordBesluit's write lock, so
// two besluiten racing on the same still-open aanvraag can't both pass the check before
// either writes — exactly one commits, the other sees the now-terminal status.
[Fact]
public async Task Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success()
{
var (a, _) = await CreateManualCaseWithDocument();
try
{
var results = await Task.WhenAll(
PostBesluit(a.Id, new { besluit = "Goedkeuren" }),
PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" }));
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
{
await DeleteAsAdmin(a.Id);
}
}
[Fact] [Fact]
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden() public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
{ {
@@ -1,5 +1,7 @@
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Zgw;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
@@ -7,7 +9,7 @@ namespace BigRegister.Tests;
/// <summary> /// <summary>
/// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted, /// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted,
/// real response shapes, real pagination/zaaktype resolution — rather than the stub /// real response shapes, real pagination/zaaktype→aanvraag-type mapping — rather than the stub
/// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>, /// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>,
/// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c> /// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c>
/// to be up and seeded first (see its README); tagged Category=Integration so it's excluded /// to be up and seeded first (see its README); tagged Category=Integration so it's excluded
@@ -23,7 +25,7 @@ namespace BigRegister.Tests;
[Trait("Category", "Integration")] [Trait("Category", "Integration")]
public class OpenZaakIntegrationTests public class OpenZaakIntegrationTests
{ {
private static WebApplicationFactory<Program> Factory() private static WebApplicationFactory<Program> Factory(string zaaktypeUrl)
{ {
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db"); var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db");
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
@@ -34,23 +36,39 @@ public class OpenZaakIntegrationTests
.UseSetting("Zgw:ClientId", "bigregister-test") .UseSetting("Zgw:ClientId", "bigregister-test")
.UseSetting("Zgw:Secret", "bigregister-test-secret") .UseSetting("Zgw:Secret", "bigregister-test-secret")
.UseSetting("Zgw:UserId", "bigregister-test") .UseSetting("Zgw:UserId", "bigregister-test")
.UseSetting("Zgw:UserRepresentation", "WP-54 integration test")); .UseSetting("Zgw:UserRepresentation", "WP-54 integration test")
.UseSetting("Zgw:ZaaktypeUrls:herregistratie", zaaktypeUrl));
}
/// <summary>bootstrap-catalogus.sh mints the seeded zaaktype's uuid fresh per harness
/// instance, so unlike every other setting <c>Factory</c> hardcodes, this one has to be
/// discovered live — the same real HTTP + JWT this test is meant to exercise, done once up
/// front to learn the URL <c>Zgw:ZaaktypeUrls</c> needs (see <see cref="OpenZaakZaakSource.AanvraagTypeFor"/>).</summary>
private static async Task<string> SeededZaaktypeUrlAsync()
{
var tokenOptions = new ZgwOptions { ClientId = "bigregister-test", Secret = "bigregister-test-secret" };
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", new ZgwTokenProvider(tokenOptions).Mint());
client.DefaultRequestHeaders.Add("Accept-Crs", "EPSG:4326"); // else OpenZaak 412s
var page = await client.GetFromJsonAsync<ZgwPage<ZgwZaak>>(
"http://localhost:8000/zaken/api/v1/zaken?identificatie=BIG-2026-000123");
return Assert.Single(page!.Results).Zaaktype;
} }
[Fact] [Fact]
public async Task Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT() public async Task Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT()
{ {
using var factory = Factory(); using var factory = Factory(await SeededZaaktypeUrlAsync());
using var client = factory.CreateClient(); using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage) client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage)
var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases"); var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases");
Assert.NotNull(cases); Assert.NotNull(cases);
// bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123, under a // bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123.
// zaaktype whose omschrijving is "Herregistratie arts" — see backend/openzaak/README.md.
var seeded = Assert.Single(cases!, c => c.Status.Referentie == "BIG-2026-000123"); var seeded = Assert.Single(cases!, c => c.Status.Referentie == "BIG-2026-000123");
Assert.Equal("Herregistratie arts", seeded.Type); Assert.Equal("herregistratie", seeded.Type);
Assert.Equal("InBehandeling", seeded.Status.Tag); Assert.Equal("InBehandeling", seeded.Status.Tag);
} }
} }
@@ -1,48 +1,58 @@
using System.Net; using System.Net;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Api.Zgw; using BigRegister.Api.Zgw;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no /// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no
/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches /// mocking library) — the guarantee that it follows ZGW pagination, maps a zaak's zaaktype
/// zaaktype labels, and always sends a Bearer token. /// back to the internal aanvraag-type key, and always sends a Bearer token.
/// </summary> /// </summary>
public class OpenZaakZaakSourceTests public class OpenZaakZaakSourceTests
{ {
private const string ZrcBase = "https://oz.example/zaken/api/v1"; private const string ZrcBase = "https://oz.example/zaken/api/v1";
private const string ZtBase = "https://oz.example/catalogi/api/v1"; private const string ZtBase = "https://oz.example/catalogi/api/v1";
private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1";
private static string Page1 => $$""" private static string Page1 => $$"""
{ "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [ { "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [
{ "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1", { "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1",
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01", "zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-03-01",
"einddatum": null, "registratiedatum": "2026-03-01" } ] } "einddatum": null, "registratiedatum": "2026-03-01" } ] }
"""; """;
private static string Page2 => $$""" private static string Page2 => $$"""
{ "count": 2, "next": null, "results": [ { "count": 2, "next": null, "results": [
{ "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2", { "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2",
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01", "zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-01-01",
"einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] } "einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] }
"""; """;
private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }""";
[Fact] [Fact]
public void Follows_pagination_caches_zaaktype_and_sends_bearer_token() public void Follows_pagination_maps_the_internal_aanvraag_type_and_sends_bearer_token()
{ {
// Regression for a real bug found via a live behandelportal walkthrough: this used to
// return OpenZaak's human zaaktype label ("Herregistratie arts") as Type, which the FE's
// AANVRAAG_TYPES trust boundary always rejects (it only accepts the internal key, the
// same contract LocalZaakSource honors) — every werkvoorraad load failed to parse.
var handler = new ZgwStubHandler(url => url switch var handler = new ZgwStubHandler(url => url switch
{ {
_ when url == $"{ZrcBase}/zaken" => Page1, _ when url == $"{ZrcBase}/zaken" => Page1,
_ when url == $"{ZrcBase}/zaken?page=2" => Page2, _ when url == $"{ZrcBase}/zaken?page=2" => Page2,
_ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype,
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"), _ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
}); });
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; var options = new ZgwOptions
{
ZrcBaseUrl = ZrcBase,
ZtcBaseUrl = ZtBase,
ClientId = "c",
Secret = "s",
ZaaktypeUrls = new() { ["herregistratie"] = ZaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var cases = source.ListCases(DateTimeOffset.UtcNow); var cases = source.ListCases(DateTimeOffset.UtcNow);
@@ -50,16 +60,31 @@ public class OpenZaakZaakSourceTests
// Both pages accumulated. // Both pages accumulated.
Assert.Equal(2, cases.Count); Assert.Equal(2, cases.Count);
Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id)); Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id));
Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type)); Assert.All(cases, c => Assert.Equal("herregistratie", c.Type));
Assert.Equal("InBehandeling", cases[0].Status.Tag); // open Assert.Equal("InBehandeling", cases[0].Status.Tag); // open
Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed
// Zaaktype resolved once despite two zaken sharing it (cache). // No Catalogi round-trip needed — the type maps back via the local Zgw:ZaaktypeUrls config.
Assert.Single(handler.Requests, r => r.Contains("zaaktypen")); Assert.DoesNotContain(handler.Requests, r => r.Contains("zaaktypen"));
// Every outbound request carried a Bearer token. // Every outbound request carried a Bearer token.
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s)); Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
} }
[Fact]
public void ListCases_throws_when_a_zaak_zaaktype_has_no_configured_aanvraag_type()
{
var handler = new ZgwStubHandler(url => url switch
{
_ when url == $"{ZrcBase}/zaken" => Page1,
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
});
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<InvalidOperationException>(() => source.ListCases(DateTimeOffset.UtcNow));
}
[Fact] [Fact]
public void ListMyCases_filters_by_the_callers_bsn() public void ListMyCases_filters_by_the_callers_bsn()
{ {
@@ -174,6 +199,11 @@ public class OpenZaakZaakSourceTests
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 }, { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] } { "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
""", """,
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
""",
_ when url == $"{ZrcBase}/resultaten" => "{}",
_ when url == $"{ZrcBase}/statussen" => "{}", _ when url == $"{ZrcBase}/statussen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"), _ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
}); });
@@ -207,6 +237,59 @@ public class OpenZaakZaakSourceTests
Assert.Contains("onvolledig", statusBody); Assert.Contains("onvolledig", statusBody);
} }
[Fact]
public void RecordBesluit_creates_a_resultaat_before_posting_the_eindstatus()
{
// Regression for a real bug found against a live OpenZaak: posting straight to the eind
// statustype without a Resultaat first gets rejected with 400 "Zaak has no resultaat" —
// ZGW requires the Resultaat to exist before a zaak can reach its eindstatus.
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
var handler = new ZgwStubHandler(url => url switch
{
_ when url.StartsWith($"{ZtBase}/statustypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
""",
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
""",
_ when url == $"{ZrcBase}/resultaten" => "{}",
_ when url == $"{ZrcBase}/statussen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
});
var options = new ZgwOptions
{
ZrcBaseUrl = ZrcBase,
ZtcBaseUrl = ZtBase,
ClientId = "c",
Secret = "s",
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
};
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
var resultaatBody = handler.BodyOf($"{ZrcBase}/resultaten");
Assert.Contains($"{ZrcBase}/zaken/uuid-existing", resultaatBody);
Assert.Contains("resultaattypen/rst-1", resultaatBody);
// The Resultaat must exist BEFORE the eindstatus is posted, not after.
Assert.True(
handler.Requests.IndexOf($"{ZrcBase}/resultaten") < handler.Requests.IndexOf($"{ZrcBase}/statussen"),
"expected /resultaten to be posted before /statussen");
}
[Fact] [Fact]
public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak() public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
{ {
@@ -1,4 +1,5 @@
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling; using BigRegister.Domain.Beoordeling;
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents; using BigRegister.Domain.Documents;
@@ -192,4 +193,49 @@ public class BeoordelingRuleTests
[InlineData(AanvraagStatusTag.Afgewezen, false)] [InlineData(AanvraagStatusTag.Afgewezen, false)]
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) => public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
Assert.Equal(expected, BeoordelingRules.CanDecide(tag)); Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
// WP-68 (F6): the toelichting rule, moved here from an inline endpoint check.
[Theory]
[InlineData(Besluit.Goedkeuren, false)]
[InlineData(Besluit.Afwijzen, true)]
[InlineData(Besluit.MeerInfoOpvragen, true)]
public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) =>
Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit));
// WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag —
// an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
private static Aanvraag Decided(Besluit besluit) => new()
{
Id = "x",
Type = "registratie",
Owner = "test",
Submitted = true,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
BesluitStatus = besluit,
BesluitToelichting = besluit == Besluit.Goedkeuren ? null : "toelichting",
};
[Theory]
[InlineData(Besluit.Goedkeuren)]
[InlineData(Besluit.Afwijzen)]
public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded)
{
var now = DateTimeOffset.UtcNow;
var tag = Decided(recorded).StatusAt(now).Tag!.Value;
Assert.False(BeoordelingRules.CanDecide(tag));
}
[Fact]
public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal()
{
var now = DateTimeOffset.UtcNow;
var tag = Decided(Besluit.MeerInfoOpvragen).StatusAt(now).Tag!.Value;
Assert.True(BeoordelingRules.CanDecide(tag));
}
} }
+71 -69
View File
@@ -49,75 +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 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. 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 |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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 | done |
| [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
@@ -0,0 +1,337 @@
# 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 — `submit` links client-supplied `documentId`s with no ownership check.**
`Program.cs:353-356` takes document ids straight from the request body and hands them to
`ApplicationStore.Submit` and `documents.LinkToZaak`; `DocumentStore.Link` has no `owner`
parameter and performs no check (`DocumentStore.cs:113-125`). Same for `SyncDraft`
(`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's `Linked = 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 in `Contracts/Mappers.ToStatusDto`
(`Mappers.cs:44-63`), not in the domain; `Concept` is **not** a member of
`AanvraagStatusTag` (`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 is
`enum + one string that is not in the enum`, so `Enum.Parse` is 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:469` calls `BeoordelingRules.CanDecide`; the write happens later in
`ApplicationStore.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 at `Program.cs:473`, although `BeoordelingRules`' own
doc-comment says the decision-recording rules were meant to land there. It therefore has no
unit test, only the endpoint test `Afwijzen_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](../../reference/architecture/0001-bff-lite-decision-dtos.md)
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (the `Aanvraag` entity, the store's
lock discipline, `AanvraagStatusTag`, `RecordBesluit`)
- `backend/src/BigRegister.Api/Contracts/Mappers.cs` (`ToStatusDto` — the logic to move)
- `backend/src/BigRegister.Api/Program.cs` lines 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 existing
`DeleteResult` enum 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
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
`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
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.
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)`, 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.
7. **`ZgwZaakMapper` is the second producer** and must be converted too, or the string literals
survive: `ToSummaryDto` and `ToCreatedStatusDto` build `AanvraagStatus` via the factories and
project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →
`InBehandeling` with `Manual: true`; closed → `Goedgekeurd`) — `ZgwZaakMapperTests` is the net.
8. **The besluit endpoint stops going through the DTO**: `var status = a.StatusAt(now);`
compare `status.Tag == AanvraagStatusTag.Concept`, pass `status.Tag` to `CanDecide`. The
`Enum.Parse` at `Program.cs:468` is deleted.
9. **One `Enum.Parse` may remain** — the beoordeling GET at `Program.cs:438`, which parses a tag
off a DTO returned by the `IZaakSource` seam. 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. **Changing `IZaakSource` to 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`, `RecordBesluit` signature + in-lock guard,
enums moved out)
- `Contracts/Mappers.cs` (`ToStatusDto` reduced 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` (new `AanvraagStatusTests` nested class +
`RequiresToelichting`), `ApplicationTests.cs` (ownership), `BeoordelingTests.cs` (concurrency)
No migration: no persisted column changes (`BesluitStatus` already stores `Besluit`, whose
member names are unchanged).
## Steps
1. Commit/stash the WP-66 working tree (see Prerequisite).
2. **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.
3. **F3** — the status type, in Decisions order 1→9. `dotnet test` green with
`AanvraagStatusTag_covers_the_published_lifecycle`,
`AutoApprovable_flips_to_goedgekeurd_after_the_window` and `ZgwZaakMapperTests` **unchanged**
— those three are the regression net for the refactor.
4. **F2** — `RecordBesluitOutcome`, guard moved inside the lock, endpoint maps the outcome.
5. **F6** — `BeoordelingRules.RequiresToelichting` + unit test; endpoint calls it.
6. **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.
7. **F5** — correct the `IntakePolicy` doc-comment; open WP-69 for the enforcement.
8. Run the full gate (see Verification).
## Acceptance criteria
- [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`). (`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
```bash
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.cs` and
`Domain/Authorization/Authz.cs` import `BigRegister.Api.Data` (and `Authz` also
`.Contracts`, returning `BriefDecisionsDto`), with nothing in CI checking direction — the FE
has `dep:check`, the backend has only `dotnet format` + `dotnet test`. This WP's step 3
removes the `BeoordelingRules` violation as a side effect; the `Authz` one and the ~6-line
reflection convention test are WP-70.
- **F5 enforcement** → WP-69 (see Decisions).
- **F7** — `ApplicationStore.Submit` and `DocumentStore.Link` take 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 `Aanvraag` as instance methods
(`TryRecordBesluit`). This WP does the two that matter; the general move can wait.
- **F9** — `Authz` spans five contexts and its four admin gates are byte-identical
`role == Admin` checks with **no direct unit test** and no test denying `Approver`.
- **F10** — splitting `Program.cs` (917 lines, 50 endpoints). **Deliberately deferred and
flagged as risky:** `OrgAdmin`, `StamdataAdmin`, `Beoordelen`, `Submit` and `AuditAuthz` are
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 `throw` instead of returning `Result`;
`runSubmit` (which mints an `Idempotency-Key`) is used for **reads** in `brief.adapter.ts:56`,
`org-template.adapter.ts:39,51`, `stamdata.adapter.ts:27,42`. Fix is `runQuery`/`runCommand`
over one shared try/catch, ~10 lines.
- **T2** — ~54 FE `it()` titles are named after `Msg` tags (`'SetField updates the draft'`,
`'SubmitConfirmed maps Submitting to Submitted'`), against `bdd.mdx` rule 3. Titles only.
- **T5** — named coverage gaps: `OrgTemplateRules.RejectDraft` (both identity branches, no
margin boundary test), the four `Authz` admin gates, `DocumentRules.CategoriesFor`'s
`herregistratie`/`org-template` branches, `SubmissionRules.NewReference`, FE
`isStatusConsistent` (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 `describe` naming has three dialects; 7 `parse*` 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" `AanvraagStatusDto` into a proper wire union
while in there. That turns a zero-diff internal refactor into an FE + `messages.en.xlf` +
`gen:api` change. The acceptance criterion "`gen:api` leaves no diff" exists to catch it.
- **Missing the second producer.** `ZgwZaakMapper` is easy to overlook because it lives under
`Zgw/`, not `Contracts/`. 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.
@@ -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.
+14 -11
View File
@@ -175,10 +175,13 @@ JWT's audit claims reflect the behandelaar, not a static identity.
ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests
never modelled the header, so it had shipped silently since WP-49/50. never modelled the header, so it had shipped silently since WP-49/50.
- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is - `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a where **URL identity** becomes the trailing uuid; `Type` takes the internal aanvraag-type
human label (the cross-service join). key (`AanvraagTypeFor`, below) — a real bug (found via a live behandelportal walkthrough,
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`. `AANVRAAG_TYPES` trust boundary always rejected.
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, maps each
zaak's zaaktype URL back to the internal key via `Zgw:ZaaktypeUrls` (`AanvraagTypeFor` — a
local lookup, no Catalogi round-trip), attaches `Authorization: Bearer <jwt>`.
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern. - `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the - `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the
**inbound** NRC webhook, not a source/mapper: see the dedicated section below. **inbound** NRC webhook, not a source/mapper: see the dedicated section below.
@@ -272,13 +275,13 @@ flag, never a rollen matrix.
## The five ZGW APIs (context for later slices) ## The five ZGW APIs (context for later slices)
| API | Component | Used by | | API | Component | Used by |
| ------------ | --------- | --------------------------------------------------- | | ------------ | --------- | ---------------------------------------------------------------- |
| Zaken | ZRC | slice 1 (read), WP-50 (create) | | Zaken | ZRC | slice 1 (read), WP-50 (create) |
| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) | | Catalogi | ZTC | WP-50/66 (statustype/roltype/resultaattype for create + besluit) |
| Documenten | DRC | WP-51 (upload + zaak↔document link) | | Documenten | DRC | WP-51 (upload + zaak↔document link) |
| Besluiten | BRC | later (formal decisions) | | Besluiten | BRC | later (formal decisions) |
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) | | Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
## How to add the next slice ## How to add the next slice
@@ -921,6 +921,12 @@ export class ApiClient {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
return; return;
}); });
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} else if (status === 404) { } else if (status === 404) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers); return throwException("Not Found", status, _responseText, _headers);
@@ -1014,6 +1020,12 @@ export class ApiClient {
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse; result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse;
return result200; return result200;
}); });
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} else if (status === 404) { } else if (status === 404) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers); return throwException("Not Found", status, _responseText, _headers);
@@ -18,6 +18,17 @@ describe('parseMe (trust boundary)', () => {
}); });
}); });
// Regression: WP-66's `aanvraag:beoordelen` (behandelportal) shipped on the `Capability`
// type but was never added to this trust-boundary's runtime KNOWN list, so a real
// behandelaar's `/me` response had the capability silently dropped and the werkvoorraad
// page always denied — every `Capability` union member belongs in KNOWN too.
it('recognizes the behandelportal besluit capability (WP-66)', () => {
expect(parseMe({ capabilities: ['aanvraag:beoordelen'] })).toEqual({
ok: true,
value: ['aanvraag:beoordelen'],
});
});
it('drops unrecognized capability strings instead of rejecting the response', () => { it('drops unrecognized capability strings instead of rejecting the response', () => {
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] }); const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
expect(r).toEqual({ ok: true, value: ['brief:approve'] }); expect(r).toEqual({ ok: true, value: ['brief:approve'] });
@@ -11,6 +11,7 @@ const KNOWN: readonly Capability[] = [
'stamdata:edit', 'stamdata:edit',
'cases:manage', 'cases:manage',
'flags:manage', 'flags:manage',
'aanvraag:beoordelen',
]; ];
/** /**
+1 -1
View File
@@ -71,7 +71,7 @@ app = Applicatie.objects.get(client_ids__contains=["bigregister-test"])
app.autorisaties.filter(component="zrc").delete() app.autorisaties.filter(component="zrc").delete()
app.autorisaties.create( app.autorisaties.create(
component="zrc", component="zrc",
scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"], scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"],
zaaktype="$container_zaaktype_url", zaaktype="$container_zaaktype_url",
max_vertrouwelijkheidaanduiding="openbaar", max_vertrouwelijkheidaanduiding="openbaar",
) )