Compare commits

...

6 Commits

Author SHA1 Message Date
4a7dc07786 docs(workflow): ADR-0016 + demo note for diploma-eligibility DMN (S-13, refs #14)
Some checks failed
CI / lint (pull_request) Successful in 1m18s
CI / build (pull_request) Successful in 58s
CI / unit (pull_request) Successful in 1m6s
CI / frontend (pull_request) Successful in 2m36s
CI / mutation (pull_request) Successful in 5m14s
CI / verify-stack (pull_request) Failing after 4m23s
Record the DMN-as-businessRuleTask decision (from proposal #100) and add the S-13
demo walkthrough showing a foreign diploma routing through CBGV-advies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:05:21 +02:00
5e1975ae84 test(workflow): diploma routing — acceptance + live foreign→CBGV path (S-13, refs #14)
Gherkin scenarios assert the submit carries the diploma origin (domestic/foreign)
into the process. verify-domain now submits a foreign registration and asserts it
parks at CBGVAdvies before Beoordelen, completes CBGV, then advances to Beoordelen
— exercising both DMN branches through the engine (the domestic DIRECT path is the
first registration already parking straight at Beoordelen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:04:12 +02:00
dc9cf41dc8 feat(workflow): diploma-eligibility DMN + CBGV-advies routing (S-13, refs #14)
Add workflows/diploma-eligibility.dmn (origin → route) and wire the registratie
BPMN: a businessRuleTask evaluates it inline and an exclusive gateway routes a
foreign diploma through a new CBGVAdvies user task before Beoordelen, a domestic
one straight there (ADR-0016). Deploy the DMN to Flowable's DMN engine and stage
both workflow files into the fl-bpmn volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:01:37 +02:00
6955e0ff98 feat(workflow): emit diplomaOrigin start variable + accept it on submit (S-13, refs #14)
The Workflow Client now posts diplomaOrigin alongside registrationId when starting
the process, and the domain submit endpoint accepts an optional diplomaOrigin
(defaulting to domestic) so a foreign submission can be driven end-to-end. Adds the
aggregate + submit-forwarding tests now that the plumbing is in place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:55:45 +02:00
1969297c97 test(workflow): start carries diploma origin as a process variable (S-13, refs #14)
Add DiplomaOrigin (Binnenlands/Buitenlands) to the Registration aggregate and
submit command, and thread it through the process-start port so the workflow's
DMN can route on it (ADR proposal #100). Failing Workflow Client test asserts the
diplomaOrigin start variable; the client takes the origin but does not emit it yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:54:28 +02:00
7bcbc726ce feat(workflow): beoordeling escalation to teamlead after 14 days (S-14, closes #15) (#99)
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 56s
CI / unit (push) Successful in 1m9s
CI / frontend (push) Successful in 2m27s
CI / mutation (push) Successful in 5m11s
CI / verify-stack (push) Successful in 7m30s
## What & why

S-14: a beoordeling a behandelaar does not pick up within **14 days** escalates to the **teamlead**.

A non-interrupting `P14D` boundary timer on the `Beoordelen` user task fires an external-worker task
(`BeoordelingEscaleren`); the domain's escalation worker reassigns the still-open task's candidate group
from `behandelaar` to `teamlead`. The task keeps its identity — only who may claim it changes. The
escalation-via-external-worker decision is recorded in **ADR-0015** (proposal #98); it upholds §8.2
(the Workflow Client stays the only code that talks to Flowable) and keeps Flowable a stock image.

Closes #15

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [x] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (no new services; escalation is additive to the domain worker).
- [x] Docs updated (ADR-0015, demo note).
- [x] ADR added (`docs/architecture/adr-0015-beoordeling-escalation.md`).
- [x] Demo note in `docs/demo-script.md`.

## How it was built (TDD)

- **Workflow Client** (`IBeoordelingEscalatieClient`): acquire `BeoordelingEscaleren` jobs → find the open `Beoordelen` task in the instance → add `teamlead`/remove `behandelaar` candidate group → complete the job. Red → green.
- **Escalation drain loop** (`BeoordelingEscalatieProcessor`) + hosted `BeoordelingEscalatiePump`, mirroring the OpenZaak worker. Red → green.
- **BPMN**: non-interrupting `P14D` boundary timer on `Beoordelen` → external task → escalation end.
- **Both branches** (escalate after timeout; no-op when completed in time) covered by the `Een beoordeling escaleren` acceptance scenarios + Workflow Client unit tests.
- **Live integration**: `verify-domain` fires the timer early via Flowable's management API and asserts the reassignment to teamlead.

## Notes for reviewers

- Interface segregation: escalation is on `IBeoordelingEscalatieClient`, separate from the OpenZaak worker's `IExternalWorkerClient`.
- Reassignment is two REST hops (add teamlead, remove behandelaar); idempotent on redelivery — see ADR-0015 consequences.
- Local checks green: domain unit tests (104), acceptance (13), `dotnet format --verify-no-changes`, Release build (0 errors), **domain mutation 96.69%** (break 90). The `run-domain-check.sh` escalation path is CI-verified on verify-stack (local full-stack run is constrained here).
- `BeoordelingEscalatiePump` excluded from mutation, mirroring the existing `OpenZaakJobPump` exclusion.

Reviewed-on: #99
2026-07-17 09:45:36 +00:00
30 changed files with 1178 additions and 45 deletions

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

View File

@@ -0,0 +1,66 @@
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN businessRuleTask
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
## Context
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
governing the registration workflow" (Flowable as a peer orchestration module).
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
back. That is one option; it is not the only one, and it is not the cleanest.
## Decision
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
as a `businessRuleTask`; an exclusive gateway routes on its output. The domain's only new job is to
carry the diploma origin and pass it into the process as a start variable.**
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
`route` (`Buitenlands``CBGV_ADVIES`, otherwise `DIRECT`). A `businessRuleTask`
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there.
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
never evaluates the DMN and never learns the route — that is the process's concern.
- **Deployed like the BPMN.** The DMN is version-controlled in `workflows/` and deployed to the DMN
engine by the same `flowable-init` step (via `dmn-api/dmn-repository/deployments`), staged into the
`fl-bpmn` volume alongside the BPMN.
## Consequences
**Positive**
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
framing); business users can read/adjust the decision table without touching domain code.
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
- The domain change is minimal and additive: one value on the aggregate, one start variable.
**Negative / costs**
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
- The DMN and its `businessRuleTask` wiring are validated live (verify-domain drives a foreign
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
cover only that the origin is carried into the process.
## Alternatives considered
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
the domain to Flowable for a decision and splits the routing across two places (domain computes,
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.

View File

@@ -298,3 +298,87 @@ 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.
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
A registration's diploma origin decides its route. A DMN `businessRuleTask` in the registratie
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
DMN, not in code — a beheerder can read and adjust the decision table directly.
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
the domain directly, so the demo drives it through the domain endpoint:
```bash
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
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:
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
#
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
| 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"}'
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
```
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
`businessRuleTask` sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
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.

View File

@@ -259,7 +259,7 @@ services:
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
@@ -267,7 +267,15 @@ services:
- -c
- |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmnbase=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
# Deploy the diploma-eligibility DMN to the DMN engine so the registratie process's
# businessRuleTask can resolve it by key (S-13, ADR-0016).
if curl -s -u rest-admin:test "$$dmnbase?name=diploma-eligibility" | grep -q '"name":"diploma-eligibility"'; then
echo "diploma-eligibility already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmnbase" >/dev/null && echo "deployed diploma-eligibility"
fi
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
else

View File

@@ -35,12 +35,12 @@ services:
condition: service_healthy
networks: [cg]
# Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
# Idempotent: skips if a deployment named "registratie" already exists.
# Deploys workflows/registratie.bpmn + diploma-eligibility.dmn via the REST API once flowable-rest is up.
# Idempotent: skips each if a deployment of that name already exists.
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
@@ -48,7 +48,15 @@ services:
- -c
- |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmnbase=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
# Deploy the diploma-eligibility DMN to the DMN engine so the registratie process's
# businessRuleTask can resolve it by key (S-13, ADR-0016).
if curl -s -u rest-admin:test "$$dmnbase?name=diploma-eligibility" | grep -q '"name":"diploma-eligibility"'; then
echo "diploma-eligibility already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmnbase" >/dev/null && echo "deployed diploma-eligibility"
fi
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
else

View File

@@ -157,4 +157,125 @@ for _ in $(seq 1 15); do
done
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the
# extra CBGVAdvies user task before Beoordelen (the DMN businessRuleTask sets route=CBGV_ADVIES and the
# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration
# above, which parked straight at Beoordelen. ──────────────────────────────────────────────────────
cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}'
echo ">> submitting a registration with a foreign diploma"
locf="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
reg_idf="${locf##*/}"
echo ">> foreign registration $reg_idf"
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
cbgv_task=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)"
cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -n "$cbgv_task" ] && break
sleep 2
done
[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> CBGVAdvies task $cbgv_task is waiting"
echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; }
echo ">> completing the CBGV-advies task"
flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> asserting it now advances to Beoordelen"
onward=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
[ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; }
sleep 2
done
[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; }
echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)"
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
echo ">> submitting a third registration to escalate"
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
reg_id3="${loc3##*/}"
echo ">> third registration $reg_id3"
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
t=next((t for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
candidate_groups() { python3 -c "import sys,json
try:
links=json.load(sys.stdin)
except Exception:
links=[]
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
# The first job id in a management jobs/timer-jobs response on stdin.
first_job_id() { python3 -c "import sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
print(((d.get('data') or [{}])[0]).get('id',''))"; }
echo ">> polling Flowable for its Beoordelen task"
task_id3=""; pid3=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
[ -n "$task_id3" ] && break
sleep 2
done
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
echo ">> asserting the task starts out claimable by the behandelaar group"
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
echo ">> firing the 14-day boundary timer early via the management API"
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
if [ -n "$async_id" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
escalated=""
for _ in $(seq 1 30); do
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
[ "$groups" = "teamlead" ] && { escalated=1; break; }
sleep 2
done
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
exit 0

View File

@@ -40,7 +40,7 @@ for key in "$@"; do
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
fl) populate rr-fl-bpmn "$here/../workflows/." ;; # registratie.bpmn + diploma-eligibility.dmn
*) echo "unknown seed key: $key" >&2; exit 2 ;;
esac
done

View File

@@ -21,6 +21,7 @@ builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>();
@@ -30,9 +31,13 @@ builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>();
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
// each overdue beoordeling to the teamlead (S-14).
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
var app = builder.Build();
@@ -43,7 +48,12 @@ app.MapGet("/health", () => "Healthy");
// a location to read the registration's progress (ADR-0009, eventual consistency).
app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) =>
{
var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn), ct);
// Diploma origin defaults to domestic; a foreign (eIDAS) submission passes "Buitenlands" so the
// workflow's DMN routes it through CBGV-advies (S-13). An unknown value is a bad request.
if (!Enum.TryParse<DiplomaOrigin>(body.DiplomaOrigin, ignoreCase: true, out var origin) && body.DiplomaOrigin is not null)
return Results.BadRequest(new { error = $"Unknown diplomaOrigin '{body.DiplomaOrigin}'. Expected 'Binnenlands' or 'Buitenlands'." });
var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn, origin), ct);
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
});
@@ -112,7 +122,7 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
await app.RunAsync();
public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record SubmitRegistrationRequest(string Bsn, string? DiplomaOrigin = null);
public sealed record DecideRequest(string Besluit);

View File

@@ -11,10 +11,12 @@ public interface IWorkflowClient
{
/// <summary>
/// Start one <c>registratie</c> process instance for the given registration, carrying the
/// registration id so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
/// aggregate. Returns the process instance id.
/// registration id (so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
/// aggregate) and the diploma origin (so the workflow's DMN can route foreign diplomas through
/// CBGV-advies, S-13). Returns the process instance id.
/// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
Task<string> StartRegistrationProcessAsync(
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
@@ -86,3 +88,10 @@ public interface IRegistrationStore
/// it) and the registration id it carries as a process variable.
/// </summary>
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
/// <summary>
/// An acquired <c>BeoordelingEscaleren</c> escalation job (S-14): the Flowable job id and the process
/// instance whose still-open <c>Beoordelen</c> task must be reassigned from behandelaar to teamlead
/// once the 14-day boundary timer fires (ADR-0015).
/// </summary>
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);

View File

@@ -2,8 +2,10 @@ using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts.</summary>
public sealed record SubmitRegistrationCommand(string Bsn);
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts. The
/// diploma origin defaults to domestic (the DigiD path); a foreign (eIDAS) submission sets it to
/// <see cref="DiplomaOrigin.Buitenlands"/> so the workflow's DMN routes it through CBGV-advies (S-13).</summary>
public sealed record SubmitRegistrationCommand(string Bsn, DiplomaOrigin DiplomaOrigin = DiplomaOrigin.Binnenlands);
/// <summary>
/// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it,
@@ -18,13 +20,14 @@ public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient
{
ArgumentNullException.ThrowIfNull(command);
var registration = Registration.Submit(command.Bsn);
var registration = Registration.Submit(command.Bsn, command.DiplomaOrigin);
// Persist before starting the process so the worker can correlate the OpenZaakAanmaken
// job back to an aggregate that already exists (ADR-0009).
await store.SaveAsync(registration, ct);
var processInstanceId = await workflow.StartRegistrationProcessAsync(registration.Id, ct);
var processInstanceId = await workflow.StartRegistrationProcessAsync(
registration.Id, registration.DiplomaOrigin, ct);
registration.RecordProcessStarted(processInstanceId);
await store.SaveAsync(registration, ct);

View File

@@ -0,0 +1,17 @@
namespace Big.Domain;
/// <summary>
/// Where a zorgprofessional's diploma was issued. It is the input to the diploma-eligibility decision
/// (S-13): a <see cref="Buitenlands"/> (foreign) diploma routes the registratie through an extra
/// CBGV-advies assessment step, a <see cref="Binnenlands"/> (domestic) one goes straight to beoordeling.
/// The decision itself lives in the workflow's DMN, not here (ADR-0016); the domain only carries the
/// origin and hands it to the process as a start variable.
/// </summary>
public enum DiplomaOrigin
{
/// <summary>A Dutch (domestic) diploma. Default for a registration submitted via DigiD.</summary>
Binnenlands,
/// <summary>A foreign diploma (e.g. an eIDAS submission). Triggers the CBGV-advies step.</summary>
Buitenlands,
}

View File

@@ -7,10 +7,11 @@ namespace Big.Domain;
/// </summary>
public sealed class Registration
{
private Registration(RegistrationId id, string bsn)
private Registration(RegistrationId id, string bsn, DiplomaOrigin diplomaOrigin)
{
Id = id;
Bsn = bsn;
DiplomaOrigin = diplomaOrigin;
Status = RegistrationStatus.Ingediend;
}
@@ -20,6 +21,10 @@ public sealed class Registration
/// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary>
public string Bsn { get; }
/// <summary>Where the diploma was issued. Rides along to the process as a start variable and
/// drives the diploma-eligibility DMN's foreign→CBGV-advies routing (S-13, ADR-0016).</summary>
public DiplomaOrigin DiplomaOrigin { get; }
public RegistrationStatus Status { get; private set; }
/// <summary>The Flowable process instance driving this registration, once started.</summary>
@@ -28,11 +33,13 @@ public sealed class Registration
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
public Uri? ZaakUrl { get; private set; }
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.</summary>
public static Registration Submit(string bsn)
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.
/// The diploma origin defaults to <see cref="DiplomaOrigin.Binnenlands"/> — the common DigiD path;
/// a foreign (eIDAS) submission passes <see cref="DiplomaOrigin.Buitenlands"/>.</summary>
public static Registration Submit(string bsn, DiplomaOrigin diplomaOrigin = DiplomaOrigin.Binnenlands)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
return new Registration(RegistrationId.New(), bsn);
return new Registration(RegistrationId.New(), bsn, diplomaOrigin);
}
/// <summary>Record that the registratie workflow process has been started for this registration.</summary>

View File

@@ -0,0 +1,37 @@
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// One poll tick of the beoordeling-escalation worker (S-14, ADR-0015): acquire the parked
/// <c>BeoordelingEscaleren</c> jobs — the tokens the 14-day boundary timer on <c>Beoordelen</c> spawns
/// — reassign each instance's still-open <c>Beoordelen</c> task to the teamlead, and complete the job.
/// A job that fails is logged and left un-completed so Flowable redelivers it (§8.6). Split out from
/// the hosted pump so the acquire→reassign→complete logic is unit-testable without a running host.
/// </summary>
public sealed class BeoordelingEscalatieProcessor(
IBeoordelingEscalatieClient client,
ILogger<BeoordelingEscalatieProcessor> logger)
{
/// <summary>Acquire and process up to <paramref name="maxJobs"/> escalations. Returns the number acquired.</summary>
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
{
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(maxJobs, ct);
foreach (var job in jobs)
{
try
{
await client.ReassignBeoordelingToTeamleadAsync(job.ProcessInstanceId, ct);
await client.CompleteBeoordelingEscalatieJobAsync(job.JobId, ct);
}
catch (Exception ex)
{
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
logger.LogError(ex, "BeoordelingEscaleren job {JobId} failed; leaving it for redelivery.", job.JobId);
}
}
return jobs.Count;
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// The hosted polling loop of the beoordeling-escalation worker (S-14, ADR-0015): on an interval it
/// resolves a scoped <see cref="BeoordelingEscalatieProcessor"/> and asks it to drain the parked
/// <c>BeoordelingEscaleren</c> jobs. A deliberately thin shell — all acquire/reassign/complete logic
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
/// and loop resilience. Structurally identical to <see cref="OpenZaakJobPump"/>.
/// </summary>
public sealed class BeoordelingEscalatiePump(
IServiceScopeFactory scopeFactory,
FlowableOptions options,
ILogger<BeoordelingEscalatiePump> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<BeoordelingEscalatieProcessor>();
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
logger.LogError(ex, "BeoordelingEscaleren job poll failed; retrying after the poll interval.");
}
try
{
await Task.Delay(options.PollInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}

View File

@@ -15,21 +15,29 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient
{
private const string Topic = "OpenZaakAanmaken";
private const string EscalatieTopic = "BeoordelingEscaleren";
private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen";
private const string BehandelaarGroup = "behandelaar";
private const string TeamleadGroup = "teamlead";
private const string RegistrationIdVariable = "registrationId";
private const string DiplomaOriginVariable = "diplomaOrigin";
private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit";
private const string IngetrokkenMessage = "RegistratieIngetrokken";
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
public async Task<string> StartRegistrationProcessAsync(
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{
var request = new StartProcessRequest(
ProcessDefinitionKey,
[new Variable(RegistrationIdVariable, "string", registrationId.ToString())]);
[
new Variable(RegistrationIdVariable, "string", registrationId.ToString()),
new Variable(DiplomaOriginVariable, "string", diplomaOrigin.ToString()),
]);
var created = await PostAsync<StartProcessRequest, ProcessInstance>(
"service/runtime/process-instances", request, ct)
@@ -106,6 +114,47 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
{
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredEscalatieJob>>(
"external-job-api/acquire/jobs", request, ct) ?? [];
return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))];
}
public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
// The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting
// boundary timer); find that task in this instance so we can move it to the teamlead. If the
// behandelaar completed it just before the timer fired there is nothing to reassign — a
// best-effort no-op (the timer/completion race, cf. §8.6).
var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey);
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
"service/query/tasks", query, ct);
var task = page?.Data?.FirstOrDefault();
if (task is null)
return;
// Add teamlead, then drop behandelaar: the task now belongs to the teamlead group.
using (var added = await SendAsync(
$"service/runtime/tasks/{task.Id}/identitylinks",
new IdentityLinkRequest(TeamleadGroup, "candidate"), ct))
added.EnsureSuccessStatusCode();
await DeleteAsync(
$"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct);
}
public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
using var response = await SendAsync(
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
@@ -115,6 +164,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
private async Task DeleteAsync(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{
using var response = await SendAsync(path, body, ct);
@@ -154,6 +211,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
private sealed record TaskByInstanceQueryRequest(
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey);
private sealed record IdentityLinkRequest(
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("type")] string Type);
private sealed record ClaimTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("assignee")] string Assignee);
@@ -193,6 +258,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredEscalatieJob(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId);
private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)

View File

@@ -16,3 +16,23 @@ public interface IExternalWorkerClient
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
}
/// <summary>
/// The escalation side of the Workflow Client (S-14): the <c>BeoordelingEscaleren</c> external-worker
/// jobs parked by the 14-day boundary timer on <c>Beoordelen</c>, and the reassignment they drive.
/// Kept separate from <see cref="IExternalWorkerClient"/> (interface segregation) so the OpenZaak
/// worker never sees escalation. Implemented by <see cref="FlowableWorkflowClient"/> — the only code
/// that talks to Flowable (§8.2, ADR-0015).
/// </summary>
public interface IBeoordelingEscalatieClient
{
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>BeoordelingEscaleren</c> jobs.</summary>
Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default);
/// <summary>Reassign the still-open <c>Beoordelen</c> task in the given process instance from the
/// behandelaar group to teamlead. Best-effort no-op if the task is no longer open.</summary>
Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default);
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,82 @@
using Big.Application;
using Big.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Big.Tests;
// S-14 (#15): the escalation drain loop. Mirrors OpenZaakJobProcessor — acquire the parked
// BeoordelingEscaleren jobs, reassign each instance's Beoordelen task to teamlead, then complete the
// job. A job whose reassignment fails is logged and left un-completed for Flowable to redeliver (§8.6).
public class BeoordelingEscalatieProcessorTests
{
/// <summary>A fake escalation client: scripts the jobs to acquire, records reassignments and
/// completions, and can be told to throw on reassigning a given instance.</summary>
private sealed class FakeEscalatieClient(params EscalatieJob[] jobs) : IBeoordelingEscalatieClient
{
public int AcquireCount { get; private set; }
public List<string> Reassigned { get; } = [];
public List<string> Completed { get; } = [];
public string? ThrowOnInstance { get; set; }
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
{
AcquireCount++;
return Task.FromResult<IReadOnlyList<EscalatieJob>>(jobs.Take(maxJobs).ToList());
}
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
if (processInstanceId == ThrowOnInstance)
throw new InvalidOperationException("reassign failed");
Reassigned.Add(processInstanceId);
return Task.CompletedTask;
}
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
Completed.Add(jobId);
return Task.CompletedTask;
}
}
[Fact]
public async Task Acquires_an_escalation_reassigns_to_teamlead_and_completes_the_job()
{
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1"));
var acquired = await new BeoordelingEscalatieProcessor(
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
Assert.Equal(1, acquired);
Assert.Equal("pi-1", Assert.Single(client.Reassigned));
Assert.Equal("job-9", Assert.Single(client.Completed));
}
[Fact]
public async Task A_failing_reassign_is_left_uncompleted_for_flowable_to_redeliver()
{
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1")) { ThrowOnInstance = "pi-1" };
var logger = new CapturingLogger<BeoordelingEscalatieProcessor>();
var acquired = await new BeoordelingEscalatieProcessor(client, logger).PumpOnceAsync(5);
Assert.Equal(1, acquired);
Assert.Empty(client.Completed);
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
Assert.Contains("job-9", error.Message);
}
[Fact]
public async Task Does_nothing_but_poll_when_there_are_no_escalations()
{
var client = new FakeEscalatieClient();
var acquired = await new BeoordelingEscalatieProcessor(
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
Assert.Equal(0, acquired);
Assert.Equal(1, client.AcquireCount);
Assert.Empty(client.Completed);
}
}

View File

@@ -32,12 +32,15 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient
{
public RegistrationId? StartedFor { get; private set; }
public DiplomaOrigin? StartedWithOrigin { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
public Task<string> StartRegistrationProcessAsync(
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{
onStart?.Invoke(registrationId);
StartedFor = registrationId;
StartedWithOrigin = diplomaOrigin;
return Task.FromResult(processInstanceId);
}

View File

@@ -24,7 +24,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
var rid = RegistrationId.New();
var pid = await client.StartRegistrationProcessAsync(rid);
var pid = await client.StartRegistrationProcessAsync(rid, DiplomaOrigin.Binnenlands);
Assert.Equal("pi-1", pid);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
@@ -38,6 +38,22 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{rid}\"", capture.Body);
}
[Theory]
[InlineData(DiplomaOrigin.Binnenlands, "Binnenlands")]
[InlineData(DiplomaOrigin.Buitenlands, "Buitenlands")]
public async Task Start_posts_the_diploma_origin_as_a_process_variable(DiplomaOrigin origin, string expected)
{
// The diploma origin rides along as a start variable so the workflow's DMN can route foreign
// diplomas through CBGV-advies (S-13, ADR-0016).
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
await client.StartRegistrationProcessAsync(RegistrationId.New(), origin);
Assert.Contains("\"name\":\"diplomaOrigin\"", capture.Body);
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
}
[Fact]
public async Task Start_uses_the_configured_worker_credentials_and_defaults()
{
@@ -125,7 +141,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands));
}
[Fact]
@@ -135,7 +151,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands));
Assert.Contains("empty process-instance", ex.Message);
}
@@ -306,4 +322,108 @@ public class FlowableWorkflowClientTests
await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
}
// ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ───────────────
// The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as
// an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open
// Beoordelen task from the behandelaar group to teamlead, then completes the escalation job.
[Fact]
public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
"""[{"id":"job-9","processInstanceId":"pi-1"}]"""));
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3);
var job = Assert.Single(jobs);
Assert.Equal("job-9", job.JobId);
Assert.Equal("pi-1", job.ProcessInstanceId);
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
capture.Seen!.RequestUri!.ToString());
Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body);
Assert.Contains("\"numberOfTasks\":3", capture.Body);
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
}
[Theory]
[InlineData("[]")]
[InlineData("null")]
public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, body));
Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1));
Assert.NotNull(capture.Seen);
}
[Fact]
public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead()
{
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
var client = Client(new StubHandler(async req =>
{
requests.Add((req.Method, req.RequestUri!.ToString(),
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
// The task query returns the still-open Beoordelen task parked in this instance.
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
? new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""",
Encoding.UTF8, "application/json"),
}
: new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
// 1. Find the still-open Beoordelen task in this process instance.
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
Assert.Equal(HttpMethod.Post, query.Method);
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body);
// 2. Add teamlead as a candidate group on that task.
var add = requests.Single(r => r.Method == HttpMethod.Post
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks"));
Assert.Contains("\"group\":\"teamlead\"", add.Body);
Assert.Contains("\"type\":\"candidate\"", add.Body);
// 3. Remove behandelaar as a candidate group — the task now belongs to teamlead.
Assert.Contains(requests, r => r.Method == HttpMethod.Delete
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate"));
}
[Fact]
public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open()
{
// The behandelaar completed it just before the timer fired: nothing to reassign, no throw.
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
});
}));
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
Assert.DoesNotContain(HttpMethod.Delete, methods);
}
[Fact]
public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await client.CompleteBeoordelingEscalatieJobAsync("job-9");
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
}
}

View File

@@ -16,6 +16,15 @@ public class RegistrationTests
Assert.Null(registration.ProcessInstanceId);
}
[Fact]
public void A_registration_defaults_to_a_domestic_diploma()
=> Assert.Equal(DiplomaOrigin.Binnenlands, Registration.Submit("123456782").DiplomaOrigin);
[Fact]
public void A_foreign_diploma_submission_records_its_origin()
=> Assert.Equal(DiplomaOrigin.Buitenlands,
Registration.Submit("123456782", DiplomaOrigin.Buitenlands).DiplomaOrigin);
[Theory]
[InlineData("")]
[InlineData(" ")]

View File

@@ -25,6 +25,19 @@ public class SubmitRegistrationTests
Assert.Equal(2, store.SaveCount);
}
[Fact]
public async Task Submitting_a_foreign_diploma_carries_its_origin_to_the_process()
{
var store = new FakeRegistrationStore();
var workflow = new FakeWorkflowClient();
var handler = new SubmitRegistration(store, workflow);
var id = await handler.HandleAsync(new SubmitRegistrationCommand("123456782", DiplomaOrigin.Buitenlands));
Assert.Equal(DiplomaOrigin.Buitenlands, (await store.GetAsync(id))!.DiplomaOrigin);
Assert.Equal(DiplomaOrigin.Buitenlands, workflow.StartedWithOrigin);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store_or_workflow()
{

View File

@@ -4,7 +4,8 @@
"test-projects": ["Big.Tests/Big.Tests.csproj"],
"reporters": ["progress", "html"],
"mutate": [
"!**/OpenZaakJobPump.cs"
"!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs"
],
"thresholds": {
"high": 95,

View File

@@ -22,6 +22,9 @@
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
<!-- The beoordeling-escalation scenario drives the escalation worker (Infrastructure), as the
ACL scenario drives Acl.Infrastructure — escalation has no domain-aggregate surface (S-14). -->
<ProjectReference Include="..\..\services\domain\Big.Infrastructure\Big.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,22 @@
# language: en
# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
Feature: Een beoordeling escaleren
Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
zodat aanvragen niet blijven liggen.
Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
Given a registration parked at the Beoordelen task for the behandelaar
When the 14-day escalation timer fires
And the escalation worker runs
Then the beoordeling is reassigned to the teamlead
Scenario: Een tijdig afgeronde beoordeling escaleert niet
Given a registration parked at the Beoordelen task for the behandelaar
When the behandelaar completes the beoordeling before the timer fires
And the 14-day escalation timer fires
And the escalation worker runs
Then the beoordeling stays with the behandelaar

View File

@@ -0,0 +1,19 @@
# language: en
# Drives S-13 (#14). A registration's diploma origin decides its route: a domestic (Binnenlands)
# diploma goes straight to beoordeling, a foreign (Buitenlands) one is routed through an extra
# CBGV-advies step (PRD flow 4). The decision itself is a DMN evaluated inside the workflow
# (ADR-0016); the domain's part — verified here — is carrying the origin into the process so the DMN
# can route on it. The DMN evaluation and the CBGV routing are verified live (verify-domain).
Feature: Een diploma op herkomst routeren
Als register wil ik een aanvraag met een buitenlands diploma extra laten toetsen
zodat een CBGV-advies wordt ingewonnen voordat een behandelaar beoordeelt.
Scenario: Een binnenlands diploma start de registratie als binnenlands
Given a zorgprofessional with a "Binnenlands" diploma
When they submit their registration
Then the registratie process is started carrying a "Binnenlands" diploma
Scenario: Een buitenlands diploma start de registratie als buitenlands
Given a zorgprofessional with a "Buitenlands" diploma
When they submit their registration
Then the registratie process is started carrying a "Buitenlands" diploma

View File

@@ -0,0 +1,44 @@
using Acceptance.Support;
using Big.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenBeoordelingEscaleren.feature</c> (S-14). Drives the escalation worker
/// (<see cref="BeoordelingEscalatieProcessor"/>) against an in-memory Flowable stand-in; one instance
/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.</summary>
[Binding]
[Scope(Feature = "Een beoordeling escaleren")]
public sealed class EenBeoordelingEscalerenSteps
{
private readonly InMemoryEscalatieClient _flowable = new();
private string _processInstanceId = "";
[Given("a registration parked at the Beoordelen task for the behandelaar")]
public void GivenARegistrationParkedForTheBehandelaar()
=> _processInstanceId = _flowable.ParkBeoordeling();
[When("the 14-day escalation timer fires")]
public void WhenTheEscalationTimerFires()
=> _flowable.FireEscalationTimer(_processInstanceId);
[When("the behandelaar completes the beoordeling before the timer fires")]
public void WhenTheBehandelaarCompletesBeforeTheTimer()
=> _flowable.CompleteBeoordeling(_processInstanceId);
[When("the escalation worker runs")]
public async Task WhenTheEscalationWorkerRuns()
=> await new BeoordelingEscalatieProcessor(
_flowable, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
[Then("the beoordeling is reassigned to the teamlead")]
public void ThenTheBeoordelingIsReassignedToTheTeamlead()
=> Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
[Then("the beoordeling stays with the behandelaar")]
public void ThenTheBeoordelingStaysWithTheBehandelaar()
=> Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
}

View File

@@ -0,0 +1,36 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenDiplomaRouteren.feature</c> (S-13). Drives the submit use case against
/// in-memory ports and asserts the registratie process is started carrying the diploma origin — the
/// domain's contribution to flow 4. The DMN evaluation and the foreign→CBGV-advies routing it drives
/// are verified live (verify-domain); one instance per scenario.</summary>
[Binding]
[Scope(Feature = "Een diploma op herkomst routeren")]
public sealed class EenDiplomaRouterenSteps
{
private readonly InMemoryRegistrationStore _store = new();
private readonly InMemoryWorkflowClient _workflow = new();
private DiplomaOrigin _origin;
[Given("a zorgprofessional with a \"(.*)\" diploma")]
public void GivenAZorgprofessionalWithADiploma(string origin)
=> _origin = Enum.Parse<DiplomaOrigin>(origin, ignoreCase: true);
[When("they submit their registration")]
public async Task WhenTheySubmitTheirRegistration()
=> await new SubmitRegistration(_store, _workflow).HandleAsync(
new SubmitRegistrationCommand("123456782", _origin));
[Then("the registratie process is started carrying a \"(.*)\" diploma")]
public void ThenTheProcessIsStartedCarryingTheDiploma(string expected)
{
Assert.NotNull(_workflow.StartedFor);
Assert.Equal(Enum.Parse<DiplomaOrigin>(expected, ignoreCase: true), _workflow.StartedWithOrigin);
}
}

View File

@@ -1,5 +1,6 @@
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
namespace Acceptance.Support;
@@ -11,11 +12,14 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
public const string StartedProcessInstanceId = "proc-acc-1";
public RegistrationId? StartedFor { get; private set; }
public DiplomaOrigin? StartedWithOrigin { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
public Task<string> StartRegistrationProcessAsync(
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{
StartedFor = registrationId;
StartedWithOrigin = diplomaOrigin;
return Task.FromResult(StartedProcessInstanceId);
}
@@ -73,6 +77,59 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
}
}
/// <summary>An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.</summary>
public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
{
private sealed class ParkedTask
{
public string CandidateGroup { get; set; } = "behandelaar";
public bool IsOpen { get; set; } = true;
}
private readonly Dictionary<string, ParkedTask> _tasks = [];
private readonly List<EscalatieJob> _parked = [];
private int _seq;
/// <summary>A registration parks at Beoordelen, claimable by the behandelaar group.</summary>
public string ParkBeoordeling()
{
var pid = $"pi-{++_seq}";
_tasks[pid] = new ParkedTask();
return pid;
}
/// <summary>The behandelaar completes the beoordeling before the timer fires: the task closes.</summary>
public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
/// <summary>The 14-day boundary timer fires: a non-interrupting token parks an escalation job.</summary>
public void FireEscalationTimer(string processInstanceId)
=> _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
/// <summary>The candidate group that may currently pick the task up.</summary>
public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EscalatieJob>>(_parked.Take(maxJobs).ToList());
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
// No-op if the behandelaar already completed it — the timer/completion race (§8.6).
var task = _tasks[processInstanceId];
if (task.IsOpen)
task.CandidateGroup = "teamlead";
return Task.CompletedTask;
}
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
_parked.RemoveAll(j => j.JobId == jobId);
return Task.CompletedTask;
}
}
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
public sealed class InMemoryRegistrationStore : IRegistrationStore
{

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Diploma-eligibility decision (S-13, ADR-0016). Evaluated inline by the registratie process as a
BPMN businessRuleTask: given the diploma's origin, it sets the `route` the process should take.
A foreign (Buitenlands) diploma routes through the extra CBGV-advies assessment step; a domestic
one (or anything else) goes DIRECT to beoordeling. FIRST hit policy: the foreign rule wins, and
the empty-input catch-all is the default. -->
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
xmlns:flowable="http://flowable.org/dmn"
id="diplomaEligibilityDefinitions"
name="Diploma eligibility"
namespace="http://respellion.nl/big/dmn">
<decision id="diploma-eligibility" name="Diploma eligibility">
<decisionTable id="dt-diploma-eligibility" hitPolicy="FIRST">
<input id="in-origin" label="Diploma origin">
<inputExpression id="ie-origin" typeRef="string">
<text>diplomaOrigin</text>
</inputExpression>
</input>
<output id="out-route" label="Route" name="route" typeRef="string"/>
<rule id="rule-foreign">
<inputEntry id="rule-foreign-in">
<text>"Buitenlands"</text>
</inputEntry>
<outputEntry id="rule-foreign-out">
<text>"CBGV_ADVIES"</text>
</outputEntry>
</rule>
<rule id="rule-default">
<inputEntry id="rule-default-in">
<text></text>
</inputEntry>
<outputEntry id="rule-default-out">
<text>"DIRECT"</text>
</outputEntry>
</rule>
</decisionTable>
</decision>
</definitions>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:flowable="http://flowable.org/bpmn"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
@@ -15,7 +16,16 @@
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
to the task's execution; the BPMN owns the cancellation path. -->
to the task's execution; the BPMN owns the cancellation path.
S-14 adds escalation: a NON-interrupting boundary timer (P14D) on Beoordelen. If a behandelaar
has not picked the task up within 14 days it fires a parallel token to an external-worker task
(BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the
behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
only changes who may claim it.
S-13 adds diploma-eligibility routing: between OpenZaakAanmaken and Beoordelen a DMN
businessRuleTask evaluates the `diploma-eligibility` decision on the diplomaOrigin start
variable; an exclusive gateway routes a foreign diploma through the CBGV-advies user task
before Beoordelen, a domestic one straight there (ADR-0016). -->
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
@@ -28,7 +38,35 @@
flowable:type="external-worker"
flowable:topic="OpenZaakAanmaken"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="DiplomaEligibiliteit"/>
<!-- S-13: evaluate the diploma-eligibility DMN inline (ADR-0016). Flowable runs the deployed
`diploma-eligibility` decision against the diplomaOrigin start variable and sets the `route`
output as a process variable. The gateway then routes a foreign diploma through CBGV-advies,
a domestic one straight to Beoordelen. -->
<businessRuleTask id="DiplomaEligibiliteit" name="Diploma-eligibiliteit bepalen" flowable:type="dmn">
<extensionElements>
<flowable:field name="decisionTableReferenceKey">
<flowable:string><![CDATA[diploma-eligibility]]></flowable:string>
</flowable:field>
</extensionElements>
</businessRuleTask>
<sequenceFlow id="flow2a" sourceRef="DiplomaEligibiliteit" targetRef="RouteOpDiploma"/>
<exclusiveGateway id="RouteOpDiploma" name="Buitenlands diploma?" default="flowDirect"/>
<sequenceFlow id="flowCbgv" sourceRef="RouteOpDiploma" targetRef="CBGVAdvies">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${route == 'CBGV_ADVIES'}]]></conditionExpression>
</sequenceFlow>
<!-- The extra CBGV-style assessment for foreign diplomas (PRD flow 4). A CBGV medewerker
completes it, after which the case continues to the regular beoordeling. -->
<userTask id="CBGVAdvies" name="CBGV-advies" flowable:candidateGroups="cbgv"/>
<sequenceFlow id="flowCbgvNaarBeoordelen" sourceRef="CBGVAdvies" targetRef="Beoordelen"/>
<sequenceFlow id="flowDirect" sourceRef="RouteOpDiploma" targetRef="Beoordelen"/>
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
@@ -45,6 +83,25 @@
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
<!-- Escalation (S-14): non-interrupting P14D boundary timer on Beoordelen. On timeout a parallel
token runs EscaleerBeoordeling, an external-worker task the Workflow Client picks up to
reassign the still-open Beoordelen task from behandelaar to teamlead (ADR-0015). -->
<boundaryEvent id="EscaleerTimer" attachedToRef="Beoordelen" cancelActivity="false">
<timerEventDefinition>
<timeDuration>P14D</timeDuration>
</timerEventDefinition>
</boundaryEvent>
<sequenceFlow id="flow5" sourceRef="EscaleerTimer" targetRef="EscaleerBeoordeling"/>
<serviceTask id="EscaleerBeoordeling" name="Beoordeling escaleren"
flowable:type="external-worker"
flowable:topic="BeoordelingEscaleren"/>
<sequenceFlow id="flow6" sourceRef="EscaleerBeoordeling" targetRef="endEscaleren"/>
<endEvent id="endEscaleren" name="Beoordeling geëscaleerd"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
@@ -53,35 +110,79 @@
<omgdc:Bounds x="100" y="100" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
<omgdc:Bounds x="180" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_dmn" bpmnElement="DiplomaEligibiliteit">
<omgdc:Bounds x="350" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_route" bpmnElement="RouteOpDiploma" isMarkerVisible="true">
<omgdc:Bounds x="520" y="90" width="40" height="40"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_cbgv" bpmnElement="CBGVAdvies">
<omgdc:Bounds x="500" y="200" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
<omgdc:Bounds x="630" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
<omgdc:Bounds x="580" y="100" width="30" height="30"/>
<omgdc:Bounds x="810" y="100" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
<omgdc:Bounds x="690" y="135" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
<omgdc:Bounds x="690" y="250" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
<omgdc:Bounds x="700" y="70" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
<omgdc:Bounds x="800" y="20" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
<omgdc:Bounds x="970" y="35" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
<omgdi:waypoint x="130" y="115"/>
<omgdi:waypoint x="200" y="115"/>
<omgdi:waypoint x="180" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
<omgdi:waypoint x="320" y="115"/>
<omgdi:waypoint x="390" y="115"/>
<omgdi:waypoint x="300" y="115"/>
<omgdi:waypoint x="350" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2a" bpmnElement="flow2a">
<omgdi:waypoint x="470" y="115"/>
<omgdi:waypoint x="520" y="110"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flowDirect" bpmnElement="flowDirect">
<omgdi:waypoint x="560" y="110"/>
<omgdi:waypoint x="630" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flowCbgv" bpmnElement="flowCbgv">
<omgdi:waypoint x="540" y="130"/>
<omgdi:waypoint x="540" y="200"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flowCbgvNaarBeoordelen" bpmnElement="flowCbgvNaarBeoordelen">
<omgdi:waypoint x="620" y="230"/>
<omgdi:waypoint x="660" y="230"/>
<omgdi:waypoint x="660" y="145"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
<omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="580" y="115"/>
<omgdi:waypoint x="750" y="115"/>
<omgdi:waypoint x="810" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
<omgdc:Bounds x="435" y="135" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
<omgdc:Bounds x="435" y="220" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
<omgdi:waypoint x="450" y="165"/>
<omgdi:waypoint x="450" y="220"/>
<omgdi:waypoint x="705" y="165"/>
<omgdi:waypoint x="705" y="250"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
<omgdi:waypoint x="715" y="70"/>
<omgdi:waypoint x="715" y="50"/>
<omgdi:waypoint x="800" y="50"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
<omgdi:waypoint x="920" y="50"/>
<omgdi:waypoint x="970" y="50"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>