diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj
index 50d99ab..e062468 100644
--- a/tests/acceptance/Acceptance.csproj
+++ b/tests/acceptance/Acceptance.csproj
@@ -22,6 +22,9 @@
+
+
diff --git a/tests/acceptance/Features/EenBeoordelingEscaleren.feature b/tests/acceptance/Features/EenBeoordelingEscaleren.feature
new file mode 100644
index 0000000..7072d43
--- /dev/null
+++ b/tests/acceptance/Features/EenBeoordelingEscaleren.feature
@@ -0,0 +1,22 @@
+# language: en
+# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
+# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
+# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
+# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
+# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
+Feature: Een beoordeling escaleren
+ Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
+ zodat aanvragen niet blijven liggen.
+
+ Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
+ Given a registration parked at the Beoordelen task for the behandelaar
+ When the 14-day escalation timer fires
+ And the escalation worker runs
+ Then the beoordeling is reassigned to the teamlead
+
+ Scenario: Een tijdig afgeronde beoordeling escaleert niet
+ Given a registration parked at the Beoordelen task for the behandelaar
+ When the behandelaar completes the beoordeling before the timer fires
+ And the 14-day escalation timer fires
+ And the escalation worker runs
+ Then the beoordeling stays with the behandelaar
diff --git a/tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs b/tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs
new file mode 100644
index 0000000..1d49dee
--- /dev/null
+++ b/tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs
@@ -0,0 +1,44 @@
+using Acceptance.Support;
+using Big.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using Reqnroll;
+using Xunit;
+
+namespace Acceptance.Steps;
+
+/// Bindings for EenBeoordelingEscaleren.feature (S-14). Drives the escalation worker
+/// () against an in-memory Flowable stand-in; one instance
+/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
+/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.
+[Binding]
+[Scope(Feature = "Een beoordeling escaleren")]
+public sealed class EenBeoordelingEscalerenSteps
+{
+ private readonly InMemoryEscalatieClient _flowable = new();
+ private string _processInstanceId = "";
+
+ [Given("a registration parked at the Beoordelen task for the behandelaar")]
+ public void GivenARegistrationParkedForTheBehandelaar()
+ => _processInstanceId = _flowable.ParkBeoordeling();
+
+ [When("the 14-day escalation timer fires")]
+ public void WhenTheEscalationTimerFires()
+ => _flowable.FireEscalationTimer(_processInstanceId);
+
+ [When("the behandelaar completes the beoordeling before the timer fires")]
+ public void WhenTheBehandelaarCompletesBeforeTheTimer()
+ => _flowable.CompleteBeoordeling(_processInstanceId);
+
+ [When("the escalation worker runs")]
+ public async Task WhenTheEscalationWorkerRuns()
+ => await new BeoordelingEscalatieProcessor(
+ _flowable, NullLogger.Instance).PumpOnceAsync(5);
+
+ [Then("the beoordeling is reassigned to the teamlead")]
+ public void ThenTheBeoordelingIsReassignedToTheTeamlead()
+ => Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
+
+ [Then("the beoordeling stays with the behandelaar")]
+ public void ThenTheBeoordelingStaysWithTheBehandelaar()
+ => Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
+}
diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs
index 3e21752..4926b21 100644
--- a/tests/acceptance/Support/InMemoryDomainPorts.cs
+++ b/tests/acceptance/Support/InMemoryDomainPorts.cs
@@ -1,5 +1,6 @@
using Big.Application;
using Big.Domain;
+using Big.Infrastructure;
namespace Acceptance.Support;
@@ -73,6 +74,59 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
}
}
+/// An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
+/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
+/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
+/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.
+public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
+{
+ private sealed class ParkedTask
+ {
+ public string CandidateGroup { get; set; } = "behandelaar";
+ public bool IsOpen { get; set; } = true;
+ }
+
+ private readonly Dictionary _tasks = [];
+ private readonly List _parked = [];
+ private int _seq;
+
+ /// A registration parks at Beoordelen, claimable by the behandelaar group.
+ public string ParkBeoordeling()
+ {
+ var pid = $"pi-{++_seq}";
+ _tasks[pid] = new ParkedTask();
+ return pid;
+ }
+
+ /// The behandelaar completes the beoordeling before the timer fires: the task closes.
+ public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
+
+ /// The 14-day boundary timer fires: a non-interrupting token parks an escalation job.
+ public void FireEscalationTimer(string processInstanceId)
+ => _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
+
+ /// The candidate group that may currently pick the task up.
+ public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
+
+ public Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
+ => Task.FromResult>(_parked.Take(maxJobs).ToList());
+
+ public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
+ {
+ // No-op if the behandelaar already completed it — the timer/completion race (§8.6).
+ var task = _tasks[processInstanceId];
+ if (task.IsOpen)
+ task.CandidateGroup = "teamlead";
+ return Task.CompletedTask;
+ }
+
+ public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
+ {
+ _parked.RemoveAll(j => j.JobId == jobId);
+ return Task.CompletedTask;
+ }
+}
+
/// An in-memory registration store for the domain acceptance scenario.
public sealed class InMemoryRegistrationStore : IRegistrationStore
{