diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh
index 359f65c..4455ba3 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,46 @@ 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() {
+ docker run --rm --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.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()
{