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).