diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs
index 4a26dd5..8e74b72 100644
--- a/services/domain/Big.Application/Ports.cs
+++ b/services/domain/Big.Application/Ports.cs
@@ -86,3 +86,10 @@ public interface IRegistrationStore
/// it) and the registration id it carries as a process variable.
///
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
+
+///
+/// An acquired BeoordelingEscaleren escalation job (S-14): the Flowable job id and the process
+/// instance whose still-open Beoordelen task must be reassigned from behandelaar to teamlead
+/// once the 14-day boundary timer fires (ADR-0015).
+///
+public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs
index 6a723c8..5f886d4 100644
--- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs
+++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs
@@ -106,6 +106,15 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode();
}
+ public Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
+ => throw new NotImplementedException();
+
+ public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
+ => throw new NotImplementedException();
+
+ public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
+ => throw new NotImplementedException();
+
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.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs
index aff3bf3..d852e11 100644
--- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs
+++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs
@@ -306,4 +306,108 @@ public class FlowableWorkflowClientTests
await Assert.ThrowsAsync(
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
}
+
+ // ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ───────────────
+ // The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as
+ // an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open
+ // Beoordelen task from the behandelaar group to teamlead, then completes the escalation job.
+
+ [Fact]
+ public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance()
+ {
+ var capture = new RequestCapture();
+ var client = Client(capture.Responds(HttpStatusCode.OK,
+ """[{"id":"job-9","processInstanceId":"pi-1"}]"""));
+
+ var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3);
+
+ var job = Assert.Single(jobs);
+ Assert.Equal("job-9", job.JobId);
+ Assert.Equal("pi-1", job.ProcessInstanceId);
+ Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
+ capture.Seen!.RequestUri!.ToString());
+ Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body);
+ Assert.Contains("\"numberOfTasks\":3", capture.Body);
+ Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
+ }
+
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("null")]
+ public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body)
+ {
+ var capture = new RequestCapture();
+ var client = Client(capture.Responds(HttpStatusCode.OK, body));
+
+ Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1));
+ Assert.NotNull(capture.Seen);
+ }
+
+ [Fact]
+ public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead()
+ {
+ var requests = new List<(HttpMethod Method, string Url, string? Body)>();
+ var client = Client(new StubHandler(async req =>
+ {
+ requests.Add((req.Method, req.RequestUri!.ToString(),
+ req.Content is null ? null : await req.Content.ReadAsStringAsync()));
+ // The task query returns the still-open Beoordelen task parked in this instance.
+ return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""",
+ Encoding.UTF8, "application/json"),
+ }
+ : new HttpResponseMessage(HttpStatusCode.OK);
+ }));
+
+ await client.ReassignBeoordelingToTeamleadAsync("pi-1");
+
+ // 1. Find the still-open Beoordelen task in this process instance.
+ var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
+ Assert.Equal(HttpMethod.Post, query.Method);
+ Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
+ Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body);
+ // 2. Add teamlead as a candidate group on that task.
+ var add = requests.Single(r => r.Method == HttpMethod.Post
+ && r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks"));
+ Assert.Contains("\"group\":\"teamlead\"", add.Body);
+ Assert.Contains("\"type\":\"candidate\"", add.Body);
+ // 3. Remove behandelaar as a candidate group — the task now belongs to teamlead.
+ Assert.Contains(requests, r => r.Method == HttpMethod.Delete
+ && r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate"));
+ }
+
+ [Fact]
+ public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open()
+ {
+ // The behandelaar completed it just before the timer fired: nothing to reassign, no throw.
+ var methods = new List();
+ var client = Client(new StubHandler(req =>
+ {
+ methods.Add(req.Method);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
+ });
+ }));
+
+ await client.ReassignBeoordelingToTeamleadAsync("pi-1");
+
+ Assert.DoesNotContain(HttpMethod.Delete, methods);
+ }
+
+ [Fact]
+ public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint()
+ {
+ var capture = new RequestCapture();
+ var client = Client(capture.Responds(HttpStatusCode.NoContent));
+
+ await client.CompleteBeoordelingEscalatieJobAsync("job-9");
+
+ Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
+ Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
+ capture.Seen.RequestUri!.ToString());
+ Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
+ }
}