feat(acl,domain): cancel the ZGW zaak on document-timeout expiry (S-10c, closes #106) (#109)
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m1s
CI / frontend (push) Successful in 2m27s
CI / mutation (push) Successful in 5m34s
CI / verify-stack (push) Successful in 8m0s
CI / unit (push) Successful in 1m17s

## S-10c · Close the ZGW zaak on document-timeout expiry (closes #106)

Completes the S-10a/S-10b boundary flagged in ADR-0017: when a registration's 30-day document term lapses, the domain now cancels the **ZGW zaak** as well as marking the aggregate `Verlopen`, so OpenZaak and the register no longer diverge.

### What it does
On expiry the `ExpireRegistrationWorker` calls the ACL to set the zaak to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (vs the approval `Afgehandeld` + `Geregistreerd`), resolved **by omschrijving** in the ACL — the ACL-first ordering mirrors approval so a failed ZGW call leaves the job for redelivery rather than diverging the two.

**Path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL `POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd) → aggregate `Verlopen`.

### Layers touched (each red→green)
- **ACL gateway** — `SetZaakToCancellationStatusAsync` (Geannuleerd + Vervallen by name); approval now resolves its `Geregistreerd` resultaat by name too (a second resultaattype now exists).
- **ACL service/API** — `AclService.CancelZaakAsync` + `POST /annuleringen`.
- **Domain** — `IAclClient.CancelZaakAsync` + client; expiry worker cancels the zaak before advancing to `Verlopen`, guarded against redelivery double-cancel.
- **Seed** — non-terminal `Geannuleerd` statustype (volgnummer 2; `Afgehandeld` → 3) + `Vervallen` resultaattype, both idempotent by omschrijving and sharing the zaaktype's procestype.
- **Verify/integration** — ACL↔OpenZaak integration test (live `Geannuleerd` + resultaat); `run-domain-check.sh` fires the real P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD scenario asserts cancel-on-timeout vs untouched-when-in-time.
- **Docs** — ADR-0019 (cancellation modelling decision), demo-script, BACKLOG.

### Design note (ADR-0019)
ZGW allows only one eindstatus per zaaktype, so `Geannuleerd` is modelled as a **non-terminal** status (it records a cancellation status + resultaat but does not set `einddatum`). This follows the issue's explicit "distinct statustype + resultaat" outcome; the shared-eindstatus alternative is recorded in the ADR.

### Tests
Unit + acceptance all green locally (Acl 38, Big 134, Acceptance 17, Bff 33, EventSubscriber 19). Integration + verify-stack run in CI (need live OpenZaak + selectielijst egress).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
not
2026-07-21 13:58:15 +00:00
parent 0904df8db0
commit c8fdfbb699
23 changed files with 591 additions and 46 deletions
+38 -17
View File
@@ -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")
+48 -4
View File
@@ -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