docs(workflow): ADR-0015 + demo note for beoordeling escalation (S-14, refs #15)
Record the escalation-via-external-worker decision (ADR-0015, from proposal #98) and add the S-14 demo walkthrough that fires the 14-day timer early via Flowable's management API to observe the reassignment to teamlead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
docs/architecture/adr-0015-beoordeling-escalation.md
Normal file
77
docs/architecture/adr-0015-beoordeling-escalation.md
Normal file
@@ -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.
|
||||||
@@ -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
|
> 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
|
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||||
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
> `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.
|
||||||
|
|||||||
Reference in New Issue
Block a user