diff --git a/BACKLOG.md b/BACKLOG.md
index c1c90a3..1bd3dca 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -215,7 +215,9 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
-**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a cancellation status (not just the domain aggregate → `Verlopen`). Adds a cancellation statustype/resultaattype to the seed + an ACL method + expiry-worker wiring. Carved from S-10b (ADR-0017/0018). Depends on #103.
+**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
+
+**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
### S-11 · Withdrawal (Flow 3)
diff --git a/docs/architecture/adr-0019-zaak-cancellation-on-timeout.md b/docs/architecture/adr-0019-zaak-cancellation-on-timeout.md
new file mode 100644
index 0000000..2b5f978
--- /dev/null
+++ b/docs/architecture/adr-0019-zaak-cancellation-on-timeout.md
@@ -0,0 +1,81 @@
+# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
+
+- **Status:** Accepted
+- **Date:** 2026-07-21
+- **Deciders:** Respellion engineering
+- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
+ reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
+
+## Context
+
+ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
+document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
+open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
+two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
+(§8.1, the only code that talks to ZGW).
+
+The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
+The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
+ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
+closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
+
+## Decision
+
+**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
+resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
+(name) rather than by position or the eindstatus flag alone.**
+
+- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
+ eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
+ resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
+ approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
+ zaaktype's single `selectielijstProcestype` so they validate on publish.
+- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
+ + `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
+ before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
+ `AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
+ constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
+- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
+ is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
+ by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
+- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
+ advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
+ redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
+ open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
+ registration that lapsed before its zaak was opened has nothing to cancel.
+
+## Consequences
+
+**Positive**
+
+- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
+- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
+ the change is additive and §8 stays clean (only the ACL talks to ZGW).
+- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
+ with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
+ cancelled end-to-end.
+
+**Negative / costs**
+
+- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
+ cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
+ domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
+ fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
+ `Vervallen` resultaat) is a possible follow-up.
+- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
+ constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
+ is documented in the gateway.
+- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
+ OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
+
+## Alternatives considered
+
+- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
+ cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
+ the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
+ explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
+- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
+ zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
+ the approval eindstatus resolution.
+- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
+ ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
diff --git a/docs/demo-script.md b/docs/demo-script.md
index 3004a40..20e3420 100644
--- a/docs/demo-script.md
+++ b/docs/demo-script.md
@@ -461,4 +461,24 @@ make verify-acl # → "Storing a diploma creates a real informatieobject
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
-> Setting the ZGW zaak to a cancellation status on 30-day expiry is a follow-up (S-10c, #106).
+## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
+
+When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` —
+it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set
+to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the
+approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019).
+
+```bash
+# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat
+# against a live OpenZaak:
+make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat"
+#
+# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts
+# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd):
+make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
+```
+
+**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL
+`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
+`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
+job for redelivery rather than diverging the two (ADR-0019).
diff --git a/infra/openzaak/seed_catalogus.py b/infra/openzaak/seed_catalogus.py
index ed4ad67..9aae3fb 100644
--- a/infra/openzaak/seed_catalogus.py
+++ b/infra/openzaak/seed_catalogus.py
@@ -10,7 +10,7 @@ Creates (if absent):
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
"""
-import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
+import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
@@ -77,8 +77,12 @@ def publish_zaaktype(zt):
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
"""
+ # Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between
+ # them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld
+ # eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a
+ # stale local stack must reset its OpenZaak volumes for the renumbering to take effect.
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
- for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
+ for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]:
if volgnummer not in have_st:
st, body = api("POST", "/statustypen", {
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
@@ -95,25 +99,42 @@ def publish_zaaktype(zt):
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
print("create roltype Aanvrager")
- if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
- print("skip resultaattype Geregistreerd")
+ # Two resultaattypen, keyed by omschrijving so each is created independently (idempotent):
+ # "Geregistreerd" — the approval outcome (S-09b)
+ # "Vervallen" — the document-timeout cancellation outcome (S-10c)
+ # Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two
+ # Selectielijst resultaten from a single procestype and set that procestype on the zaaktype.
+ have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")}
+ wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")]
+ if all(naam in have_rt for naam, _ in wanted):
+ print("skip resultaattypen Geregistreerd + Vervallen")
else:
- resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
+ # Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so
+ # both klassen validate against the zaaktype's selectielijstProcestype.
+ procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"]
+ resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"]
+ if len(resultaten) < len(wanted):
+ sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen")
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
- oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
- # The selectielijstklasse and the zaaktype must share a procestype.
- st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
+ oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"]
+
+ st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype})
if st != 200:
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
- st, body = api("POST", "/resultaattypen", {
- "zaaktype": zt["url"], "omschrijving": "Geregistreerd",
- "resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
- "archiefnominatie": "blijvend_bewaren",
- "brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
- })
- if st != 201:
- sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
- print("create resultaattype Geregistreerd")
+
+ for i, (naam, archiefnominatie) in enumerate(wanted):
+ if naam in have_rt:
+ print(f"skip resultaattype {naam}")
+ continue
+ st, body = api("POST", "/resultaattypen", {
+ "zaaktype": zt["url"], "omschrijving": naam,
+ "resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
+ "archiefnominatie": archiefnominatie,
+ "brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
+ })
+ if st != 201:
+ sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
+ print(f"create resultaattype {naam}")
if zt.get("concept", True):
st, body = api("POST", f"{zt['url']}/publish")
diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh
index 359f65c..3c360d6 100755
--- a/infra/run-domain-check.sh
+++ b/infra/run-domain-check.sh
@@ -309,10 +309,11 @@ done
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
-# ── S-10a: document timeout. A registration parks at WachtOpDocumenten and — unlike every block above —
-# its documents never arrive. We fire its 30-day boundary timer early via the management API; the
-# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The
-# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ────────────
+# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
+# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
+# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
+# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
+# registration to VERLOPEN (ADR-0017). ─────────────────────────────────────────────────────────────
echo ">> submitting a registration to let its document term lapse"
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
@@ -354,4 +355,47 @@ for _ in $(seq 1 30); do
done
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
+
+# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN
+# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with
+# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack).
+zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)"
+[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; }
+echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak"
+
+read_zaak_status() {
+ # -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty).
+ docker run --rm -i --network "$net" \
+ -e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \
+ -e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \
+ python:3-slim python - "$1" <<'PY'
+import base64, hashlib, hmac, json, os, sys, time, urllib.request
+cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"]
+b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
+def token():
+ hdr = {"alg": "HS256", "typ": "JWT"}
+ pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"}
+ seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode())
+ return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode()
+def get(url):
+ req = urllib.request.Request(url, headers={
+ "Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"})
+ with urllib.request.urlopen(req, timeout=30) as r:
+ return json.loads(r.read())
+zaak = get(sys.argv[1])
+status_url = zaak.get("status")
+if not status_url:
+ print(""); sys.exit(0)
+print(get(get(status_url)["statustype"]).get("omschrijving", ""))
+PY
+}
+
+geannuleerd=""
+for _ in $(seq 1 15); do
+ oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)"
+ [ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; }
+ sleep 2
+done
+[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
+echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
exit 0
diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs
index 9581455..1b1bf7f 100644
--- a/services/acl/Acl.Api/Program.cs
+++ b/services/acl/Acl.Api/Program.cs
@@ -32,6 +32,14 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
return Results.NoContent();
});
+// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype
+// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1).
+app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) =>
+{
+ await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct);
+ return Results.NoContent();
+});
+
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
@@ -55,6 +63,8 @@ public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record SetStatusRequest(string ZaakUrl);
+public sealed record CancelZaakRequest(string ZaakUrl);
+
public sealed record ZaakReferenceRequest(string ZaakUrl);
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs
index f692041..6f00fef 100644
--- a/services/acl/Acl.Application/AclService.cs
+++ b/services/acl/Acl.Application/AclService.cs
@@ -30,6 +30,18 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
+ ///
+ /// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
+ /// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
+ /// statustype/resultaat means "cancelled" (§8.1).
+ ///
+ public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(zaakUrl);
+
+ return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
+ }
+
/// The zaak's reference (its ZGW identificatie), for the read projection (#78).
public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs
index 73903f0..1e71337 100644
--- a/services/acl/Acl.Application/IZaakGateway.cs
+++ b/services/acl/Acl.Application/IZaakGateway.cs
@@ -13,6 +13,15 @@ public interface IZaakGateway
///
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
+ ///
+ /// Set the given zaak to the cancellation statustype ("Geannuleerd") and record the
+ /// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term
+ /// lapsed" (S-10c). Distinct from (approval): the gateway
+ /// resolves both the cancellation statustype and resultaattype from the catalogus by their
+ /// omschrijving, POSTs the resultaat then the status, dated .
+ ///
+ Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
+
/// Read the zaak's identificatie — the public-safe reference the register shows.
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).
Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
index 18907ab..5f33ba8 100644
--- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
+++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
@@ -8,6 +8,12 @@ namespace Acl.Infrastructure;
/// The only code that talks to OpenZaak's Zaken API (ADR-0001).
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
{
+ // The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These
+ // omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py).
+ private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome
+ private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c)
+ private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c)
+
public async Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
@@ -48,7 +54,9 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
- var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
+ // Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
+ // the first would be ambiguous (the Zaken API does not guarantee order).
+ var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct);
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
@@ -62,6 +70,27 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
"Setting the zaak status", ct);
}
+ public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(zaakUrl);
+ ArgumentNullException.ThrowIfNull(zaaktypeUrl);
+
+ // Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd
+ // is a non-terminal statustype, so it is never the eindstatus the approval path resolves).
+ var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct);
+ var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct);
+
+ // As with approval, OpenZaak wants the resultaat recorded before the status.
+ await PostAsync("/zaken/api/v1/resultaten",
+ new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()),
+ "Setting the zaak cancellation resultaat", ct);
+
+ await PostAsync("/zaken/api/v1/statussen",
+ new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(),
+ datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
+ "Setting the zaak cancellation status", ct);
+ }
+
public async Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
@@ -174,13 +203,23 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(eindstatus.Url);
}
- /// Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).
- private async Task ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
+ /// Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").
+ private async Task ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
+ {
+ var page = await GetCatalogusAsync("statustypen", zaaktypeUrl, "statustypen", ct);
+ var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving)
+ ?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}");
+ return new Uri(match.Url);
+ }
+
+ /// Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines
+ /// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation).
+ private async Task ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
{
var page = await GetCatalogusAsync("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
- var resultaattype = (page.Results ?? []).FirstOrDefault()
- ?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
- return new Uri(resultaattype.Url);
+ var match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
+ ?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
+ return new Uri(match.Url);
}
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
@@ -224,7 +263,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
private sealed record StatustypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer,
- [property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
+ [property: JsonPropertyName("isEindstatus")] bool IsEindstatus,
+ [property: JsonPropertyName("omschrijving")] string? Omschrijving);
private sealed record ResultaatDto(
[property: JsonPropertyName("zaak")] string Zaak,
@@ -234,7 +274,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("results")] IReadOnlyList? Results);
private sealed record ResultaattypeDto(
- [property: JsonPropertyName("url")] string Url);
+ [property: JsonPropertyName("url")] string Url,
+ [property: JsonPropertyName("omschrijving")] string? Omschrijving);
private sealed record CreatedDto(
[property: JsonPropertyName("url")] string Url);
diff --git a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
index 32dece6..0407918 100644
--- a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
+++ b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
@@ -114,6 +114,19 @@ public sealed class OpenZaakFixture : IDisposable
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
}
+ /// Resolve a statustype by its omschrijving (e.g. the S-10c "Geannuleerd" cancellation status).
+ public async Task FindStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct = default)
+ {
+ var query = new Uri(BaseUrl,
+ "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
+ var page = await GetJsonAsync(query, ct);
+ foreach (var st in page.GetProperty("results").EnumerateArray())
+ if (st.TryGetProperty("omschrijving", out var o) && o.GetString() == omschrijving)
+ return new Uri(st.GetProperty("url").GetString()!);
+
+ throw new InvalidOperationException($"No '{omschrijving}' statustype for zaaktype {zaaktypeUrl}");
+ }
+
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
private string MintToken()
diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs
index f9c4c13..d59d0d9 100644
--- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs
+++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs
@@ -75,6 +75,39 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
}
+ [Fact]
+ public async Task Cancelling_a_zaak_records_the_geannuleerd_status_and_a_resultaat()
+ {
+ var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
+ Assert.True(zaaktype is not null,
+ "No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
+ "seed it with OZ_PUBLISH=1 (`make integration` does this).");
+
+ var gateway = new OpenZaakGateway(stack.Http, stack.Options);
+ var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
+ Bronorganisatie: "517439943",
+ VerantwoordelijkeOrganisatie: "517439943",
+ Vertrouwelijkheidaanduiding: "openbaar",
+ Zaaktype: zaaktype!,
+ Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
+ Identificatie: Guid.NewGuid().ToString()));
+
+ await gateway.SetZaakToCancellationStatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
+
+ // The zaak's current status is the Geannuleerd statustype — distinct from the approval eindstatus.
+ var zaak = await stack.GetZaakAsync(zaakUrl);
+ var statusUrl = zaak.GetProperty("status").GetString();
+ Assert.False(string.IsNullOrEmpty(statusUrl), "the cancelled zaak has no current status");
+
+ var status = await stack.GetJsonAsync(new Uri(statusUrl!));
+ var geannuleerd = await stack.FindStatustypeByOmschrijvingAsync(zaaktype!, "Geannuleerd");
+ Assert.Equal(geannuleerd.ToString(), status.GetProperty("statustype").GetString());
+
+ // ...and a resultaat is recorded (OpenZaak requires it before a closing/terminal status).
+ Assert.False(string.IsNullOrEmpty(zaak.GetProperty("resultaat").GetString()),
+ "the cancelled zaak has no resultaat");
+ }
+
[Fact]
public async Task Storing_a_diploma_creates_a_real_informatieobject_related_to_the_zaak()
{
diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs
index fe2a81a..01cbcef 100644
--- a/services/acl/Acl.Tests/AclServiceTests.cs
+++ b/services/acl/Acl.Tests/AclServiceTests.cs
@@ -23,6 +23,14 @@ public class AclServiceTests
return Task.CompletedTask;
}
+ public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled;
+
+ public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
+ {
+ Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet);
+ return Task.CompletedTask;
+ }
+
public Uri? ReadReferenceFor;
public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
@@ -126,6 +134,34 @@ public class AclServiceTests
Assert.Null(gateway.Approved);
}
+ [Fact]
+ public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
+ {
+ var gateway = new FakeGateway();
+ var defaults = Defaults();
+ var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
+ var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
+
+ await service.CancelZaakAsync(zaak);
+
+ Assert.NotNull(gateway.Cancelled);
+ Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
+ Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
+ Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
+ // Cancellation must not touch the approval path.
+ Assert.Null(gateway.Approved);
+ }
+
+ [Fact]
+ public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
+ {
+ var gateway = new FakeGateway();
+ var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
+
+ await Assert.ThrowsAsync(() => service.CancelZaakAsync(null!));
+ Assert.Null(gateway.Cancelled);
+ }
+
[Fact]
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
{
diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs
index e58c050..f167f75 100644
--- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs
+++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs
@@ -173,7 +173,8 @@ public class OpenZaakGatewayTests
private sealed class OzRoutes
{
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
- public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}""";
+ public string ResultaattypenJson { get; init; } =
+ """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1","omschrijving":"Geregistreerd"}]}""";
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
@@ -251,6 +252,138 @@ public class OpenZaakGatewayTests
Assert.True(status.Length > 0);
}
+ [Fact]
+ public async Task Approving_selects_the_geregistreerd_resultaat_by_name_when_several_exist()
+ {
+ // Once S-10c adds a second resultaattype (Vervallen), picking the first is ambiguous — the
+ // Zaken API does not guarantee order. Approval must resolve its resultaat by omschrijving.
+ var rec = new Recorder();
+ var twoResultaattypen = """
+ {"results":[
+ {"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"},
+ {"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"}
+ ]}
+ """;
+
+ await Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = twoResultaattypen }))
+ .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
+
+ Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd\"",
+ rec.Sent("/resultaten").Body);
+ }
+
+ // --- SetZaakToCancellationStatusAsync (document-timeout cancellation / S-10c) ---
+
+ // A catalogus with the three statustypen S-10c seeds (Geannuleerd is non-terminal, below the
+ // Afgehandeld eindstatus) and both resultaattypen. Cancellation must resolve "Geannuleerd" and
+ // "Vervallen" by omschrijving, never the approval pair.
+ private const string CancellationStatustypenJson = """
+ {"results":[
+ {"url":"http://openzaak/catalogi/api/v1/statustypen/ontvangen","volgnummer":1,"omschrijving":"Ontvangen","isEindstatus":false},
+ {"url":"http://openzaak/catalogi/api/v1/statustypen/geannuleerd","volgnummer":2,"omschrijving":"Geannuleerd","isEindstatus":false},
+ {"url":"http://openzaak/catalogi/api/v1/statustypen/afgehandeld","volgnummer":3,"omschrijving":"Afgehandeld","isEindstatus":true}
+ ]}
+ """;
+
+ private const string CancellationResultaattypenJson = """
+ {"results":[
+ {"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"},
+ {"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"}
+ ]}
+ """;
+
+ [Fact]
+ public async Task Cancelling_records_the_vervallen_resultaat_then_the_geannuleerd_status_against_the_zaak()
+ {
+ var rec = new Recorder();
+
+ await Gateway(ApprovalStub(rec, new OzRoutes
+ {
+ StatustypenJson = CancellationStatustypenJson,
+ ResultaattypenJson = CancellationResultaattypenJson,
+ })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
+
+ // Resultaat precedes status (OpenZaak requires a resultaat before a closing/terminal status).
+ Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
+
+ var resultaat = rec.Sent("/resultaten");
+ Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
+ // The cancellation resultaat (Vervallen) is chosen by name — not the approval one (Geregistreerd).
+ Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/vervallen\"", resultaat.Body);
+
+ var status = rec.Sent("/statussen");
+ Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
+ // The Geannuleerd statustype is chosen by name — not the Afgehandeld eindstatus (approval).
+ Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/geannuleerd\"", status.Body);
+ Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
+ }
+
+ [Fact]
+ public async Task Cancelling_throws_when_the_zaaktype_has_no_geannuleerd_statustype()
+ {
+ var rec = new Recorder();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes
+ {
+ // Only the approval statustypen — no "Geannuleerd".
+ StatustypenJson = StatustypenPage(withEindstatusFlag: true),
+ ResultaattypenJson = CancellationResultaattypenJson,
+ })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("Geannuleerd", ex.Message);
+ }
+
+ [Fact]
+ public async Task Cancelling_rejects_a_null_zaak_without_calling_openzaak()
+ {
+ var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
+ await Assert.ThrowsAsync(() =>
+ Gateway(handler).SetZaakToCancellationStatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
+ }
+
+ [Fact]
+ public async Task Cancelling_rejects_a_null_zaaktype_without_calling_openzaak()
+ {
+ var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
+ await Assert.ThrowsAsync(() =>
+ Gateway(handler).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
+ }
+
+ [Fact]
+ public async Task Cancelling_surfaces_the_failure_when_recording_the_resultaat_is_rejected()
+ {
+ var rec = new Recorder();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes
+ {
+ StatustypenJson = CancellationStatustypenJson,
+ ResultaattypenJson = CancellationResultaattypenJson,
+ ResultaatPostStatus = HttpStatusCode.BadRequest,
+ })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("cancellation resultaat", ex.Message);
+ // It fails on the resultaat, before it ever posts the status.
+ Assert.Equal(-1, rec.IndexOf("/statussen"));
+ }
+
+ [Fact]
+ public async Task Cancelling_surfaces_the_failure_when_recording_the_status_is_rejected()
+ {
+ var rec = new Recorder();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes
+ {
+ StatustypenJson = CancellationStatustypenJson,
+ ResultaattypenJson = CancellationResultaattypenJson,
+ StatusPostStatus = HttpStatusCode.BadRequest,
+ })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("cancellation status", ex.Message);
+ }
+
[Fact]
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
{
@@ -325,7 +458,7 @@ public class OpenZaakGatewayTests
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
- Assert.Contains("No resultaattypen found", ex.Message);
+ Assert.Contains("'Geregistreerd' resultaattype", ex.Message);
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
Assert.Equal(-1, rec.IndexOf("/resultaten"));
Assert.Equal(-1, rec.IndexOf("/statussen"));
diff --git a/services/domain/Big.Application/ExpireRegistrationWorker.cs b/services/domain/Big.Application/ExpireRegistrationWorker.cs
index 05f8b94..b351355 100644
--- a/services/domain/Big.Application/ExpireRegistrationWorker.cs
+++ b/services/domain/Big.Application/ExpireRegistrationWorker.cs
@@ -9,7 +9,7 @@ namespace Big.Application;
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
/// .
///
-public sealed class ExpireRegistrationWorker(IRegistrationStore store)
+public sealed class ExpireRegistrationWorker(IRegistrationStore store, IAclClient acl)
{
///
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
@@ -31,6 +31,14 @@ public sealed class ExpireRegistrationWorker(IRegistrationStore store)
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
return;
+ // Cancel the ZGW zaak before advancing the aggregate (mirrors the approval path): if the ACL
+ // call fails it throws, the aggregate stays open, and the job is redelivered (§8.6) — rather
+ // than leaving the aggregate VERLOPEN while the zaak stays open. The status guard above stops a
+ // redelivered job from cancelling the zaak twice (a second resultaat would be a 400). A
+ // registration expired before its zaak was opened has nothing to cancel.
+ if (registration.ZaakUrl is not null)
+ await acl.CancelZaakAsync(registration.ZaakUrl, ct);
+
registration.Expire();
await store.SaveAsync(registration, ct);
}
diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs
index 9d2c5e7..1a88018 100644
--- a/services/domain/Big.Application/Ports.cs
+++ b/services/domain/Big.Application/Ports.cs
@@ -59,6 +59,12 @@ public interface IAclClient
/// zaak (§8.1). Returns the stored document's URL.
///
Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default);
+
+ ///
+ /// Cancel the zaak on document-timeout expiry (S-10c): the 30-day document term lapsed, so the ACL
+ /// translates this to the ZGW cancellation status/resultaat. The domain never names statustypen.
+ ///
+ Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default);
}
///
diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs
index 22c235c..0d387ef 100644
--- a/services/domain/Big.Infrastructure/AclHttpClient.cs
+++ b/services/domain/Big.Infrastructure/AclHttpClient.cs
@@ -31,6 +31,15 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
response.EnsureSuccessStatusCode();
}
+ public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(zaakUrl);
+
+ using var response = await http.PostAsJsonAsync(
+ new Uri(options.BaseUrl, "annuleringen"), new CancelZaakRequest(zaakUrl.ToString()), ct);
+ response.EnsureSuccessStatusCode();
+ }
+
public async Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
@@ -56,6 +65,8 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
+ private sealed record CancelZaakRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
+
private sealed record StoreDocumentRequest(
[property: JsonPropertyName("zaakUrl")] string ZaakUrl,
[property: JsonPropertyName("contentBase64")] string ContentBase64,
diff --git a/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs b/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs
index dafc963..e9b4379 100644
--- a/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs
+++ b/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs
@@ -10,10 +10,13 @@ public class ExpireRegistrationWorkerTests
{
private const string Bsn = "123456782";
- private static Registration Submitted(string processInstanceId = "proc-1")
+ // By the time the 30-day document timer fires, the zaak was opened long ago (OpenZaakAanmaken runs
+ // early in the flow), so a timed-out registration carries a zaak the worker can cancel.
+ private static Registration Submitted(string processInstanceId = "proc-1", Uri? zaakUrl = null)
{
var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId);
+ registration.AttachZaak(zaakUrl ?? FakeAclClient.DefaultZaakUrl);
return registration;
}
@@ -24,7 +27,7 @@ public class ExpireRegistrationWorkerTests
var registration = Submitted();
store.Seed(registration);
- await new ExpireRegistrationWorker(store).HandleAsync(
+ await new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
var saved = await store.GetAsync(registration.Id);
@@ -33,37 +36,60 @@ public class ExpireRegistrationWorkerTests
}
[Fact]
- public async Task An_already_verlopen_registration_is_not_persisted_again()
+ public async Task Cancels_the_zaak_via_the_acl_when_expiring_a_still_open_registration()
{
- // A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again.
+ // S-10c: expiring the aggregate is not enough — the ZGW zaak must also be set to its
+ // cancellation status, which the ACL owns (§8.1). The worker hands the ACL the zaak URL.
+ var store = new FakeRegistrationStore();
+ var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/timed-out");
+ var registration = Submitted(zaakUrl: zaak);
+ store.Seed(registration);
+ var acl = new FakeAclClient();
+
+ await new ExpireRegistrationWorker(store, acl).HandleAsync(
+ new RegistratieVerlopenJob("job-7", registration.Id));
+
+ Assert.Equal(1, acl.CancelCallCount);
+ Assert.Equal(zaak, acl.CancelledZaakUrl);
+ }
+
+ [Fact]
+ public async Task An_already_verlopen_registration_is_not_persisted_again_and_the_zaak_is_not_recancelled()
+ {
+ // A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again — and
+ // the ACL is not asked to cancel the zaak a second time (posting a second resultaat would 400).
var store = new FakeRegistrationStore();
var registration = Submitted();
registration.Expire();
store.Seed(registration);
+ var acl = new FakeAclClient();
- await new ExpireRegistrationWorker(store).HandleAsync(
+ await new ExpireRegistrationWorker(store, acl).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount);
+ Assert.Equal(0, acl.CancelCallCount);
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
- public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
+ public async Task An_already_resolved_registration_is_left_alone_and_the_zaak_is_not_cancelled()
{
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
- // into a redelivery loop.
+ // into a redelivery loop — and it must not cancel the zaak of a registration it didn't expire.
var store = new FakeRegistrationStore();
var registration = Submitted();
registration.Withdraw();
store.Seed(registration);
+ var acl = new FakeAclClient();
- await new ExpireRegistrationWorker(store).HandleAsync(
+ await new ExpireRegistrationWorker(store, acl).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount);
+ Assert.Equal(0, acl.CancelCallCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
@@ -73,12 +99,12 @@ public class ExpireRegistrationWorkerTests
var store = new FakeRegistrationStore();
await Assert.ThrowsAsync(() =>
- new ExpireRegistrationWorker(store).HandleAsync(
+ new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
new RegistratieVerlopenJob("job-7", RegistrationId.New())));
}
[Fact]
public async Task Rejects_a_null_job()
=> await Assert.ThrowsAsync(() =>
- new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!));
+ new ExpireRegistrationWorker(new FakeRegistrationStore(), new FakeAclClient()).HandleAsync(null!));
}
diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs
index ef539ef..de2a029 100644
--- a/services/domain/Big.Tests/Fakes.cs
+++ b/services/domain/Big.Tests/Fakes.cs
@@ -119,4 +119,14 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
StoredDiploma = (zaakUrl, content, fileName, contentType);
return Task.FromResult(DefaultDocumentUrl);
}
+
+ public Uri? CancelledZaakUrl { get; private set; }
+ public int CancelCallCount { get; private set; }
+
+ public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
+ {
+ CancelCallCount++;
+ CancelledZaakUrl = zaakUrl;
+ return Task.CompletedTask;
+ }
}
diff --git a/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs b/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs
index e13c950..5efcd57 100644
--- a/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs
+++ b/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs
@@ -30,7 +30,7 @@ public class RegistratieVerlopenProcessorTests
}
}
- private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store);
+ private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store, new FakeAclClient());
[Fact]
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
diff --git a/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature b/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature
index 7ec02bf..9158d90 100644
--- a/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature
+++ b/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature
@@ -14,6 +14,7 @@ Feature: Een documenttermijn laten verlopen
When the 30-day document timer fires
And the document-timeout worker runs
Then the registration is verlopen
+ And the zaak is cancelled in ZGW
Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
Given a registration parked at the WachtOpDocumenten task
@@ -21,3 +22,4 @@ Feature: Een documenttermijn laten verlopen
And the 30-day document timer fires
And the document-timeout worker runs
Then the registration is not verlopen
+ And the zaak is not cancelled in ZGW
diff --git a/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs b/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs
index 402cfde..59ebdee 100644
--- a/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs
+++ b/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs
@@ -17,8 +17,11 @@ namespace Acceptance.Steps;
[Scope(Feature = "Een documenttermijn laten verlopen")]
public sealed class EenDocumentTermijnVerlopenSteps
{
+ private static readonly Uri ZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-timeout");
+
private readonly InMemoryDocumentTimeoutClient _flowable = new();
private readonly Support.InMemoryRegistrationStore _store = new();
+ private readonly InMemoryAclClient _acl = new();
private Registration _registration = null!;
private string _processInstanceId = "";
@@ -26,6 +29,9 @@ public sealed class EenDocumentTermijnVerlopenSteps
public async Task GivenARegistrationParkedAtWachtOpDocumenten()
{
_registration = Registration.Submit("123456782");
+ // By the time it parks at WachtOpDocumenten its zaak has been opened (OpenZaakAanmaken runs
+ // earlier), so a timeout has a zaak to cancel.
+ _registration.AttachZaak(ZaakUrl);
await _store.SaveAsync(_registration);
_processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
}
@@ -39,7 +45,7 @@ public sealed class EenDocumentTermijnVerlopenSteps
[When("the document-timeout worker runs")]
public async Task WhenTheTimeoutWorkerRuns()
=> await new RegistratieVerlopenProcessor(
- _flowable, new ExpireRegistrationWorker(_store),
+ _flowable, new ExpireRegistrationWorker(_store, _acl),
NullLogger.Instance).PumpOnceAsync(5);
[Then("the registration is verlopen")]
@@ -49,4 +55,10 @@ public sealed class EenDocumentTermijnVerlopenSteps
[Then("the registration is not verlopen")]
public async Task ThenTheRegistrationIsNotVerlopen()
=> Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
+
+ [Then("the zaak is cancelled in ZGW")]
+ public void ThenTheZaakIsCancelled() => Assert.Equal(ZaakUrl, _acl.CancelledZaakUrl);
+
+ [Then("the zaak is not cancelled in ZGW")]
+ public void ThenTheZaakIsNotCancelled() => Assert.Null(_acl.CancelledZaakUrl);
}
diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs
index c63b05e..0400d19 100644
--- a/tests/acceptance/Support/InMemoryDomainPorts.cs
+++ b/tests/acceptance/Support/InMemoryDomainPorts.cs
@@ -60,6 +60,14 @@ public sealed class InMemoryAclClient : IAclClient
return Task.CompletedTask;
}
+ public Uri? CancelledZaakUrl { get; private set; }
+
+ public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
+ {
+ CancelledZaakUrl = zaakUrl;
+ return Task.CompletedTask;
+ }
+
public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; }
public Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs
index e9e205d..d16d353 100644
--- a/tests/acceptance/Support/InMemoryZaakGateway.cs
+++ b/tests/acceptance/Support/InMemoryZaakGateway.cs
@@ -12,6 +12,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway
public ZaakRequest? Captured { get; private set; }
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
+ public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { get; private set; }
public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{
@@ -25,6 +26,12 @@ public sealed class InMemoryZaakGateway : IZaakGateway
return Task.CompletedTask;
}
+ public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
+ {
+ Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet);
+ return Task.CompletedTask;
+ }
+
public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("ACC-REF-1");