diff --git a/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature b/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature
new file mode 100644
index 0000000..7ec02bf
--- /dev/null
+++ b/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature
@@ -0,0 +1,23 @@
+# language: en
+# Drives S-10a (#102). After the zaak is opened the process parks at WachtOpDocumenten with an
+# INTERRUPTING 30-day boundary timer. If the documents do not arrive in time the timer cancels the
+# task and parks a RegistratieVerlopen job (ADR-0017) which the timeout worker drains, expiring the
+# registration to VERLOPEN. Documents received before the timer fires close the wait, so no expiry
+# happens. This scenario exercises the timeout worker against an in-memory Flowable stand-in; the timer
+# firing live is verify-domain.
+Feature: Een documenttermijn laten verlopen
+ Als registerbeheerder wil ik dat een aanvraag waarvoor de documenten niet binnen 30 dagen binnen zijn
+ automatisch vervalt zodat onvolledige aanvragen niet blijven liggen.
+
+ Scenario: Zonder documenten binnen 30 dagen vervalt de registratie
+ Given a registration parked at the WachtOpDocumenten task
+ When the 30-day document timer fires
+ And the document-timeout worker runs
+ Then the registration is verlopen
+
+ Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
+ Given a registration parked at the WachtOpDocumenten task
+ When the documents arrive before the timer fires
+ And the 30-day document timer fires
+ And the document-timeout worker runs
+ Then the registration is not verlopen
diff --git a/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs b/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs
new file mode 100644
index 0000000..402cfde
--- /dev/null
+++ b/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs
@@ -0,0 +1,52 @@
+using Acceptance.Support;
+using Big.Application;
+using Big.Domain;
+using Big.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using Reqnroll;
+using Xunit;
+
+namespace Acceptance.Steps;
+
+/// Bindings for EenDocumentTermijnVerlopen.feature (S-10a). Drives the timeout worker
+/// ( over the ) against
+/// an in-memory Flowable stand-in and a shared registration store; one instance per scenario. The
+/// interrupting 30-day timer either cancels the wait and expires the registration, or — if the
+/// documents arrived first — never fires; the scenario asserts on the aggregate's status.
+[Binding]
+[Scope(Feature = "Een documenttermijn laten verlopen")]
+public sealed class EenDocumentTermijnVerlopenSteps
+{
+ private readonly InMemoryDocumentTimeoutClient _flowable = new();
+ private readonly Support.InMemoryRegistrationStore _store = new();
+ private Registration _registration = null!;
+ private string _processInstanceId = "";
+
+ [Given("a registration parked at the WachtOpDocumenten task")]
+ public async Task GivenARegistrationParkedAtWachtOpDocumenten()
+ {
+ _registration = Registration.Submit("123456782");
+ await _store.SaveAsync(_registration);
+ _processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
+ }
+
+ [When("the 30-day document timer fires")]
+ public void WhenTheDocumentTimerFires() => _flowable.FireDocumentTimer(_processInstanceId);
+
+ [When("the documents arrive before the timer fires")]
+ public void WhenTheDocumentsArriveBeforeTheTimer() => _flowable.ReceiveDocuments(_processInstanceId);
+
+ [When("the document-timeout worker runs")]
+ public async Task WhenTheTimeoutWorkerRuns()
+ => await new RegistratieVerlopenProcessor(
+ _flowable, new ExpireRegistrationWorker(_store),
+ NullLogger.Instance).PumpOnceAsync(5);
+
+ [Then("the registration is verlopen")]
+ public async Task ThenTheRegistrationIsVerlopen()
+ => Assert.Equal(RegistrationStatus.Verlopen, (await _store.GetAsync(_registration.Id))!.Status);
+
+ [Then("the registration is not verlopen")]
+ public async Task ThenTheRegistrationIsNotVerlopen()
+ => Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
+}
diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs
index d52bb05..f480320 100644
--- a/tests/acceptance/Support/InMemoryDomainPorts.cs
+++ b/tests/acceptance/Support/InMemoryDomainPorts.cs
@@ -137,6 +137,57 @@ public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
}
}
+/// An in-memory Flowable stand-in for the document-timeout scenario (S-10a): it models one
+/// WachtOpDocumenten wait per process instance — whether it is still open and the registration it
+/// correlates to — and the RegistratieVerlopen jobs the interrupting 30-day boundary timer parks. It
+/// drives the timeout worker's behaviour without a running Flowable; the timer firing live is the
+/// verify-domain check.
+public sealed class InMemoryDocumentTimeoutClient : IRegistratieVerlopenClient
+{
+ private sealed class Wait
+ {
+ public required RegistrationId RegistrationId { get; init; }
+ public bool IsWaiting { get; set; } = true;
+ }
+
+ private readonly Dictionary _waits = [];
+ private readonly List _parked = [];
+ private int _seq;
+
+ /// A registration parks at WachtOpDocumenten, waiting for the citizen's documents.
+ public string ParkWaitingForDocuments(RegistrationId registrationId)
+ {
+ var pid = $"pi-{++_seq}";
+ _waits[pid] = new Wait { RegistrationId = registrationId };
+ return pid;
+ }
+
+ /// The documents arrive before the timer fires: the wait task closes, so the interrupting
+ /// timer no longer fires (mirrors the Workflow Client completing WachtOpDocumenten).
+ public void ReceiveDocuments(string processInstanceId) => _waits[processInstanceId].IsWaiting = false;
+
+ /// The 30-day interrupting boundary timer fires: if still waiting, it cancels the wait and
+ /// parks a RegistratieVerlopen job carrying the correlated registration id. A no-op if the documents
+ /// already arrived (the wait/timer race, §8.6).
+ public void FireDocumentTimer(string processInstanceId)
+ {
+ var wait = _waits[processInstanceId];
+ if (!wait.IsWaiting)
+ return;
+ wait.IsWaiting = false;
+ _parked.Add(new RegistratieVerlopenJob($"job-{++_seq}", wait.RegistrationId));
+ }
+
+ public Task> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
+ => Task.FromResult>(_parked.Take(maxJobs).ToList());
+
+ public Task CompleteRegistratieVerlopenJobAsync(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
{