diff --git a/docs/architecture/adr-0015-beoordeling-escalation.md b/docs/architecture/adr-0015-beoordeling-escalation.md new file mode 100644 index 0000000..2b1a570 --- /dev/null +++ b/docs/architecture/adr-0015-beoordeling-escalation.md @@ -0,0 +1,77 @@ +# ADR-0015: Beoordeling escalation reassigns via an external-worker task + +- **Status:** Accepted +- **Date:** 2026-07-17 +- **Deciders:** Respellion engineering +- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow + Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event + pattern on `Beoordelen`). + +## Context + +S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must +move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by +the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm. + +Two forces shape this. + +1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the + work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while + escalation happens alongside it. +2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate + groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener + embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a + "stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable + (§8.2)" posture since ADR-0009. + +## Decision + +**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task +(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the +behandelaar group to teamlead.** + +- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an + `external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling + geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code + that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken` + delegates the ZGW call (ADR-0009). No custom code runs inside Flowable. +- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen` + task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds + `teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now + belongs to the teamlead; its history and variables are untouched. +- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar + completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment + leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the + `OpenZaakAanmaken` worker. +- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from + the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation + (interface segregation). Both are implemented by the one `FlowableWorkflowClient`. + +## Consequences + +**Positive** + +- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the + Workflow Client remains the sole Flowable client (§8.2 upheld, not bent). +- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope, + redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump. +- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day + timer. + +**Negative / costs** + +- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between + them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent + on redelivery. +- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the + timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP + exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014. + +## Alternatives considered + +- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected: + it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy + surface to the engine for no capability the external-worker route lacks. +- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh + teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a + candidate-group swap on the same task expresses "the same work, now the teamlead's" directly. diff --git a/docs/demo-script.md b/docs/demo-script.md index 5ece867..5c8b44c 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -298,3 +298,48 @@ interrupting boundary event ends it → the werkbak drops the case. > DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path > (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the > `Een registratie intrekken` acceptance scenarios and the domain live check. + +## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015) + +A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A +non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task; +the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to +`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps +its identity throughout — only who may claim it changes. + +The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the +verify-domain check automates): + +```bash +# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case +# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar) +# but NOT claimed. +# +# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early: +FL=http://localhost:8090/flowable-rest/service +PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ + -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])') +TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ + -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') +TJ=$(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/$TJ" \ + -H 'Content-Type: application/json' -d '{"action":"move"}' +AJ=$(curl -s -u rest-admin:test "$FL/management/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/jobs/$AJ" \ + -H 'Content-Type: application/json' -d '{"action":"execute"}' +# +# 3. Within a couple of poll cycles the task's candidate group flips to teamlead: +curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}] +``` + +**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren` +external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's +candidate group behandelaar → teamlead (§8.2). + +> Both branches (escalate after 14 days; no-op when completed in time) are covered by the +> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing +> and reassignment are asserted live by the verify-domain check.