Compare commits

...
Author SHA1 Message Date
notandClaude Opus 4.8 92b19e5d7b arch(workflow): ADR-0014 — withdrawal cancels the process via a message event (refs #12)
CI / lint (pull_request) Failing after 1m16s
CI / mutation (pull_request) Has been cancelled
CI / unit (pull_request) Failing after 1m1s
CI / build (pull_request) Failing after 53s
CI / verify-stack (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 18ab868724 test(verify): the domain check exercises withdrawal cancelling the Beoordelen task (refs #12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 edffbb4575 feat(domain): withdrawal cancels the registratie process via a BPMN message event (refs #12)
Add an interrupting message boundary event (RegistratieIngetrokken) on the Beoordelen task that

ends the process; the Workflow Client delivers the message to the task's execution, and the

withdraw handler triggers it best-effort after the domain transition (ADR-0014).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 2a6874ef6d test(domain): withdrawal cancels the workflow via the Beoordelen task's execution (refs #12)
The WithdrawRegistration handler delivers the withdrawal message to the open Beoordelen task's

execution; the Workflow Client PUTs messageEventReceived (RegistratieIngetrokken). BeoordelingTask

now carries its executionId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 435db81bdf feat(domain): the werkbak lists only registrations still open for beoordeling (refs #12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:43 +02:00
notandClaude Opus 4.8 345191b1df test(domain): the werkbak drops a registration that is no longer open (refs #12)
A withdrawn registration may keep a lingering Beoordelen task until the workflow cancels it; the

werkbak must list only registrations still open for beoordeling, so it drops off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:43 +02:00
12 changed files with 291 additions and 28 deletions
@@ -0,0 +1,71 @@
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Respellion engineering
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
(behandel-portal wiring, the Beoordelen user task)
## Context
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
Two questions shape this sub-slice.
1. **How does the case get cancelled — in code, or in the BPMN model?**
2. **How does a withdrawal correlate to the right running process instance?**
## Decision
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated via the Beoordelen task's execution.** The `WithdrawRegistration` handler already
knows the registration; it finds the open `Beoordelen` task for it (the same task-query the werkbak
uses, §8.2) and asks the Workflow Client to deliver the withdrawal message to that task's execution
(`messageEventReceived`). No separate correlation store is needed — the werkbak task set is the
authoritative correlation, exactly as the beoordeling decision reuses it (ADR-0013).
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
completes its task best-effort.
## Consequences
**Positive**
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
that talks to Flowable and does not delete instances behind the model's back.
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
**Negative / costs**
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
reference at these volumes; a process-level interrupting event subprocess would close the gap and
is an additive follow-up if it matters.
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
(consistent with ADR-0009).
## Alternatives considered
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
not the process. The message event keeps the cancellation a first-class part of the workflow.
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
change if the race proves to matter.
+42 -5
View File
@@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
fl_base="http://$(ip "$fl"):8080/flowable-rest/service" fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}" reg_id="${loc##*/}"
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin. # Extracts the Beoordelen task id for a given registration from a Flowable task-query response on
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. # stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
try: try:
d=json.load(sys.stdin) d=json.load(sys.stdin)
except Exception: except Exception:
@@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id="" task_id=""
for _ in $(seq 1 30); do 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)" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg)" task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -n "$task_id" ] && break [ -n "$task_id" ] && break
sleep 2 sleep 2
done done
@@ -115,7 +115,44 @@ flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/j
echo ">> asserting the process finished (no Beoordelen task remains for the registration)" echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg)" still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
echo ">> submitting a second registration to withdraw"
loc2="$(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 "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
echo ">> polling Flowable for its Beoordelen task"
task_id2=""
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)"
task_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -n "$task_id2" ] && break
sleep 2
done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone=""
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)"
still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -z "$still2" ] && { gone=1; break; }
sleep 2
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)"
exit 0 exit 0
+9 -2
View File
@@ -54,11 +54,18 @@ public interface IUserTaskClient
/// <summary>Complete a beoordeling task, carrying the decision into the process as the /// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary> /// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default); Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
/// <summary>Deliver the withdrawal message to a <c>Beoordelen</c> task's execution, tripping the
/// process's interrupting message boundary event so the registratie process cancels (S-11,
/// ADR-0014). The registration itself is already INGETROKKEN in the domain; this ends its
/// workflow so the case leaves the werkbak.</summary>
Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default);
} }
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and /// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary> /// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId); /// event), and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId);
/// <summary> /// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice /// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
+10 -2
View File
@@ -1,3 +1,5 @@
using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the /// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
@@ -8,7 +10,8 @@ public sealed record WerkbakItem(string RegistrationId, string Bsn, string Statu
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the /// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) — /// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task /// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
/// whose registration the domain doesn't know is skipped rather than invented. /// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
/// </summary> /// </summary>
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
{ {
@@ -20,7 +23,7 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
foreach (var task in open) foreach (var task in open)
{ {
var registration = await store.GetAsync(task.RegistrationId, ct); var registration = await store.GetAsync(task.RegistrationId, ct);
if (registration is null) if (registration is null || !IsOpenForBeoordeling(registration.Status))
continue; continue;
items.Add(new WerkbakItem( items.Add(new WerkbakItem(
@@ -29,4 +32,9 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
return items; return items;
} }
// Only registrations still open for a decision belong in the werkbak; a terminal one (decided or
// withdrawn — S-11) whose Beoordelen task has not yet been cleared must not surface to a behandelaar.
private static bool IsOpenForBeoordeling(RegistrationStatus status)
=> status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling;
} }
@@ -7,13 +7,14 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// <summary> /// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered /// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again). /// by delivering the withdrawal message to its open <c>Beoordelen</c> task (ADR-0014), so the case
/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later /// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an
/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only — /// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is
/// mirroring how the beoordeling's rejection deferred its zaak propagation. /// best-effort: if no <c>Beoordelen</c> task is open (the process has not parked there yet, or has
/// already ended) the withdrawal still stands — mirroring <see cref="BeoordeelRegistratie"/>.
/// </summary> /// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store) public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks)
{ {
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{ {
@@ -22,11 +23,22 @@ public sealed class WithdrawRegistration(IRegistrationStore store)
var registration = await store.GetAsync(command.RegistrationId, ct) var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw."); ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
// A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again. // A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken) if (registration.Status == RegistrationStatus.Ingetrokken)
return; return;
registration.Withdraw(); registration.Withdraw();
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
await CancelWorkflowTaskAsync(command.RegistrationId, ct);
}
// Cancel the workflow: deliver the withdrawal message to the open Beoordelen task's execution so
// the process's boundary event ends it. If none is open the withdrawal still stands.
private async Task CancelWorkflowTaskAsync(RegistrationId registrationId, CancellationToken ct)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
if (task is not null)
await tasks.WithdrawBeoordelingAsync(task.ExecutionId, ct);
} }
} }
@@ -23,6 +23,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string ZaakUrlVariable = "zaakUrl"; private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit"; 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, CancellationToken ct = default)
{ {
@@ -65,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
"service/query/tasks", request, ct); "service/query/tasks", request, ct);
var tasks = page?.Data ?? []; var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; return [.. tasks.Select(t => new BeoordelingTask(t.Id, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))];
} }
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
@@ -85,6 +86,18 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{
// Deliver the withdrawal message to the Beoordelen task's execution, tripping the process's
// interrupting message boundary event so the registratie instance ends (ADR-0014). Flowable
// takes messageEventReceived as a PUT on the subscribed execution.
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{ {
using var response = await SendAsync(path, body, ct); using var response = await SendAsync(path, body, ct);
@@ -92,9 +105,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
return await response.Content.ReadFromJsonAsync<TResponse>(ct); return await response.Content.ReadFromJsonAsync<TResponse>(ct);
} }
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct) private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null)
{ {
var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path))
{ {
Content = JsonContent.Create(body), Content = JsonContent.Create(body),
}; };
@@ -132,11 +145,16 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables); [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record MessageEventRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("messageName")] string MessageName);
private sealed record TaskQueryResult( private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data); [property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
private sealed record UserTaskDto( private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("executionId")] string ExecutionId,
// Flowable's task-query returns the (included) process variables under "variables", not // Flowable's task-query returns the (included) process variables under "variables", not
// "processVariables"; the request opts in via includeProcessVariables. // "processVariables"; the request opts in via includeProcessVariables.
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
@@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests
} }
private static FakeUserTaskClient TaskFor(Registration registration) => private static FakeUserTaskClient TaskFor(Registration registration) =>
new([new BeoordelingTask("task-1", registration.Id)]); new([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
[Fact] [Fact]
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
+7
View File
@@ -47,6 +47,7 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
{ {
public (string TaskId, string Behandelaar)? Claimed { get; private set; } public (string TaskId, string Behandelaar)? Claimed { get; private set; }
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public string? WithdrawnExecutionId { get; private set; }
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default) public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult(open); => Task.FromResult(open);
@@ -62,6 +63,12 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
Completed = (taskId, besluit); Completed = (taskId, besluit);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{
WithdrawnExecutionId = executionId;
return Task.CompletedTask;
}
} }
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a /// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
@@ -158,13 +158,14 @@ public class FlowableWorkflowClientTests
var capture = new RequestCapture(); var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, var client = Client(capture.Responds(HttpStatusCode.OK,
$$""" $$"""
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1} {"data":[{"id":"task-1","executionId":"exec-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
""")); """));
var tasks = await client.GetOpenBeoordelingenAsync(); var tasks = await client.GetOpenBeoordelingenAsync();
var task = Assert.Single(tasks); var task = Assert.Single(tasks);
Assert.Equal("task-1", task.TaskId); Assert.Equal("task-1", task.TaskId);
Assert.Equal("exec-1", task.ExecutionId);
Assert.Equal(rid, task.RegistrationId); Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks", Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
@@ -242,6 +243,32 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{expected}\"", capture.Body); Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
} }
[Fact]
public async Task Withdraw_beoordeling_delivers_the_message_to_the_task_execution()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.WithdrawBeoordelingAsync("exec-1");
// A PUT messageEventReceived on the execution trips the Beoordelen boundary event (ADR-0014).
Assert.Equal(HttpMethod.Put, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-1",
capture.Seen.RequestUri!.ToString());
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"action\":\"messageEventReceived\"", capture.Body);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", capture.Body);
}
[Fact]
public async Task Withdraw_beoordeling_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(() => client.WithdrawBeoordelingAsync("exec-1"));
}
[Fact] [Fact]
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
{ {
+17 -2
View File
@@ -18,7 +18,7 @@ public class WerkbakTests
registration.AttachZaak(FakeAclClient.DefaultZaakUrl); registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.TakeIntoBehandeling(); registration.TakeIntoBehandeling();
store.Seed(registration); store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
var werkbak = new Werkbak(tasks, store); var werkbak = new Werkbak(tasks, store);
var items = await werkbak.GetAsync(); var items = await werkbak.GetAsync();
@@ -41,9 +41,24 @@ public class WerkbakTests
public async Task Skips_a_task_whose_registration_is_unknown() public async Task Skips_a_task_whose_registration_is_unknown()
{ {
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for. // Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",RegistrationId.New())]);
var werkbak = new Werkbak(tasks, new FakeRegistrationStore()); var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync()); Assert.Empty(await werkbak.GetAsync());
} }
[Fact]
public async Task Skips_a_task_whose_registration_is_no_longer_open()
{
// A withdrawn registration (S-11) may still have a Beoordelen task lingering until the workflow
// cancels it; the werkbak lists only registrations still open for beoordeling, so it drops off.
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
registration.Withdraw();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
var werkbak = new Werkbak(tasks, store);
Assert.Empty(await werkbak.GetAsync());
}
} }
@@ -5,13 +5,15 @@ namespace Big.Tests;
public class WithdrawRegistrationTests public class WithdrawRegistrationTests
{ {
private static FakeUserTaskClient NoTasks() => new([]);
[Fact] [Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store); var handler = new WithdrawRegistration(store, NoTasks());
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
@@ -20,11 +22,44 @@ public class WithdrawRegistrationTests
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
} }
[Fact]
public async Task Withdrawing_cancels_the_open_beoordelen_task_via_its_execution()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
var handler = new WithdrawRegistration(store, tasks);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The withdrawal message is delivered to the Beoordelen task's execution, tripping the
// boundary event that ends the process (ADR-0014).
Assert.Equal("exec-1", tasks.WithdrawnExecutionId);
}
[Fact]
public async Task Withdrawing_with_no_open_task_still_marks_ingetrokken()
{
// If the process has not parked at Beoordelen yet (or already ended), the withdrawal stands:
// the aggregate is INGETROKKEN and nothing is cancelled.
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var tasks = NoTasks();
var handler = new WithdrawRegistration(store, tasks);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(tasks.WithdrawnExecutionId);
}
[Fact] [Fact]
public async Task Rejects_a_null_command_without_touching_the_store() public async Task Rejects_a_null_command_without_touching_the_store()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store); var handler = new WithdrawRegistration(store, NoTasks());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
@@ -34,7 +69,7 @@ public class WithdrawRegistrationTests
public async Task Withdrawing_an_unknown_registration_throws() public async Task Withdrawing_an_unknown_registration_throws()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store); var handler = new WithdrawRegistration(store, NoTasks());
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
@@ -48,7 +83,7 @@ public class WithdrawRegistrationTests
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store); var handler = new WithdrawRegistration(store, NoTasks());
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
+27 -1
View File
@@ -11,7 +11,13 @@
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
as a user task: the process parks here until a behandelaar claims and completes it with a as a user task: the process parks here until a behandelaar claims and completes it with a
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process `besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
variable so the werkbak can correlate each task back to its aggregate. --> variable so the werkbak can correlate each task back to its aggregate.
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. -->
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
<process id="registratie" name="Registratie ontvangen" isExecutable="true"> <process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/> <startEvent id="start" name="Registratie ontvangen"/>
@@ -29,6 +35,16 @@
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/> <sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
<endEvent id="end" name="Registratie beoordeeld"/> <endEvent id="end" name="Registratie beoordeeld"/>
<!-- Withdrawal (S-11): interrupting message boundary event on Beoordelen. On RegistratieIngetrokken
the task is cancelled and the process ends as "ingetrokken". -->
<boundaryEvent id="Ingetrokken" attachedToRef="Beoordelen" cancelActivity="true">
<messageEventDefinition messageRef="Message_Ingetrokken"/>
</boundaryEvent>
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
</process> </process>
<bpmndi:BPMNDiagram id="diagram"> <bpmndi:BPMNDiagram id="diagram">
@@ -57,6 +73,16 @@
<omgdi:waypoint x="510" y="115"/> <omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="580" y="115"/> <omgdi:waypoint x="580" y="115"/>
</bpmndi:BPMNEdge> </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"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>
</definitions> </definitions>