From 67a60e7f63d99db97921ba2635df674357a8f756 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:36:10 +0200 Subject: [PATCH 01/23] docs(backlog): split S-10 into S-10a (#102) and S-10b (#103) (refs #102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-10 (#11) spanned six net-new surfaces incl. a new ZGW boundary — too large for one slice (§13). S-10a is the workflow/timeout spine (backend); S-10b is the document-upload vertical (ACL Documenten API + portal). #11 closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- BACKLOG.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 41b1b38..5b4ed89 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -199,9 +199,19 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i ### S-10 · Document upload + boundary timer for document timeout (Flow 2) -**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled. +Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary: -**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing. +#### S-10a · Document-wait task + 30-day timeout cancellation (workflow spine) — #102 + +**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status via an external-worker (mirrors S-14 escalation / S-11 withdrawal). Backend only, no frontend. + +**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom. + +#### S-10b · Diploma upload via ACL Documenten API + self-service portal — #103 + +**Outcome:** the self-service portal supports diploma upload; the document is stored in the ZGW Documenten (DRC) API and related to the zaak, with all document calls routed through the ACL (§8.1). A successful upload completes the `WachtOpDocumenten` task from S-10a. Depends on #102. + +**Acceptance:** BDD upload-completes-wait-task; Playwright e2e upload journey. ### S-11 · Withdrawal (Flow 3) -- 2.54.0 From f39ec2afa3f49214dbe7735c60514f4fb641738f Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:41:29 +0200 Subject: [PATCH 02/23] test(domain): a document-wait timeout expires the registration to Verlopen (refs #102) RED: Registration.Expire() moves an open registration to a new terminal Verlopen status, needs no zaak, is idempotent on redelivery, and is rejected once the registration has been decided or withdrawn. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/Big.Tests/RegistrationTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index b166831..003021e 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -294,4 +294,63 @@ public class RegistrationTests Assert.Contains("only an INGEDIEND", ex.Message); Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); } + + [Fact] + public void Expiring_an_ingediend_registration_sets_it_verlopen() + { + // The 30-day document-wait timer fired before the documents arrived (S-10a): the case is + // cancelled and the aggregate becomes terminal VERLOPEN. + var registration = Registration.Submit("123456782"); + + registration.Expire(); + + Assert.Equal(RegistrationStatus.Verlopen, registration.Status); + } + + [Fact] + public void Expiring_needs_no_zaak() + { + // The timer fires on a purely time-based boundary; expiry does not depend on the zaak. + var registration = Registration.Submit("123456782"); + + registration.Expire(); + + Assert.Equal(RegistrationStatus.Verlopen, registration.Status); + Assert.Null(registration.ZaakUrl); + } + + [Fact] + public void Re_expiring_an_already_verlopen_registration_is_idempotent() + { + // The RegistratieVerlopen worker job may be redelivered (§8.6); re-expiring is a no-op. + var registration = Registration.Submit("123456782"); + registration.Expire(); + + registration.Expire(); + + Assert.Equal(RegistrationStatus.Verlopen, registration.Status); + } + + [Fact] + public void Expiring_an_approved_registration_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + registration.Approve(); + + var ex = Assert.Throws(() => registration.Expire()); + Assert.Contains("only an INGEDIEND", ex.Message); + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } + + [Fact] + public void Expiring_a_withdrawn_registration_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.Withdraw(); + + var ex = Assert.Throws(() => registration.Expire()); + Assert.Contains("only an INGEDIEND", ex.Message); + Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status); + } } -- 2.54.0 From 11ef26d8cc540145638c93ddefc7540bebe09f40 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:42:07 +0200 Subject: [PATCH 03/23] feat(domain): Registration.Expire() lapses an open registration to Verlopen (refs #102) Adds the terminal Verlopen status and Expire(), reusing the RequireOpenForDecision guard so only an INGEDIEND/IN_BEHANDELING registration can lapse; idempotent once Verlopen. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Domain/Registration.cs | 20 +++++++++++++++++-- .../domain/Big.Domain/RegistrationStatus.cs | 4 ++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index 51f20cf..a3b3503 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -133,8 +133,24 @@ public sealed class Registration Status = RegistrationStatus.Ingetrokken; } - // A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or - // IN_BEHANDELING). + /// + /// Expire the registration — the 30-day document-wait timer fired before the required documents + /// were supplied, so the registratie process cancels the case (S-10a). Allowed while it is still + /// open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a decided (INGESCHREVEN/AFGEWEZEN) or + /// withdrawn (INGETROKKEN) registration can no longer expire. Re-expiring one already + /// is a no-op — the worker job may be redelivered (§8.6). + /// + public void Expire() + { + if (Status == RegistrationStatus.Verlopen) + return; + + RequireOpenForDecision(nameof(Expire)); + Status = RegistrationStatus.Verlopen; + } + + // A decision (or withdrawal, or expiry) is only valid while the registration is still open + // (INGEDIEND or IN_BEHANDELING). private void RequireOpenForDecision(string decision) { if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)) diff --git a/services/domain/Big.Domain/RegistrationStatus.cs b/services/domain/Big.Domain/RegistrationStatus.cs index fccfac7..5e8a28c 100644 --- a/services/domain/Big.Domain/RegistrationStatus.cs +++ b/services/domain/Big.Domain/RegistrationStatus.cs @@ -21,4 +21,8 @@ public enum RegistrationStatus /// Withdrawn by the zorgprofessional before a decision (S-11). Terminal. Ingetrokken, + + /// Lapsed: the required documents were not supplied within the 30-day window, so the + /// registratie process cancelled the case (S-10a). Terminal. + Verlopen, } -- 2.54.0 From 5add817c1072bd70e4a8fc9cb8665c0aed147c48 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:43:13 +0200 Subject: [PATCH 04/23] test(domain): RegistratieVerlopen worker expires the correlated registration (refs #102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: ExpireRegistrationWorker loads the registration a RegistratieVerlopen job correlates to and expires it (idempotent on redelivery, throws on unknown so the job is redelivered); RegistratieVerlopenProcessor drains the parked jobs and completes each, leaving a failing one un-completed (§8.6). Mirrors the OpenZaak and escalation worker/processor pairs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExpireRegistrationWorkerTests.cs | 65 +++++++++++++++ .../RegistratieVerlopenProcessorTests.cs | 79 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs create mode 100644 services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs diff --git a/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs b/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs new file mode 100644 index 0000000..612139f --- /dev/null +++ b/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs @@ -0,0 +1,65 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +// S-10a (#102): the application handler behind the RegistratieVerlopen external-worker job. The 30-day +// document-wait timer fired, so the correlated registration is expired to VERLOPEN. Mirrors +// OpenZaakWorker — pure application logic over ports, idempotent under at-least-once delivery (§8.6). +public class ExpireRegistrationWorkerTests +{ + private const string Bsn = "123456782"; + + private static Registration Submitted(string processInstanceId = "proc-1") + { + var registration = Registration.Submit(Bsn); + registration.RecordProcessStarted(processInstanceId); + return registration; + } + + [Fact] + public async Task Expires_the_registration_the_job_correlates_to() + { + var store = new FakeRegistrationStore(); + var registration = Submitted(); + store.Seed(registration); + + await new ExpireRegistrationWorker(store).HandleAsync( + new RegistratieVerlopenJob("job-7", registration.Id)); + + var saved = await store.GetAsync(registration.Id); + Assert.Equal(RegistrationStatus.Verlopen, saved!.Status); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task An_already_verlopen_registration_is_not_persisted_again() + { + // A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again. + var store = new FakeRegistrationStore(); + var registration = Submitted(); + registration.Expire(); + store.Seed(registration); + + await new ExpireRegistrationWorker(store).HandleAsync( + new RegistratieVerlopenJob("job-7", registration.Id)); + + Assert.Equal(0, store.SaveCount); + Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status); + } + + [Fact] + public async Task An_unknown_registration_throws_so_the_job_is_redelivered() + { + var store = new FakeRegistrationStore(); + + await Assert.ThrowsAsync(() => + new ExpireRegistrationWorker(store).HandleAsync( + new RegistratieVerlopenJob("job-7", RegistrationId.New()))); + } + + [Fact] + public async Task Rejects_a_null_job() + => await Assert.ThrowsAsync(() => + new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!)); +} diff --git a/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs b/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs new file mode 100644 index 0000000..e13c950 --- /dev/null +++ b/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs @@ -0,0 +1,79 @@ +using Big.Application; +using Big.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Big.Tests; + +// S-10a (#102): the document-timeout drain loop. Mirrors BeoordelingEscalatieProcessor — acquire the +// parked RegistratieVerlopen jobs (the tokens the 30-day boundary timer on WachtOpDocumenten spawns), +// expire each correlated registration via the ExpireRegistrationWorker, then complete the job. A job +// whose expiry fails is logged and left un-completed for Flowable to redeliver (§8.6). +public class RegistratieVerlopenProcessorTests +{ + /// A fake client scripting the jobs to acquire and recording completions. + private sealed class FakeVerlopenClient(params RegistratieVerlopenJob[] jobs) : IRegistratieVerlopenClient + { + public int AcquireCount { get; private set; } + public List Completed { get; } = []; + + public Task> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default) + { + AcquireCount++; + return Task.FromResult>(jobs.Take(maxJobs).ToList()); + } + + public Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default) + { + Completed.Add(jobId); + return Task.CompletedTask; + } + } + + private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store); + + [Fact] + public async Task Acquires_a_job_expires_the_registration_and_completes_the_job() + { + var store = new FakeRegistrationStore(); + var registration = Domain.Registration.Submit("123456782"); + store.Seed(registration); + var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", registration.Id)); + + var acquired = await new RegistratieVerlopenProcessor( + client, Worker(store), NullLogger.Instance).PumpOnceAsync(5); + + Assert.Equal(1, acquired); + Assert.Equal(Domain.RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status); + Assert.Equal("job-9", Assert.Single(client.Completed)); + } + + [Fact] + public async Task A_failing_expiry_is_left_uncompleted_for_flowable_to_redeliver() + { + // Unknown registration → the worker throws → the job is left for redelivery, error logged. + var store = new FakeRegistrationStore(); + var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", Domain.RegistrationId.New())); + var logger = new CapturingLogger(); + + var acquired = await new RegistratieVerlopenProcessor(client, Worker(store), logger).PumpOnceAsync(5); + + Assert.Equal(1, acquired); + Assert.Empty(client.Completed); + var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error); + Assert.Contains("job-9", error.Message); + } + + [Fact] + public async Task Does_nothing_but_poll_when_there_are_no_jobs() + { + var client = new FakeVerlopenClient(); + + var acquired = await new RegistratieVerlopenProcessor( + client, Worker(new FakeRegistrationStore()), NullLogger.Instance).PumpOnceAsync(5); + + Assert.Equal(0, acquired); + Assert.Equal(1, client.AcquireCount); + Assert.Empty(client.Completed); + } +} -- 2.54.0 From c536c965de7b5e5c184395952ab8ef336fa876e9 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:44:15 +0200 Subject: [PATCH 05/23] feat(domain): RegistratieVerlopen worker + processor expire on document timeout (refs #102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RegistratieVerlopenJob, IRegistratieVerlopenClient, the ExpireRegistrationWorker application handler, and the RegistratieVerlopenProcessor drain loop — the timeout counterpart to the OpenZaak/escalation worker trios. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExpireRegistrationWorker.cs | 34 +++++++++++++++ services/domain/Big.Application/Ports.cs | 8 ++++ .../IExternalWorkerClient.cs | 16 ++++++++ .../RegistratieVerlopenProcessor.cs | 41 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 services/domain/Big.Application/ExpireRegistrationWorker.cs create mode 100644 services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs diff --git a/services/domain/Big.Application/ExpireRegistrationWorker.cs b/services/domain/Big.Application/ExpireRegistrationWorker.cs new file mode 100644 index 0000000..6e900e7 --- /dev/null +++ b/services/domain/Big.Application/ExpireRegistrationWorker.cs @@ -0,0 +1,34 @@ +using Big.Domain; + +namespace Big.Application; + +/// +/// Handles one acquired RegistratieVerlopen external-worker job (S-10a, ADR-0017): load the +/// registration the job correlates to and expire it to VERLOPEN — the 30-day document-wait timer fired +/// before the documents arrived, so the case is cancelled. Pure application logic over ports; it knows +/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors +/// . +/// +public sealed class ExpireRegistrationWorker(IRegistrationStore store) +{ + /// + /// Process the job. Idempotent: a redelivered job whose registration is already VERLOPEN is a + /// no-op — not persisted again (§8.6, at-least-once delivery). An unknown registration is an error: + /// it throws, leaving the job un-completed for Flowable to redeliver. + /// + public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(job); + + var registration = await store.GetAsync(job.RegistrationId, ct) + ?? throw new InvalidOperationException( + $"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}."); + + // A redelivered job whose registration is already VERLOPEN completes without persisting again. + if (registration.Status == RegistrationStatus.Verlopen) + return; + + registration.Expire(); + await store.SaveAsync(registration, ct); + } +} diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index b89ac82..492b232 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -95,3 +95,11 @@ public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId); /// once the 14-day boundary timer fires (ADR-0015). /// public sealed record EscalatieJob(string JobId, string ProcessInstanceId); + +/// +/// An acquired RegistratieVerlopen job (S-10a): the Flowable job id and the registration id it +/// carries as a process variable. The 30-day boundary timer on WachtOpDocumenten spawns it when +/// the required documents were not supplied in time; expiring the correlated registration to VERLOPEN +/// cancels the case (ADR-0017). +/// +public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId); diff --git a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs index ade8872..912ff59 100644 --- a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs +++ b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs @@ -36,3 +36,19 @@ public interface IBeoordelingEscalatieClient /// Complete an acquired escalation job so its token reaches the escalation end event. Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default); } + +/// +/// The document-timeout side of the Workflow Client (S-10a): the RegistratieVerlopen +/// external-worker jobs parked by the 30-day boundary timer on WachtOpDocumenten. Kept separate +/// from the other worker ports (interface segregation) so neither the OpenZaak nor escalation worker +/// sees expiry. Implemented by — the only code that talks to +/// Flowable (§8.2, ADR-0017). +/// +public interface IRegistratieVerlopenClient +{ + /// Acquire and lock up to RegistratieVerlopen jobs. + Task> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default); + + /// Complete an acquired expiry job so its token reaches the endVerlopen end event. + Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default); +} diff --git a/services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs b/services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs new file mode 100644 index 0000000..681e765 --- /dev/null +++ b/services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs @@ -0,0 +1,41 @@ +using Big.Application; +using Microsoft.Extensions.Logging; + +namespace Big.Infrastructure; + +/// +/// One poll tick of the document-timeout worker (S-10a, ADR-0017): acquire the parked +/// RegistratieVerlopen jobs — the tokens the 30-day boundary timer on WachtOpDocumenten +/// spawns — expire each correlated registration via the , and +/// complete the job so its token reaches endVerlopen. A job that fails is logged and left +/// un-completed so Flowable redelivers it (§8.6). Split out from the hosted pump so the +/// acquire→expire→complete logic is unit-testable without a running host. Mirrors +/// and . +/// +public sealed class RegistratieVerlopenProcessor( + IRegistratieVerlopenClient client, + ExpireRegistrationWorker worker, + ILogger logger) +{ + /// Acquire and process up to jobs. Returns the number acquired. + public async Task PumpOnceAsync(int maxJobs, CancellationToken ct = default) + { + var jobs = await client.AcquireRegistratieVerlopenJobsAsync(maxJobs, ct); + + foreach (var job in jobs) + { + try + { + await worker.HandleAsync(job, ct); + await client.CompleteRegistratieVerlopenJobAsync(job.JobId, ct); + } + catch (Exception ex) + { + // Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6). + logger.LogError(ex, "RegistratieVerlopen job {JobId} failed; leaving it for redelivery.", job.JobId); + } + } + + return jobs.Count; + } +} -- 2.54.0 From 9421aa007a4409098abcbe5f5736cfa5ab859f27 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:45:26 +0200 Subject: [PATCH 06/23] test(infra): Flowable client acquires/completes RegistratieVerlopen + completes wait task (refs #102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: the Workflow Client posts the RegistratieVerlopen topic and parses the correlated registration id, completes the expiry job, and (documents-in-time) completes the WachtOpDocumenten user task in the instance — best-effort no-op if that task is no longer open. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Big.Tests/FlowableWorkflowClientTests.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index b67d9d7..9f7b184 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -426,4 +426,106 @@ public class FlowableWorkflowClientTests capture.Seen.RequestUri!.ToString()); Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); } + + // ── S-10a (#102): document-wait timeout → RegistratieVerlopen (ADR-0017) ────────────────────── + // A 30-day interrupting boundary timer on WachtOpDocumenten spawns a RegistratieVerlopen + // external-worker job carrying the registration id; the worker expires the registration and + // completes the job. Separately, "documents received" completes the WachtOpDocumenten user task. + + [Fact] + public async Task Acquire_verlopen_jobs_posts_the_topic_and_parses_jobs_with_their_registration_id() + { + var rid = RegistrationId.New(); + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + $$"""[{"id":"job-9","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]""")); + + var jobs = await client.AcquireRegistratieVerlopenJobsAsync(3); + + var job = Assert.Single(jobs); + Assert.Equal("job-9", job.JobId); + Assert.Equal(rid, job.RegistrationId); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs", + capture.Seen!.RequestUri!.ToString()); + Assert.Contains("\"topic\":\"RegistratieVerlopen\"", capture.Body); + Assert.Contains("\"numberOfTasks\":3", capture.Body); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + } + + [Theory] + [InlineData("[]")] + [InlineData("null")] + public async Task Acquire_verlopen_jobs_returns_empty_when_none_are_parked(string body) + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, body)); + + Assert.Empty(await client.AcquireRegistratieVerlopenJobsAsync(1)); + Assert.NotNull(capture.Seen); + } + + [Fact] + public async Task Complete_verlopen_job_posts_to_the_job_complete_endpoint() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await client.CompleteRegistratieVerlopenJobAsync("job-9"); + + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete", + capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + } + + [Fact] + public async Task Provide_documents_completes_the_wacht_op_documenten_task_in_the_instance() + { + var requests = new List<(HttpMethod Method, string Url, string? Body)>(); + var client = Client(new StubHandler(async req => + { + requests.Add((req.Method, req.RequestUri!.ToString(), + req.Content is null ? null : await req.Content.ReadAsStringAsync())); + return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks") + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[{"id":"task-3"}],"total":1}""", + Encoding.UTF8, "application/json"), + } + : new HttpResponseMessage(HttpStatusCode.OK); + })); + + await client.CompleteDocumentWaitAsync("pi-1"); + + // 1. Find the still-open WachtOpDocumenten task in this process instance. + var query = requests.Single(r => r.Url.EndsWith("service/query/tasks")); + Assert.Equal(HttpMethod.Post, query.Method); + Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body); + Assert.Contains("\"taskDefinitionKey\":\"WachtOpDocumenten\"", query.Body); + // 2. Complete that task so the process leaves the wait state. + var complete = requests.Single(r => r.Url.EndsWith("service/runtime/tasks/task-3")); + Assert.Equal(HttpMethod.Post, complete.Method); + Assert.Contains("\"action\":\"complete\"", complete.Body); + } + + [Fact] + public async Task Provide_documents_is_a_no_op_when_the_wait_task_is_no_longer_open() + { + // The process already left WachtOpDocumenten (e.g. timed out): nothing to complete, no throw. + 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":[],"total":0}""", Encoding.UTF8, "application/json"), + }); + })); + + await client.CompleteDocumentWaitAsync("pi-1"); + + // Only the query ran; no task-completion POST followed. + Assert.DoesNotContain(methods, m => m == HttpMethod.Put || m == HttpMethod.Delete); + Assert.Single(methods); + } } -- 2.54.0 From 3f04cb856fa03a02d32161d30cbe7553fb796cb5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:48:38 +0200 Subject: [PATCH 07/23] feat(infra): Flowable RegistratieVerlopen worker + document-wait completion (refs #102) FlowableWorkflowClient implements IRegistratieVerlopenClient (acquire/complete the RegistratieVerlopen jobs) and CompleteDocumentWaitAsync (complete WachtOpDocumenten, best-effort). Wires the RegistratieVerlopenProcessor + hosted RegistratieVerlopenPump into the domain host and excludes the pump from mutation (like the other pumps). Fakes updated for the new IWorkflowClient member. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Api/Program.cs | 6 +++ services/domain/Big.Application/Ports.cs | 8 +++ .../FlowableWorkflowClient.cs | 40 ++++++++++++++- .../RegistratieVerlopenPump.cs | 49 +++++++++++++++++++ services/domain/Big.Tests/Fakes.cs | 7 +++ services/domain/stryker-config.json | 3 +- .../acceptance/Support/InMemoryDomainPorts.cs | 7 +++ 7 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 services/domain/Big.Infrastructure/RegistratieVerlopenPump.cs diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index ac83c22..627725e 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -22,6 +22,7 @@ builder.Services.AddTransient(sp => sp.GetRequiredService(sp => sp.GetRequiredService()); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(); builder.Services.AddScoped(); @@ -32,12 +33,17 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion. builder.Services.AddHostedService(); // The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns // each overdue beoordeling to the teamlead (S-14). builder.Services.AddHostedService(); +// The document-timeout worker polls the RegistratieVerlopen jobs the 30-day timer on WachtOpDocumenten +// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017). +builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 492b232..af12888 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -25,6 +25,14 @@ public interface IWorkflowClient /// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless. /// Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default); + + /// + /// Signal that the required documents have arrived (S-10a): complete the WachtOpDocumenten + /// user task in the instance so the process leaves the 30-day wait state and continues to + /// beoordeling (ADR-0017). Best-effort — if the instance is not parked at that task (already + /// continued, or timed out) it is a no-op. The upload trigger that calls this is wired in S-10b. + /// + Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index edf379c..b3938b4 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -15,12 +15,14 @@ namespace Big.Infrastructure; /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) - : IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient + : IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient, IRegistratieVerlopenClient { private const string Topic = "OpenZaakAanmaken"; private const string EscalatieTopic = "BeoordelingEscaleren"; + private const string VerlopenTopic = "RegistratieVerlopen"; private const string ProcessDefinitionKey = "registratie"; private const string BeoordelenTaskKey = "Beoordelen"; + private const string WachtOpDocumentenTaskKey = "WachtOpDocumenten"; private const string BehandelaarGroup = "behandelaar"; private const string TeamleadGroup = "teamlead"; private const string RegistrationIdVariable = "registrationId"; @@ -114,6 +116,25 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public async Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default) + { + // Find the still-open WachtOpDocumenten task in this instance and complete it, so the process + // leaves the 30-day wait and continues to beoordeling (S-10a, ADR-0017). If the instance is no + // longer parked there (already continued, or the timer already cancelled it) this is a + // best-effort no-op — mirroring the withdrawal/escalation correlation (§8.6). + var query = new TaskByInstanceQueryRequest(processInstanceId, WachtOpDocumentenTaskKey); + var page = await PostAsync( + "service/query/tasks", query, ct); + + var task = page?.Data?.FirstOrDefault(); + if (task is null) + return; + + using var response = await SendAsync( + $"service/runtime/tasks/{task.Id}", new CompleteTaskRequest("complete", []), ct); + response.EnsureSuccessStatusCode(); + } + public async Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default) { var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId); @@ -155,6 +176,23 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public async Task> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default) + { + var request = new AcquireJobsRequest(VerlopenTopic, options.LockDuration, maxJobs, options.WorkerId); + + var jobs = await PostAsync>( + "external-job-api/acquire/jobs", request, ct) ?? []; + + return [.. jobs.Select(job => new RegistratieVerlopenJob(job.Id, RegistrationId.Parse(job.RegistrationId())))]; + } + + public async Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default) + { + using var response = await SendAsync( + $"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct); + response.EnsureSuccessStatusCode(); + } + private async Task GetAsync(string path, CancellationToken ct) { var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path)); diff --git a/services/domain/Big.Infrastructure/RegistratieVerlopenPump.cs b/services/domain/Big.Infrastructure/RegistratieVerlopenPump.cs new file mode 100644 index 0000000..4b2ba82 --- /dev/null +++ b/services/domain/Big.Infrastructure/RegistratieVerlopenPump.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Big.Infrastructure; + +/// +/// The hosted polling loop of the document-timeout worker (S-10a, ADR-0017): on an interval it +/// resolves a scoped and asks it to drain the parked +/// RegistratieVerlopen jobs. A deliberately thin shell — all acquire/expire/complete logic +/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope, +/// and loop resilience. Structurally identical to . +/// +public sealed class RegistratieVerlopenPump( + IServiceScopeFactory scopeFactory, + FlowableOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + // A transient fault (e.g. Flowable briefly unreachable) must not kill the loop. + logger.LogError(ex, "RegistratieVerlopen job poll failed; retrying after the poll interval."); + } + + try + { + await Task.Delay(options.PollInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } +} diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index e9c6953..f183fda 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -34,6 +34,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac public RegistrationId? StartedFor { get; private set; } public DiplomaOrigin? StartedWithOrigin { get; private set; } public string? WithdrawnProcessInstanceId { get; private set; } + public string? CompletedDocumentWaitFor { get; private set; } public Task StartRegistrationProcessAsync( RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default) @@ -49,6 +50,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac WithdrawnProcessInstanceId = processInstanceId; return Task.CompletedTask; } + + public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default) + { + CompletedDocumentWaitFor = processInstanceId; + return Task.CompletedTask; + } } /// A fake user-task client for the werkbak/decision use cases: returns a scripted set of diff --git a/services/domain/stryker-config.json b/services/domain/stryker-config.json index 0391844..ac22608 100644 --- a/services/domain/stryker-config.json +++ b/services/domain/stryker-config.json @@ -5,7 +5,8 @@ "reporters": ["progress", "html"], "mutate": [ "!**/OpenZaakJobPump.cs", - "!**/BeoordelingEscalatiePump.cs" + "!**/BeoordelingEscalatiePump.cs", + "!**/RegistratieVerlopenPump.cs" ], "thresholds": { "high": 95, diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index af07de0..d52bb05 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -14,6 +14,7 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient public RegistrationId? StartedFor { get; private set; } public DiplomaOrigin? StartedWithOrigin { get; private set; } public string? WithdrawnProcessInstanceId { get; private set; } + public string? CompletedDocumentWaitFor { get; private set; } public Task StartRegistrationProcessAsync( RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default) @@ -28,6 +29,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient WithdrawnProcessInstanceId = processInstanceId; return Task.CompletedTask; } + + public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default) + { + CompletedDocumentWaitFor = processInstanceId; + return Task.CompletedTask; + } } /// An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, -- 2.54.0 From 9bd71f1e780dea0bd258cd275ce8f8a40c764015 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:53:01 +0200 Subject: [PATCH 08/23] feat(workflow): WachtOpDocumenten wait task + 30-day timeout cancellation (refs #102) Inserts a WachtOpDocumenten user task after OpenZaakAanmaken with an interrupting P30D boundary timer: "documents received" completes it and the process continues to the diploma routing; on timeout the RegistratieVerlopen external-worker task runs and the process ends as verlopen (ADR-0017). Verified live against flowable-rest: complete -> routes to Beoordelen; timer fire -> RegistratieVerlopen job (carrying registrationId) + the wait task cancelled. Co-Authored-By: Claude Opus 4.8 (1M context) --- workflows/registratie.bpmn | 119 +++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 32 deletions(-) diff --git a/workflows/registratie.bpmn b/workflows/registratie.bpmn index 360297e..669a6ab 100644 --- a/workflows/registratie.bpmn +++ b/workflows/registratie.bpmn @@ -22,10 +22,16 @@ (BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer only changes who may claim it. - S-13 adds diploma-eligibility routing: between OpenZaakAanmaken and Beoordelen a DMN service + S-13 adds diploma-eligibility routing: between the document wait and Beoordelen a DMN service task (flowable:type="dmn") evaluates the `diploma-eligibility` decision on the diplomaOrigin start variable; an exclusive gateway routes a foreign diploma through the CBGV-advies user task - before Beoordelen, a domestic one straight there (ADR-0016). --> + before Beoordelen, a domestic one straight there (ADR-0016). + S-10a adds the document wait: right after the zaak is opened the process parks at a + WachtOpDocumenten user task with an INTERRUPTING P30D boundary timer. "Documents received" + (the S-10b upload, via the Workflow Client) completes the task and the process continues to the + diploma routing; if the 30 days lapse first the timer cancels the task and runs the + RegistratieVerlopen external-worker task, whose worker expires the registration to VERLOPEN, + ending the process as "verlopen" (ADR-0017). --> @@ -38,7 +44,32 @@ flowable:type="external-worker" flowable:topic="OpenZaakAanmaken"/> - + + + + + + + + + + P30D + + + + + + + + + + +