## What & why S-10a, the **workflow/timeout spine** of the (split) document-upload slice: the registratie process now parks at a **`WachtOpDocumenten`** user task with an **interrupting `P30D` boundary timer**. When the documents arrive the task completes and the process continues into the diploma routing (S-13) → Beoordelen; if the 30 days lapse, the timer cancels the wait, runs a `RegistratieVerlopen` external-worker task, and the domain expires the aggregate to a new terminal status **`Verlopen`**. Backend only — the real upload trigger (portal → BFF → ACL → Documenten API) is S-10b (#103). Closes #102 Mechanism recorded in **ADR-0017**; opened as proposal #104. Mirrors the S-14 escalation (boundary-timer + external-worker) and S-11 withdrawal (interrupting cancel) patterns. ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (red→green pairs per layer). - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issue (`refs #102`). - [ ] CI green — all Gitea Actions jobs (pending on this PR). - [x] `docker compose up` health unaffected (no new services; deploy path unchanged). - [x] Docs updated (ADR-0017, demo-script, BACKLOG split). - [x] ADR added (`docs/architecture/adr-0017-document-wait-timeout-cancellation.md`). - [x] Demo note in `docs/demo-script.md`. ## Notes for reviewers - **Domain** (`Registration.Expire()` + `Verlopen`), **application** (`ExpireRegistrationWorker`), **infra** (`RegistratieVerlopenProcessor`/`Pump`, `IRegistratieVerlopenClient`, Flowable acquire/complete + `CompleteDocumentWaitAsync`) — the timeout counterpart to the OpenZaak/escalation worker trios; idempotent per §8.6. - **BPMN** verified live against a `flowable-rest` probe: complete `WachtOpDocumenten` → routes to Beoordelen; fire the P30D timer → `RegistratieVerlopen` job (carrying `registrationId`) + the wait task cancelled. `verify-domain` exercises both branches in-stack (completes the wait in every existing block; fires the timer and asserts `Verlopen` in a new block). - **Scope boundary:** on expiry the aggregate goes `Verlopen` and the process ends, but the ZGW *zaak* is not yet set to a cancellation status — that needs a new ACL method + statustype seeding and is folded into S-10b (noted in ADR-0017). - `CompleteDocumentWaitAsync` is built and HTTP-tested here but not yet called from a domain endpoint; S-10b wires the upload trigger to it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #105
6.3 KiB
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
RegistratieVerlopenexternal-worker task (topic mirrorsOpenZaakAanmaken/BeoordelingEscaleren) →endVerlopen. - The domain stays authoritative. The
RegistratieVerlopenjob carries theregistrationId; theRegistratieVerlopenProcessordrains it and theExpireRegistrationWorkerloads the aggregate and callsRegistration.Expire(), moving it to the new terminal statusVerlopen. 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 alreadyVerlopencompletes without persisting again; an unknown registration throws so the job is redelivered. - Documents-in-time transition.
IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)completes theWachtOpDocumententask (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 is wired end-to-end in S-10a: aProvideDocumentsapplication use case behind an owner-scoped domain endpointPOST /registrations/{id}/documents, a BFF passthroughPOST /self-service/registrations/{id}/documents(bsn from the DigiD token), and a "Documenten aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration can still reach the behandelaar). S-10b replaces the stub trigger with a real file upload stored in the ZGW Documenten (DRC) API via the ACL; the completion of the wait is unchanged.- Why the trigger lives here, not in S-10b: inserting the
WachtOpDocumentengate without any way to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass the gate" across slices would leavemainred, so S-10a owns both; S-10b is purely the ZGW storage behind the same action.
- Why the trigger lives here, not in S-10b: inserting the
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
WachtOpDocumentenbefore Beoordelen, so the other flows must supply documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path through the process. - On expiry S-10a cancels the process and marks the aggregate
Verlopenbut 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. - Withdrawing while parked at
WachtOpDocumentenmarks the aggregateIngetrokkenbut does not cancel the process (the withdrawal message boundary is onBeoordelen); the timeout worker tolerates this by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
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 sameRequireOpenForDecisionguard as withdrawal/decision, so only anINGEDIEND/IN_BEHANDELINGregistration can lapse and the terminal states stay mutually exclusive — no new guard logic.