fix(domain): correlate withdrawal by process instance to the subscribed execution (refs #12)
All checks were successful
CI / lint (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 1m1s
CI / unit (pull_request) Successful in 1m20s
CI / frontend (pull_request) Successful in 2m50s
CI / mutation (pull_request) Successful in 5m22s
CI / verify-stack (pull_request) Successful in 7m18s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 12:13:47 +02:00
parent 66af21853f
commit ddfdedc302
10 changed files with 144 additions and 97 deletions

View File

@@ -15,6 +15,14 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id.
/// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> 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.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
}
/// <summary>
@@ -54,18 +62,11 @@ public interface IUserTaskClient
/// <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>
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
/// 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.</summary>
public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId);
/// complete it) and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
/// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice

View File

@@ -8,13 +8,13 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// <summary>
/// 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 <c>Beoordelen</c> 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 <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"/>.
/// 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 <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary>
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);
}
}