## 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
5.5 KiB
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.
Geannuleerdis seeded atvolgnummer2 — betweenOntvangen(1) and theAfgehandeldeindstatus (3) — so it is a non-terminal status and never displaces the eindstatus the approval path resolves. A second resultaattypeVervallen(archiefnominatievernietigen) is seeded beside the approvalGeregistreerd(blijvend_bewaren); both draw theirselectielijstklassefrom the zaaktype's singleselectielijstProcestypeso they validate on publish. - The ACL owns the mapping.
OpenZaakGateway.SetZaakToCancellationStatusAsyncresolvesGeannuleerdVervallenby omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat before a closing/terminal status), mirroringSetZaakToEindstatusAsync. Exposed asAclService.CancelZaakAsyncbehind the ACL endpointPOST /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
Geregistreerdby omschrijving. Its statustype resolution is unchanged (still the eindstatus). - Domain wiring. The
ExpireRegistrationWorkercallsIAclClient.CancelZaakAsync(zaakUrl)before advancing the aggregate toVerlopen(ACL-first, mirroring approval): if the ACL call fails the job is redelivered (§8.6) rather than leaving the aggregateVerlopenwith 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
Geannuleerdwith a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is cancelled end-to-end.
Negative / costs
Geannuleerdis non-terminal, so the cancelled zaak'seinddatumis 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 reusingAfgehandeldwith aVervallenresultaat) 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
Afgehandeldfromvolgnummer2 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 seteinddatumon 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".