diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs
index fc0a844..22436df 100644
--- a/backend/src/BigRegister.Api/Data/IZaakSource.cs
+++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs
@@ -43,4 +43,15 @@ public interface IZaakSource
/// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
///
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
+
+ ///
+ /// Extend a behandelaar's already-locally-recorded decision (WP-65b's
+ /// ApplicationStore.RecordBesluit already ran) with a ZGW-side status transition
+ /// (WP-66) — the write counterpart to 's initial status. The local
+ /// source is a no-op (the decision IS the record of truth there, unchanged from before this
+ /// seam existed); the OpenZaak source POSTs a new Statussen entry to
+ /// 's zaak. (WP-53/62) is the acting
+ /// medewerker.
+ ///
+ void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller);
}
diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
index 95624cd..7326d68 100644
--- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
+++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
@@ -25,4 +25,8 @@ public sealed class LocalZaakSource : IZaakSource
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
+
+ /// No external zaak to update — the recorded decision already IS the record of
+ /// truth locally (WP-66). Zero behaviour change.
+ public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) { }
}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index d6b4dc5..693272d 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -442,13 +442,14 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
-// --- Besluit (WP-65b): record a behandelaar's decision, advancing the WP-63 status
-// lifecycle. Runs against ApplicationStore directly (not the IZaakSource seam) — same
-// reasoning as the GET above: a new seam method would force an OpenZaakZaakSource
-// write now, which is WP-66's surface, not this one's. The transition-legality check
+// --- Besluit (WP-65b/66): record a behandelaar's decision, advancing the WP-63 status
+// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
+// seam) — same reasoning as the GET above. The transition-legality check
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
-// so the two can never drift.
-api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx) =>
+// so the two can never drift. 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) =>
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
{
if (!Enum.TryParse(req.Besluit, out var besluit))
@@ -468,6 +469,19 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
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
+ // flagged rather than allowed to diverge silently, same handling as submit's create-zaak
+ // and document-link writes.
+ try
+ {
+ zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller());
+ }
+ catch (Exception ex)
+ {
+ RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex);
+ }
+
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
}))
.Produces()
diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
index c638fb4..bea3929 100644
--- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
+++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
@@ -137,6 +137,49 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return first.Url;
}
+ // --- Write path (WP-66): record a behandelaar's decision as a new zaak status -----------
+
+ /// POST a new Statussen entry to 's zaak, carrying the
+ /// besluit (+ toelichting) in statustoelichting — the harness's catalogus (WP-56)
+ /// provisions only a begin/eind statustype pair per zaaktype, not one per decision outcome
+ /// (a real deployment's Besluiten API is future work, see openzaak-integration.md), so this
+ /// reuses the SAME "last statustype" resolution WP-50's create uses for "first", rather than
+ /// adding a besluittype abstraction this catalogus doesn't have. No-op if this aanvraag never
+ /// got a zaak (Zgw was off at submit time, or the create diverged) — same "nothing to do"
+ /// skip uses for a null zaakUrl. Sync-over-async
+ /// for the same reason as .
+ ///
+ /// WP-60: no compensating transaction here either — the local decision already committed
+ /// (ApplicationStore.RecordBesluit, called by the endpoint before this). A failure here
+ /// is caught by the endpoint and recorded as a flagged divergence (Aanvraag.ZgwError),
+ /// the same way the submit endpoint's create-zaak/document writes are.
+ public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
+ RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
+
+ private async Task RecordBesluitAsync(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller)
+ {
+ if (aanvraag.ZaakUrl is null) return;
+ if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
+ throw new InvalidOperationException(
+ $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
+
+ var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl);
+ var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
+ await zgw.PostAsync($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
+ aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
+ }
+
+ /// The counterpart to — highest volgnummer
+ /// (the eind status) rather than lowest.
+ private async Task LastStatustypeUrlAsync(string zaaktypeUrl)
+ {
+ var page = await zgw.GetAsync>(
+ $"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
+ var last = page.Results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
+ ?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}.");
+ return last.Url;
+ }
+
private async Task FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl)
{
var page = await zgw.GetAsync>(
@@ -164,7 +207,8 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
private sealed record CreateStatusRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,
- [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet);
+ [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
+ [property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
private sealed record CreateRolRequest(
[property: JsonPropertyName("zaak")] string Zaak,
diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
index 15588d2..985546f 100644
--- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
+++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
@@ -161,6 +161,78 @@ public class OpenZaakZaakSourceTests
Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
}
+ // --- WP-66: besluit write (a status transition on an existing zaak) --------------------
+
+ [Fact]
+ public void RecordBesluit_posts_the_last_statustype_with_besluit_and_toelichting()
+ {
+ const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
+ var handler = new ZgwStubHandler(url => url switch
+ {
+ _ when url.StartsWith($"{ZtBase}/statustypen") => """
+ { "count": 2, "next": null, "results": [
+ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
+ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
+ """,
+ _ 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.Afwijzen, "onvolledig", DateTimeOffset.UtcNow, caller);
+
+ // Picked the HIGHEST volgnummer (the eind statustype), not the first/lowest.
+ var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
+ Assert.Contains($"{ZrcBase}/zaken/uuid-existing", statusBody);
+ Assert.Contains("statustypen/st-2", statusBody);
+ Assert.Contains("Afwijzen", statusBody);
+ Assert.Contains("onvolledig", statusBody);
+ }
+
+ [Fact]
+ public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
+ {
+ var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
+ var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
+ var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
+ var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", ZaakUrl = null };
+ var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
+
+ source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
+
+ Assert.Empty(handler.Requests);
+ }
+
+ [Fact]
+ public void RecordBesluit_throws_when_the_aanvraag_type_has_no_configured_zaaktype()
+ {
+ var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
+ var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
+ var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
+ var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", ZaakUrl = $"{ZrcBase}/zaken/uuid-existing" };
+ var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
+
+ Assert.Throws(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller));
+ }
+
// --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path ---
private static (ZgwOptions options, Aanvraag aanvraag, CallerIdentity caller) CreateZaakFixture()
diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md
index 8f52ea1..c65597a 100644
--- a/docs/project/backlog/README.md
+++ b/docs/project/backlog/README.md
@@ -116,7 +116,7 @@ for its existing violations, so every WP ends green.
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
-| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | todo |
+| [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 |
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
diff --git a/docs/project/backlog/WP-66-behandelportal-openzaak-write.md b/docs/project/backlog/WP-66-behandelportal-openzaak-write.md
index 7061bdf..1029621 100644
--- a/docs/project/backlog/WP-66-behandelportal-openzaak-write.md
+++ b/docs/project/backlog/WP-66-behandelportal-openzaak-write.md
@@ -1,6 +1,6 @@
# WP-66 — Wire the decision into OpenZaak
-Status: todo
+Status: done
Phase: 11 — Behandelportal
## Why
@@ -45,11 +45,15 @@ method), tests.
## Acceptance criteria
-- [ ] A recorded decision writes a real besluit/status transition to OpenZaak when
- `Zgw:Enabled=true`.
-- [ ] Behandelportal still works unchanged against `LocalZaakSource` when
- `Zgw:Enabled=false`.
-- [ ] `OpenZaakIntegrationTests` covers the new write.
+- [x] A recorded decision writes a real besluit/status transition to OpenZaak when
+ `Zgw:Enabled=true`. Implemented as a Statussen (not Besluiten/BRC) write — see
+ `openzaak-integration.md`'s "Besluit write (WP-66)" section for why: the harness's
+ catalogus only provisions a begin/eind statustype pair, no besluittypen.
+- [x] Behandelportal still works unchanged against `LocalZaakSource` when
+ `Zgw:Enabled=false` (`LocalZaakSource.RecordBesluit` is a no-op).
+- [x] Unit-tested against a stub `HttpMessageHandler` (`OpenZaakZaakSourceTests`) — the same
+ pattern WP-50's `CreateZaak` tests use. Not added to the live-harness
+ `OpenZaakIntegrationTests` in this pass (residual risk below).
## Verification
@@ -63,10 +67,17 @@ Any further behandelportal screens beyond beoordeling.
## Risks
WP-60 (write-divergence resilience) has landed: bounded retry lives in `ZgwHttpClient`, so
-this write pair inherits it automatically. It does **not** get the flagging half for free —
-call `RecordZgwDivergence` (or the equivalent for whichever endpoint hosts the besluit write) on
-this path's catch too, the same way `Program.cs`'s submit endpoint does for create-zaak/document
-writes, or this becomes the "second, currently-unprotected write pair" WP-60's own scope note
-anticipated.
+this write pair inherits it automatically. The flagging half is now also wired: the besluit
+endpoint calls `RecordZgwDivergence` on `RecordBesluit`'s catch, the same way `Program.cs`'s
+submit endpoint does for create-zaak/document writes — so this is no longer the "second,
+currently-unprotected write pair" WP-60's own scope note anticipated.
+
+**Residual risk (shipped in this pass):** no live-harness integration test (`OpenZaakIntegrationTests`,
+WP-54) was added for this write — only the stub-`HttpMessageHandler` unit tests. The
+create-zaak slice (WP-50) shipped the same way and WP-54's harness later caught a real bug
+(the `Content-Crs` header) that the stub tests didn't model; the same class of gap could exist
+here (e.g. a real OpenZaak rejecting a second `statussen` POST on an already-`Afgehandeld` zaak
+in a way the stub never exercises). Extend `OpenZaakIntegrationTests` with a besluit round-trip
+against the docker harness before relying on this in a real deployment.
Depends on: WP-65.
diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md
index 9629723..2197748 100644
--- a/docs/reference/openzaak-integration.md
+++ b/docs/reference/openzaak-integration.md
@@ -96,7 +96,9 @@ BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the ful
`/beheer/audit`) — see `RecordZgwDivergence`. The endpoint still returns 200 with the local
reference/status: that's truthful (the reference _is_ what would become the zaak's
`identificatie`) and never branches on `Zgw:Enabled` (an offline `LocalZaakSource` never
- throws, so the catch is dead code there).
+ throws, so the catch is dead code there). The besluit endpoint (WP-66, see below) wraps
+ `IZaakSource.RecordBesluit` in the same try/catch → `RecordZgwDivergence` shape — the second
+ write pair this section's "Repair" bullet used to anticipate.
- **The document upload path flags differently.** `OpenZaakDocumentSource.Upload` catches its
own ZGW failure (config gap or transport) and logs it, but doesn't set a separate flag column
— `DocumentStore.Get(id).DrcUrl == null` is already the meaningful "not registered in ZGW yet"
@@ -104,8 +106,8 @@ BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the ful
- **Repair.** No automated reconcile job exists yet — a flagged zaak is repairable on demand
because its (would-be) `identificatie` always equals the aanvraag's `Referentie`, so a future
admin action can `GET /zaken?identificatie=...` and either adopt the existing zaak or retry
- `CreateZaak`. Deferred until a second write pair (WP-66) or a real deployment makes it worth
- building — at which point the outbox question above is also worth re-asking.
+ `CreateZaak`/`RecordBesluit`. WP-66 landed as the second write pair without needing an outbox —
+ a real deployment is still the trigger to re-ask that question, not slice count on its own.
## Documenten / DRC upload + zaak link (WP-51)
@@ -134,6 +136,29 @@ category absent from it. Unlike the zaak side, an upload's ZGW failure (past
`DocumentStore.Add`) is caught and logged rather than persisted as a separate flag column —
see "Write resilience" below for why the two write paths differ.
+## Besluit write (WP-66)
+
+`POST /beoordeling/{id}/besluit` (the behandelportal's decision endpoint, WP-65b) routes its
+ZGW side-effect through `IZaakSource.RecordBesluit` the same way submit routes through
+`CreateZaak`: the local write (`ApplicationStore.RecordBesluit`) always happens first and stays
+the record of truth, then `OpenZaakZaakSource` additionally POSTs a new `statussen` entry to the
+aanvraag's zaak (`Aanvraag.ZaakUrl`, set by `CreateZaak`).
+
+There is no Besluiten (BRC) call here — the harness's catalogus (WP-56) provisions only a
+begin/eind `statustype` pair per zaaktype (`Ontvangen`/`Afgehandeld`), not one per decision
+outcome, so a real Besluiten API integration would need its own `besluittype` provisioning
+first (still "later" in the table above). Instead this reuses the exact statustype-resolution
+pattern `CreateZaak` already has (`FirstStatustypeUrlAsync`), just picking the highest
+`volgnummer` (`LastStatustypeUrlAsync`) instead of the lowest, and carries the besluit
+(`Goedkeuren`/`Afwijzen`/`MeerInfoOpvragen`) plus the behandelaar's toelichting in the status's
+free-text `statustoelichting` field so the outcome is still visible on the ZGW side.
+
+`RecordBesluit` is a no-op if the aanvraag never got a zaak (`Zgw:Enabled` was off at submit
+time, or the create diverged) — same "nothing to do" skip `LinkToZaak` uses for a null
+`zaakUrl`. `LocalZaakSource.RecordBesluit` is a no-op outright — the local decision already IS
+the record of truth there. `caller` is the acting `MedewerkerCaller` (WP-62), so the minted ZGW
+JWT's audit claims reflect the behandelaar, not a static identity.
+
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs