From 345191b1dfb787c6e1762008ffb5f10f62102da1 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:20:43 +0200 Subject: [PATCH 1/9] 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) --- services/domain/Big.Tests/WerkbakTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/services/domain/Big.Tests/WerkbakTests.cs b/services/domain/Big.Tests/WerkbakTests.cs index bfbf182..0b3bdcf 100644 --- a/services/domain/Big.Tests/WerkbakTests.cs +++ b/services/domain/Big.Tests/WerkbakTests.cs @@ -46,4 +46,19 @@ public class WerkbakTests 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", registration.Id)]); + var werkbak = new Werkbak(tasks, store); + + Assert.Empty(await werkbak.GetAsync()); + } } -- 2.49.1 From 435db81bdf6cb8a3fcb3f875832db33d84e240e7 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:20:43 +0200 Subject: [PATCH 2/9] feat(domain): the werkbak lists only registrations still open for beoordeling (refs #12) Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Application/Werkbak.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/services/domain/Big.Application/Werkbak.cs b/services/domain/Big.Application/Werkbak.cs index 4275d50..dfd6cf8 100644 --- a/services/domain/Big.Application/Werkbak.cs +++ b/services/domain/Big.Application/Werkbak.cs @@ -1,3 +1,5 @@ +using Big.Domain; + namespace Big.Application; /// 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 /// open Beoordelen tasks from the workflow engine (§8.2, via ) — /// 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. /// 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) { var registration = await store.GetAsync(task.RegistrationId, ct); - if (registration is null) + if (registration is null || !IsOpenForBeoordeling(registration.Status)) continue; items.Add(new WerkbakItem( @@ -29,4 +32,9 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) 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; } -- 2.49.1 From 2a6874ef6d4398d015f9cf2b2f9085e259d403ac Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:29:56 +0200 Subject: [PATCH 3/9] 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) --- .../Big.Tests/BeoordeelRegistratieTests.cs | 2 +- services/domain/Big.Tests/Fakes.cs | 7 +++ .../Big.Tests/FlowableWorkflowClientTests.cs | 29 ++++++++++++- services/domain/Big.Tests/WerkbakTests.cs | 6 +-- .../Big.Tests/WithdrawRegistrationTests.cs | 43 +++++++++++++++++-- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs index 9ad48de..d86cf8b 100644 --- a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs +++ b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs @@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests } private static FakeUserTaskClient TaskFor(Registration registration) => - new([new BeoordelingTask("task-1", registration.Id)]); + new([new BeoordelingTask("task-1", "exec-1", registration.Id)]); [Fact] public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index bfd0a54..c866314 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -47,6 +47,7 @@ internal sealed class FakeUserTaskClient(IReadOnlyList open) : { public (string TaskId, string Behandelaar)? Claimed { get; private set; } public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } + public string? WithdrawnExecutionId { get; private set; } public Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) => Task.FromResult(open); @@ -62,6 +63,12 @@ internal sealed class FakeUserTaskClient(IReadOnlyList open) : Completed = (taskId, besluit); return Task.CompletedTask; } + + public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) + { + WithdrawnExecutionId = executionId; + return Task.CompletedTask; + } } /// A fake ACL client that records the bsn it was asked to open a zaak for and returns a diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index f1bf579..6c8ddc0 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -158,13 +158,14 @@ public class FlowableWorkflowClientTests var capture = new RequestCapture(); 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 task = Assert.Single(tasks); Assert.Equal("task-1", task.TaskId); + Assert.Equal("exec-1", task.ExecutionId); Assert.Equal(rid, task.RegistrationId); Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal("http://flowable/flowable-rest/service/query/tasks", @@ -242,6 +243,32 @@ public class FlowableWorkflowClientTests 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(() => client.WithdrawBeoordelingAsync("exec-1")); + } + [Fact] public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() { diff --git a/services/domain/Big.Tests/WerkbakTests.cs b/services/domain/Big.Tests/WerkbakTests.cs index 0b3bdcf..cfc8b3c 100644 --- a/services/domain/Big.Tests/WerkbakTests.cs +++ b/services/domain/Big.Tests/WerkbakTests.cs @@ -18,7 +18,7 @@ public class WerkbakTests registration.AttachZaak(FakeAclClient.DefaultZaakUrl); registration.TakeIntoBehandeling(); 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 items = await werkbak.GetAsync(); @@ -41,7 +41,7 @@ public class WerkbakTests 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. - 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()); Assert.Empty(await werkbak.GetAsync()); @@ -56,7 +56,7 @@ public class WerkbakTests var registration = Registration.Submit("123456782"); registration.Withdraw(); 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); Assert.Empty(await werkbak.GetAsync()); diff --git a/services/domain/Big.Tests/WithdrawRegistrationTests.cs b/services/domain/Big.Tests/WithdrawRegistrationTests.cs index 0506db0..eae7852 100644 --- a/services/domain/Big.Tests/WithdrawRegistrationTests.cs +++ b/services/domain/Big.Tests/WithdrawRegistrationTests.cs @@ -5,13 +5,15 @@ namespace Big.Tests; public class WithdrawRegistrationTests { + private static FakeUserTaskClient NoTasks() => new([]); + [Fact] public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() { var store = new FakeRegistrationStore(); var registration = Registration.Submit("123456782"); store.Seed(registration); - var handler = new WithdrawRegistration(store); + var handler = new WithdrawRegistration(store, NoTasks()); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); @@ -20,11 +22,44 @@ public class WithdrawRegistrationTests 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] public async Task Rejects_a_null_command_without_touching_the_store() { var store = new FakeRegistrationStore(); - var handler = new WithdrawRegistration(store); + var handler = new WithdrawRegistration(store, NoTasks()); await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); Assert.Equal(0, store.SaveCount); @@ -34,7 +69,7 @@ public class WithdrawRegistrationTests public async Task Withdrawing_an_unknown_registration_throws() { var store = new FakeRegistrationStore(); - var handler = new WithdrawRegistration(store); + var handler = new WithdrawRegistration(store, NoTasks()); var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); @@ -48,7 +83,7 @@ public class WithdrawRegistrationTests var store = new FakeRegistrationStore(); var registration = Registration.Submit("123456782"); 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)); -- 2.49.1 From edffbb4575d8f164466f9fc68a26870eb949f71b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:29:56 +0200 Subject: [PATCH 4/9] 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) --- services/domain/Big.Application/Ports.cs | 11 ++++++-- .../Big.Application/WithdrawRegistration.cs | 26 ++++++++++++----- .../FlowableWorkflowClient.cs | 24 ++++++++++++++-- workflows/registratie.bpmn | 28 ++++++++++++++++++- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 0859d7c..b75a805 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -54,11 +54,18 @@ public interface IUserTaskClient /// Complete a beoordeling task, carrying the decision into the process as the /// besluit variable so the workflow can continue on the chosen branch. Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default); + + /// Deliver the withdrawal message to a Beoordelen 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. + Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default); } /// A Beoordelen user task in the werkbak: the Flowable task id (needed to claim and -/// complete it) and the registration it carries as a process variable. -public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId); +/// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary +/// event), and the registration it carries as a process variable. +public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId); /// /// Persistence port for the aggregate. In-memory for the minimal slice diff --git a/services/domain/Big.Application/WithdrawRegistration.cs b/services/domain/Big.Application/WithdrawRegistration.cs index 282d35a..b218efc 100644 --- a/services/domain/Big.Application/WithdrawRegistration.cs +++ b/services/domain/Big.Application/WithdrawRegistration.cs @@ -7,13 +7,14 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); /// /// 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 -/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again). -/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later -/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only — -/// mirroring how the beoordeling's rejection deferred its zaak propagation. +/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process +/// by delivering the withdrawal message to its open Beoordelen task (ADR-0014), so the case +/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an +/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is +/// best-effort: if no Beoordelen task is open (the process has not parked there yet, or has +/// already ended) the withdrawal still stands — mirroring . /// -public sealed class WithdrawRegistration(IRegistrationStore store) +public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks) { 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) ?? 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) return; registration.Withdraw(); 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); } } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index f782cea..5aa8533 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -23,6 +23,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; private const string BesluitVariable = "besluit"; + private const string IngetrokkenMessage = "RegistratieIngetrokken"; public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -65,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti "service/query/tasks", request, ct); 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) @@ -85,6 +86,18 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti 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 PostAsync(string path, TRequest body, CancellationToken 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(ct); } - private Task SendAsync(string path, TRequest body, CancellationToken ct) + private Task SendAsync(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), }; @@ -132,11 +145,16 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti [property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("variables")] IReadOnlyList Variables); + private sealed record MessageEventRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("messageName")] string MessageName); + private sealed record TaskQueryResult( [property: JsonPropertyName("data")] IReadOnlyList? Data); private sealed record UserTaskDto( [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("executionId")] string ExecutionId, // Flowable's task-query returns the (included) process variables under "variables", not // "processVariables"; the request opts in via includeProcessVariables. [property: JsonPropertyName("variables")] IReadOnlyList? Variables) diff --git a/workflows/registratie.bpmn b/workflows/registratie.bpmn index 51f7de9..99a7ec7 100644 --- a/workflows/registratie.bpmn +++ b/workflows/registratie.bpmn @@ -11,7 +11,13 @@ 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 `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. --> + + @@ -29,6 +35,16 @@ + + + + + + + + + @@ -57,6 +73,16 @@ + + + + + + + + + + -- 2.49.1 From 18ab868724baac8217c6e4d42c79129f3bca34d5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:29:56 +0200 Subject: [PATCH 5/9] test(verify): the domain check exercises withdrawal cancelling the Beoordelen task (refs #12) Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/run-domain-check.sh | 47 ++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index a701b21..74d6122 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)" fl_base="http://$(ip "$fl"):8080/flowable-rest/service" reg_id="${loc##*/}" -# Extracts the Beoordelen task id for our registration from a Flowable task-query response on 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 +# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on +# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. +task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json try: d=json.load(sys.stdin) except Exception: @@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)" task_id="" 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_id="$(printf '%s' "$resp" | task_for_reg)" + task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")" [ -n "$task_id" ] && break sleep 2 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)" 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; } 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 -- 2.49.1 From 92b19e5d7ba4af2aa5fe99d22ffd59206ce4a17e Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:29:56 +0200 Subject: [PATCH 6/9] =?UTF-8?q?arch(workflow):=20ADR-0014=20=E2=80=94=20wi?= =?UTF-8?q?thdrawal=20cancels=20the=20process=20via=20a=20message=20event?= =?UTF-8?q?=20(refs=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...adr-0014-withdrawal-cancels-the-process.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/architecture/adr-0014-withdrawal-cancels-the-process.md diff --git a/docs/architecture/adr-0014-withdrawal-cancels-the-process.md b/docs/architecture/adr-0014-withdrawal-cancels-the-process.md new file mode 100644 index 0000000..8bc2064 --- /dev/null +++ b/docs/architecture/adr-0014-withdrawal-cancels-the-process.md @@ -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. -- 2.49.1 From b8ee6c64608d248b832ad227a3e2a69c46b7fca5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:40:14 +0200 Subject: [PATCH 7/9] fix(acceptance): implement WithdrawBeoordelingAsync on the in-memory user-task client (refs #12) The S-11b port change (WithdrawBeoordelingAsync + BeoordelingTask.ExecutionId) broke the solution build: the acceptance in-memory IUserTaskClient still used the old signature. Implement the new member and carry an executionId. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/acceptance/Support/InMemoryDomainPorts.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index b5714ef..ac7e2e1 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -50,8 +50,10 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient private readonly List _open = []; public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } + public string? WithdrawnExecutionId { get; private set; } - public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId)); + public void Open(RegistrationId registrationId) => + _open.Add(new BeoordelingTask($"task-{registrationId}", $"exec-{registrationId}", registrationId)); public Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) => Task.FromResult>(_open); @@ -64,6 +66,13 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient _open.RemoveAll(t => t.TaskId == taskId); return Task.CompletedTask; } + + public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) + { + WithdrawnExecutionId = executionId; + _open.RemoveAll(t => t.ExecutionId == executionId); + return Task.CompletedTask; + } } /// An in-memory registration store for the domain acceptance scenario. -- 2.49.1 From 66af21853f944b833b2b24bca23208f74bdbeeee Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:47:20 +0200 Subject: [PATCH 8/9] =?UTF-8?q?style(domain):=20dotnet=20format=20?= =?UTF-8?q?=E2=80=94=20spacing=20in=20werkbak=20test=20task=20args=20(refs?= =?UTF-8?q?=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Tests/WerkbakTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/domain/Big.Tests/WerkbakTests.cs b/services/domain/Big.Tests/WerkbakTests.cs index cfc8b3c..76449ac 100644 --- a/services/domain/Big.Tests/WerkbakTests.cs +++ b/services/domain/Big.Tests/WerkbakTests.cs @@ -18,7 +18,7 @@ public class WerkbakTests registration.AttachZaak(FakeAclClient.DefaultZaakUrl); registration.TakeIntoBehandeling(); store.Seed(registration); - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]); var werkbak = new Werkbak(tasks, store); var items = await werkbak.GetAsync(); @@ -41,7 +41,7 @@ public class WerkbakTests 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. - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",RegistrationId.New())]); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", RegistrationId.New())]); var werkbak = new Werkbak(tasks, new FakeRegistrationStore()); Assert.Empty(await werkbak.GetAsync()); @@ -56,7 +56,7 @@ public class WerkbakTests var registration = Registration.Submit("123456782"); registration.Withdraw(); store.Seed(registration); - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]); var werkbak = new Werkbak(tasks, store); Assert.Empty(await werkbak.GetAsync()); -- 2.49.1 From ddfdedc30289dc8eb9090263a11c361e3c7ce6a1 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 12:13:47 +0200 Subject: [PATCH 9/9] fix(domain): correlate withdrawal by process instance to the subscribed execution (refs #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-stack surfaced a Flowable 500: delivering messageEventReceived to the Beoordelen task's execution is wrong — a message boundary event's subscription lives on its own execution. Correlate instead by the registration's process instance id (recorded at submit): query the execution subscribed to RegistratieIngetrokken and deliver the message there. Withdrawal moves from IUserTaskClient to IWorkflowClient.WithdrawProcessAsync; the handler no longer needs a task lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...adr-0014-withdrawal-cancels-the-process.md | 11 ++-- services/domain/Big.Application/Ports.cs | 19 +++--- .../Big.Application/WithdrawRegistration.cs | 25 +++----- .../FlowableWorkflowClient.cs | 37 ++++++++--- .../Big.Tests/BeoordeelRegistratieTests.cs | 2 +- services/domain/Big.Tests/Fakes.cs | 14 ++--- .../Big.Tests/FlowableWorkflowClientTests.cs | 62 ++++++++++++++----- services/domain/Big.Tests/WerkbakTests.cs | 6 +- .../Big.Tests/WithdrawRegistrationTests.cs | 47 +++++++------- .../acceptance/Support/InMemoryDomainPorts.cs | 18 +++--- 10 files changed, 144 insertions(+), 97 deletions(-) diff --git a/docs/architecture/adr-0014-withdrawal-cancels-the-process.md b/docs/architecture/adr-0014-withdrawal-cancels-the-process.md index 8bc2064..51cf3a6 100644 --- a/docs/architecture/adr-0014-withdrawal-cancels-the-process.md +++ b/docs/architecture/adr-0014-withdrawal-cancels-the-process.md @@ -29,11 +29,12 @@ task; the Workflow Client correlates a `RegistratieIngetrokken` message to the t 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). +- **Correlated by the registration's own process instance.** The aggregate records its Flowable + process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that + id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the + `RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it. + Targeting the subscribed execution (not the user task's execution — a message boundary event's + subscription lives on its own execution) is what makes the correlation land. - **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 diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index b75a805..4a26dd5 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -15,6 +15,14 @@ public interface IWorkflowClient /// aggregate. Returns the process instance id. /// Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); + + /// + /// Cancel a running registratie process on withdrawal (S-11): correlate the + /// RegistratieIngetrokken message to the instance, tripping the interrupting message event + /// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already + /// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless. + /// + Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default); } /// @@ -54,18 +62,11 @@ public interface IUserTaskClient /// Complete a beoordeling task, carrying the decision into the process as the /// besluit variable so the workflow can continue on the chosen branch. Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default); - - /// Deliver the withdrawal message to a Beoordelen 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. - Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default); } /// A Beoordelen user task in the werkbak: the Flowable task id (needed to claim and -/// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary -/// event), and the registration it carries as a process variable. -public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId); +/// complete it) and the registration it carries as a process variable. +public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId); /// /// Persistence port for the aggregate. In-memory for the minimal slice diff --git a/services/domain/Big.Application/WithdrawRegistration.cs b/services/domain/Big.Application/WithdrawRegistration.cs index b218efc..92f632f 100644 --- a/services/domain/Big.Application/WithdrawRegistration.cs +++ b/services/domain/Big.Application/WithdrawRegistration.cs @@ -8,13 +8,13 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); /// /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It /// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process -/// by delivering the withdrawal message to its open Beoordelen task (ADR-0014), so the case -/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an -/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is -/// best-effort: if no Beoordelen task is open (the process has not parked there yet, or has -/// already ended) the withdrawal still stands — mirroring . +/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the +/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn +/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the +/// registration never started a process the withdrawal still stands (the process cancel is skipped), +/// mirroring how completes its task best-effort. /// -public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks) +public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow) { public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) { @@ -29,16 +29,9 @@ public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClie registration.Withdraw(); 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); + // Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak. + if (registration.ProcessInstanceId is not null) + await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct); } } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index 5aa8533..6a723c8 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -66,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti "service/query/tasks", request, ct); var tasks = page?.Data ?? []; - return [.. tasks.Select(t => new BeoordelingTask(t.Id, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))]; + return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; } public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) @@ -86,18 +86,35 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } - public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) + public async Task WithdrawProcessAsync(string processInstanceId, 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); + // Correlate the withdrawal message to the instance: find the execution subscribed to it (the + // interrupting message event's own execution — NOT the user task's), then deliver + // messageEventReceived to that execution so the process ends (ADR-0014). If nothing is + // subscribed (the process is not parked at Beoordelen) this is a best-effort no-op. + var subscribed = await GetAsync( + $"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}", + ct); + var execution = subscribed?.Data?.FirstOrDefault(); + if (execution is null) + return; + + var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage); using var response = await SendAsync( - $"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put); + $"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put); response.EnsureSuccessStatusCode(); } + private async Task GetAsync(string path, CancellationToken ct) + { + var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path)); + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials()); + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(ct); + } + private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); @@ -154,7 +171,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private sealed record UserTaskDto( [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("executionId")] string ExecutionId, // Flowable's task-query returns the (included) process variables under "variables", not // "processVariables"; the request opts in via includeProcessVariables. [property: JsonPropertyName("variables")] IReadOnlyList? Variables) @@ -172,6 +188,11 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); + private sealed record ExecutionQueryResult( + [property: JsonPropertyName("data")] IReadOnlyList? Data); + + private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id); + private sealed record AcquiredJob( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("variables")] IReadOnlyList Variables) diff --git a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs index d86cf8b..9ad48de 100644 --- a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs +++ b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs @@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests } private static FakeUserTaskClient TaskFor(Registration registration) => - new([new BeoordelingTask("task-1", "exec-1", registration.Id)]); + new([new BeoordelingTask("task-1", registration.Id)]); [Fact] public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index c866314..539f554 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -32,6 +32,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac : IWorkflowClient { public RegistrationId? StartedFor { get; private set; } + public string? WithdrawnProcessInstanceId { get; private set; } public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -39,6 +40,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac StartedFor = registrationId; return Task.FromResult(processInstanceId); } + + public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default) + { + WithdrawnProcessInstanceId = processInstanceId; + return Task.CompletedTask; + } } /// A fake user-task client for the werkbak/decision use cases: returns a scripted set of @@ -47,7 +54,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList open) : { public (string TaskId, string Behandelaar)? Claimed { get; private set; } public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } - public string? WithdrawnExecutionId { get; private set; } public Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) => Task.FromResult(open); @@ -63,12 +69,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList open) : Completed = (taskId, besluit); return Task.CompletedTask; } - - public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) - { - WithdrawnExecutionId = executionId; - return Task.CompletedTask; - } } /// A fake ACL client that records the bsn it was asked to open a zaak for and returns a diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index 6c8ddc0..aff3bf3 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -158,14 +158,13 @@ public class FlowableWorkflowClientTests var capture = new RequestCapture(); var client = Client(capture.Responds(HttpStatusCode.OK, $$""" - {"data":[{"id":"task-1","executionId":"exec-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1} + {"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1} """)); var tasks = await client.GetOpenBeoordelingenAsync(); var task = Assert.Single(tasks); Assert.Equal("task-1", task.TaskId); - Assert.Equal("exec-1", task.ExecutionId); Assert.Equal(rid, task.RegistrationId); Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal("http://flowable/flowable-rest/service/query/tasks", @@ -244,29 +243,58 @@ public class FlowableWorkflowClientTests } [Fact] - public async Task Withdraw_beoordeling_delivers_the_message_to_the_task_execution() + public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution() { - var capture = new RequestCapture(); - var client = Client(capture.Responds(HttpStatusCode.OK)); + HttpRequestMessage? getReq = null; + HttpRequestMessage? putReq = null; + string? putBody = null; + var client = Client(new StubHandler(async req => + { + if (req.Method == HttpMethod.Get) + { + getReq = req; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"), + }; + } + putReq = req; + putBody = await req.Content!.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.OK); + })); - await client.WithdrawBeoordelingAsync("exec-1"); + await client.WithdrawProcessAsync("pi-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); + // 1. Find the execution subscribed to the withdrawal message for this instance. + Assert.Equal(HttpMethod.Get, getReq!.Method); + Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString()); + Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query); + Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query); + // 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event. + Assert.Equal(HttpMethod.Put, putReq!.Method); + Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9", + putReq.RequestUri!.ToString()); + Assert.Contains("\"action\":\"messageEventReceived\"", putBody); + Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody); } [Fact] - public async Task Withdraw_beoordeling_throws_when_flowable_rejects_the_request() + public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed() { - var capture = new RequestCapture(); - var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); + var methods = new List(); + var client = Client(new StubHandler(req => + { + methods.Add(req.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"), + }); + })); - await Assert.ThrowsAsync(() => client.WithdrawBeoordelingAsync("exec-1")); + await client.WithdrawProcessAsync("pi-1"); + + // No subscribed execution → no message delivered (best-effort), no throw. + Assert.DoesNotContain(HttpMethod.Put, methods); } [Fact] diff --git a/services/domain/Big.Tests/WerkbakTests.cs b/services/domain/Big.Tests/WerkbakTests.cs index 76449ac..0b3bdcf 100644 --- a/services/domain/Big.Tests/WerkbakTests.cs +++ b/services/domain/Big.Tests/WerkbakTests.cs @@ -18,7 +18,7 @@ public class WerkbakTests registration.AttachZaak(FakeAclClient.DefaultZaakUrl); registration.TakeIntoBehandeling(); store.Seed(registration); - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); var werkbak = new Werkbak(tasks, store); var items = await werkbak.GetAsync(); @@ -41,7 +41,7 @@ public class WerkbakTests 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. - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", RegistrationId.New())]); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]); var werkbak = new Werkbak(tasks, new FakeRegistrationStore()); Assert.Empty(await werkbak.GetAsync()); @@ -56,7 +56,7 @@ public class WerkbakTests var registration = Registration.Submit("123456782"); registration.Withdraw(); store.Seed(registration); - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); var werkbak = new Werkbak(tasks, store); Assert.Empty(await werkbak.GetAsync()); diff --git a/services/domain/Big.Tests/WithdrawRegistrationTests.cs b/services/domain/Big.Tests/WithdrawRegistrationTests.cs index eae7852..99de8b9 100644 --- a/services/domain/Big.Tests/WithdrawRegistrationTests.cs +++ b/services/domain/Big.Tests/WithdrawRegistrationTests.cs @@ -5,15 +5,20 @@ namespace Big.Tests; public class WithdrawRegistrationTests { - private static FakeUserTaskClient NoTasks() => new([]); + private static Registration Submitted(string processInstanceId = "proc-1") + { + var registration = Registration.Submit("123456782"); + registration.RecordProcessStarted(processInstanceId); + return registration; + } [Fact] public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() { var store = new FakeRegistrationStore(); - var registration = Registration.Submit("123456782"); + var registration = Submitted(); store.Seed(registration); - var handler = new WithdrawRegistration(store, NoTasks()); + var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); @@ -23,43 +28,42 @@ public class WithdrawRegistrationTests } [Fact] - public async Task Withdrawing_cancels_the_open_beoordelen_task_via_its_execution() + public async Task Withdrawing_cancels_the_running_process() { var store = new FakeRegistrationStore(); - var registration = Registration.Submit("123456782"); + var registration = Submitted("proc-42"); store.Seed(registration); - var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]); - var handler = new WithdrawRegistration(store, tasks); + var workflow = new FakeWorkflowClient(); + var handler = new WithdrawRegistration(store, workflow); 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); + // The process the registration recorded at submit is cancelled (ADR-0014). + Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId); } [Fact] - public async Task Withdrawing_with_no_open_task_still_marks_ingetrokken() + public async Task Withdrawing_before_a_process_was_started_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. + // A registration with no recorded process (e.g. start never happened) can still be withdrawn; + // there is simply no process to cancel. var store = new FakeRegistrationStore(); - var registration = Registration.Submit("123456782"); + var registration = Registration.Submit("123456782"); // no RecordProcessStarted store.Seed(registration); - var tasks = NoTasks(); - var handler = new WithdrawRegistration(store, tasks); + var workflow = new FakeWorkflowClient(); + var handler = new WithdrawRegistration(store, workflow); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); - Assert.Null(tasks.WithdrawnExecutionId); + Assert.Null(workflow.WithdrawnProcessInstanceId); } [Fact] public async Task Rejects_a_null_command_without_touching_the_store() { var store = new FakeRegistrationStore(); - var handler = new WithdrawRegistration(store, NoTasks()); + var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); Assert.Equal(0, store.SaveCount); @@ -69,7 +73,7 @@ public class WithdrawRegistrationTests public async Task Withdrawing_an_unknown_registration_throws() { var store = new FakeRegistrationStore(); - var handler = new WithdrawRegistration(store, NoTasks()); + var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); @@ -81,9 +85,10 @@ public class WithdrawRegistrationTests public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent() { var store = new FakeRegistrationStore(); - var registration = Registration.Submit("123456782"); + var registration = Submitted(); store.Seed(registration); - var handler = new WithdrawRegistration(store, NoTasks()); + var workflow = new FakeWorkflowClient(); + var handler = new WithdrawRegistration(store, workflow); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index ac7e2e1..3e21752 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -11,12 +11,19 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient public const string StartedProcessInstanceId = "proc-acc-1"; public RegistrationId? StartedFor { get; private set; } + public string? WithdrawnProcessInstanceId { get; private set; } public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { StartedFor = registrationId; return Task.FromResult(StartedProcessInstanceId); } + + public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default) + { + WithdrawnProcessInstanceId = processInstanceId; + return Task.CompletedTask; + } } /// An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, @@ -50,10 +57,8 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient private readonly List _open = []; public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } - public string? WithdrawnExecutionId { get; private set; } - public void Open(RegistrationId registrationId) => - _open.Add(new BeoordelingTask($"task-{registrationId}", $"exec-{registrationId}", registrationId)); + public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId)); public Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) => Task.FromResult>(_open); @@ -66,13 +71,6 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient _open.RemoveAll(t => t.TaskId == taskId); return Task.CompletedTask; } - - public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) - { - WithdrawnExecutionId = executionId; - _open.RemoveAll(t => t.ExecutionId == executionId); - return Task.CompletedTask; - } } /// An in-memory registration store for the domain acceptance scenario. -- 2.49.1