From d943b54ce8efa7bf1f6992ae64f5de4e95d531a6 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:59:36 +0200 Subject: [PATCH] docs(workflow): ADR-0017 + demo note for the document-wait timeout (refs #102) Records the interrupting P30D WachtOpDocumenten timer, the RegistratieVerlopen worker, and the new terminal Verlopen status; notes the S-10a/S-10b boundary (ZGW zaak-close deferred). Demo covers both branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0017-document-wait-timeout-cancellation.md | 79 +++++++++++++++++++ docs/demo-script.md | 53 ++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr-0017-document-wait-timeout-cancellation.md diff --git a/docs/architecture/adr-0017-document-wait-timeout-cancellation.md b/docs/architecture/adr-0017-document-wait-timeout-cancellation.md new file mode 100644 index 0000000..a799f40 --- /dev/null +++ b/docs/architecture/adr-0017-document-wait-timeout-cancellation.md @@ -0,0 +1,79 @@ +# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration + +- **Status:** Accepted +- **Date:** 2026-07-20 +- **Deciders:** Respellion engineering +- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task + worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling + escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN). + +## Context + +Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The +registratie process must park waiting for those documents and, if they do not arrive within 30 days, +cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only); +**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the +wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain +aggregate stays in sync. + +## Decision + +**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an +`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task +and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task, +runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain +worker expires the correlated aggregate to a new terminal status `Verlopen`.** + +- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak + exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen + (ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting + between the gateway and Beoordelen. +- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer + (non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the + wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The + timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors + `OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`. +- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the + `RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and + calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the + aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and + withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes + without persisting again; an unknown registration throws so the job is redelivered. +- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)` + completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to + Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or + timed out). The *trigger* that calls it (the portal upload) is wired in S-10b; S-10a builds and tests + the completion path with the trigger stubbed (the live check completes the task directly to prove the + in-time branch, and the domain acceptance drives the worker against an in-memory stand-in). + +## Consequences + +**Positive** + +- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker + pattern already proven by S-14, so the domain change is small and additive: one terminal status, one + worker trio (worker + processor + pump), one Workflow Client method. +- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is + introduced in S-10a. +- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom + and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015. + +**Negative / costs** + +- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other live-check blocks + (S-11/S-12b/S-13/S-14) must complete that task first — a small, explicit step standing in for the + S-10b upload until it lands. +- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW + *zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps + S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary. + +## Alternatives considered + +- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then + be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's + status — the case would still look open. +- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment + (including the CBGV-advies routing), so the wait belongs before the DMN, not after it. +- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same + `RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING` + registration can lapse and the terminal states stay mutually exclusive — no new guard logic. diff --git a/docs/demo-script.md b/docs/demo-script.md index 436fbcb..203d0bd 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -361,7 +361,8 @@ DOM=http://localhost:8080 # domain service curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \ -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:' # -# 2. Once the zaak is opened, the process parks at the CBGV-advies task (NOT Beoordelen). In Flowable: +# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task +# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable: FL=http://localhost:8090/flowable-rest/service curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool @@ -382,3 +383,53 @@ domestic: `Beoordelen` directly (§8.2, ADR-0016). > The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance > scenarios and unit tests (the origin is carried into the process); the DMN decision and the > foreign→CBGV routing are asserted live by the verify-domain check. + +## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017) + +After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting +for the citizen's documents (their diploma). Two things can happen: + +- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility + routing (S-13) → beoordeling. +- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the + `RegistratieVerlopen` external task, and the domain expires the registration to the terminal status + **VERLOPEN** (the case is cancelled). + +The real upload trigger (portal → BFF → domain → ACL → Documenten API) is S-10b; until then the +"documents received" step is completing the task in Flowable, and the timeout is demonstrated by +firing the timer early via the management API. + +```bash +DOM=http://localhost:8080 # domain service +FL=http://localhost:8090/flowable-rest/service # flowable-rest + +# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten: +curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \ + -d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/ reference +WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}' + +# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling. +TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ + -d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') +curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \ + -H 'Content-Type: application/json' -d '{"action":"complete"}' + +# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the +# instance's timer job, "move" it to executable; the async executor fires the interrupting event. +PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ + -d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])') +JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') +curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \ + -H 'Content-Type: application/json' -d '{"action":"move"}' +# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN: +curl -s "$DOM/registrations/" # → {"status":"Verlopen", ...} +``` + +**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→ +routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task +→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017). + +> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker + +> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the +> verify-domain check.