diff --git a/.gitignore b/.gitignore
index 21f4674..91aea34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,9 @@ obj/
[Rr]elease/
*.user
+# Reqnroll-generated test code (regenerated from *.feature on build)
+*.feature.cs
+
# Test results / coverage
[Tt]est[Rr]esults/
*.trx
diff --git a/docs/architecture/adr-0004-bdd-framework.md b/docs/architecture/adr-0004-bdd-framework.md
new file mode 100644
index 0000000..546b72e
--- /dev/null
+++ b/docs/architecture/adr-0004-bdd-framework.md
@@ -0,0 +1,52 @@
+# ADR-0004: Reqnroll as the BDD acceptance framework
+
+- **Status:** Accepted
+- **Date:** 2026-06-04
+- **Deciders:** Respellion engineering
+- **Relates to:** S-04 (#5); supports CLAUDE.md §3 (BDD at the use-case level) and §11 (tests pyramid)
+
+## Context
+
+CLAUDE.md §11 mandates that each user-visible flow is driven by a Gherkin acceptance
+scenario living in `tests/acceptance/`, and §3 names "BDD at the use-case level" as a core
+engineering principle. The foundational slices (S-00…S-03) added no acceptance layer; S-04
+is the first slice with real domain behaviour to drive, so it is where the BDD framework is
+introduced. We need a .NET tool that:
+
+- parses Gherkin `.feature` files and binds steps to C#,
+- integrates with the existing xUnit test runner (the repo standardises on xUnit), so
+ acceptance tests run under the same `dotnet test` / `make ci` gate as everything else,
+- is actively maintained on modern .NET (we target net10.0).
+
+## Decision
+
+**Use [Reqnroll](https://reqnroll.net/) (`Reqnroll.xUnit`) for acceptance tests.**
+
+- Reqnroll is the actively-maintained, open-source successor to SpecFlow (which is no longer
+ maintained). It keeps the same Gherkin + `[Binding]` model, so the knowledge transfers.
+- `Reqnroll.xUnit` generates one xUnit test per scenario, so acceptance tests are discovered
+ and run by the same runner as the unit tests — no second test framework, no extra CI step.
+- Acceptance projects live under `tests/acceptance/` per the PRD §9 layout. Generated
+ `*.feature.cs` files are build artefacts and are git-ignored.
+
+## Consequences
+
+- **Positive:** one assertion/runner stack (xUnit) across unit and acceptance tests; scenarios
+ are written in business language (Dutch domain terms inline) and reviewed as the slice's
+ contract; maintained tooling on net10.0.
+- **Cost:** a new dependency (`Reqnroll.xUnit`) and its xUnit v2 transitive graph. Reqnroll
+ pulls `xunit.core` but not the assertion library, so the `xunit` metapackage is referenced
+ explicitly to get `Assert`.
+- **Replaceable by:** hand-written xUnit "scenario" tests with a Given/When/Then helper, at
+ the cost of losing Gherkin as the shared, readable contract — which is the whole point of §3.
+- **Follow-ups:** the real-OpenZaak integration test (Testcontainers) and the Stryker mutation
+ baseline for S-04 are tracked as their own issues split off #5.
+
+## Alternatives considered
+
+- **SpecFlow** — rejected: unmaintained and without an official net10.0 story; Reqnroll is its
+ drop-in successor.
+- **Plain xUnit Given/When/Then helpers** — rejected for user-visible flows: loses the
+ business-readable Gherkin contract that §3/§11 require. Still fine for unit-level tests.
+- **Xunit.Gherkin.Quick** — rejected: lighter but less featureful (no hooks/scoped contexts,
+ smaller community) than Reqnroll.
diff --git a/mkdocs.yml b/mkdocs.yml
index 6f4e752..92e4c5b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -23,6 +23,9 @@ nav:
- Product Requirements: PRD.md
- Architecture:
- "ADR-0001: Loose coupling": architecture/adr-0001-loose-coupling.md
+ - "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
+ - "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
+ - "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
- Working in Gitea: gitea-workflow.md
- Runbooks:
- CI: runbooks/ci.md
diff --git a/register-referentie.slnx b/register-referentie.slnx
index bf80fac..fe5cf56 100644
--- a/register-referentie.slnx
+++ b/register-referentie.slnx
@@ -10,4 +10,7 @@
+
+
+
diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj
new file mode 100644
index 0000000..c182441
--- /dev/null
+++ b/tests/acceptance/Acceptance.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/acceptance/Features/EenZaakOpenen.feature b/tests/acceptance/Features/EenZaakOpenen.feature
new file mode 100644
index 0000000..8b7e41c
--- /dev/null
+++ b/tests/acceptance/Features/EenZaakOpenen.feature
@@ -0,0 +1,27 @@
+# language: en
+# Drives S-04 (#5). The ACL is the only code that talks to ZGW (ADR-0001); it
+# default-fills the ZGW-mandatory zaak fields the domain never sees (ADR-0003).
+# Real-OpenZaak verification is a separate slice — this scenario exercises the
+# use case against an in-memory stand-in for the Zaken API.
+Feature: Een zaak openen vanuit een registratie
+ Als domein wil ik de ACL vragen een zaak te openen
+ zodat de ZGW-verplichte velden worden ingevuld zonder dat het domein ze kent.
+
+ Scenario: De ACL vult de ZGW-verplichte velden default in
+ Given a domain registration for BSN "123456782"
+ And the ACL is configured with these defaults:
+ | field | value |
+ | bronorganisatie | 517439943 |
+ | verantwoordelijkeOrganisatie | 517439943 |
+ | vertrouwelijkheidaanduiding | openbaar |
+ | zaaktype | http://openzaak/catalogi/api/v1/zaaktypen/big |
+ And today is "2026-06-04"
+ When the domain asks the ACL to open a zaak
+ Then a zaak is created with these default-filled fields
+ | field | value |
+ | bronorganisatie | 517439943 |
+ | verantwoordelijkeOrganisatie | 517439943 |
+ | vertrouwelijkheidaanduiding | openbaar |
+ | zaaktype | http://openzaak/catalogi/api/v1/zaaktypen/big |
+ | startdatum | 2026-06-04 |
+ And the ACL returns the URL of the created zaak
diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs
new file mode 100644
index 0000000..8c191bc
--- /dev/null
+++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs
@@ -0,0 +1,65 @@
+using Acceptance.Support;
+using Acl.Application;
+using Reqnroll;
+using Xunit;
+
+namespace Acceptance.Steps;
+
+/// Bindings for EenZaakOpenen.feature. Reqnroll creates one
+/// instance per scenario, so instance fields hold scenario-scoped state.
+[Binding]
+public sealed class EenZaakOpenenSteps
+{
+ private readonly InMemoryZaakGateway _gateway = new();
+ private DomainRegistration? _registration;
+ private AclDefaults? _defaults;
+ private DateOnly _today;
+ private Uri? _returnedUrl;
+
+ [Given("a domain registration for BSN \"(.*)\"")]
+ public void GivenADomainRegistrationForBsn(string bsn)
+ => _registration = new DomainRegistration(bsn);
+
+ [Given("the ACL is configured with these defaults:")]
+ public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)
+ {
+ var values = ToFieldMap(defaults);
+ _defaults = new AclDefaults
+ {
+ Bronorganisatie = values["bronorganisatie"],
+ VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
+ Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
+ ZaaktypeUrl = new Uri(values["zaaktype"]),
+ };
+ }
+
+ [Given("today is \"(.*)\"")]
+ public void GivenTodayIs(string date)
+ => _today = DateOnly.Parse(date);
+
+ [When("the domain asks the ACL to open a zaak")]
+ public async Task WhenTheDomainAsksTheAclToOpenAZaak()
+ {
+ var service = new AclService(_gateway, _defaults!, new FixedClock(_today));
+ _returnedUrl = await service.OpenZaakAsync(_registration!);
+ }
+
+ [Then("a zaak is created with these default-filled fields")]
+ public void ThenAZaakIsCreatedWithTheseDefaultFilledFields(DataTable expected)
+ {
+ var request = Assert.IsType(_gateway.Captured);
+ var fields = ToFieldMap(expected);
+ Assert.Equal(fields["bronorganisatie"], request.Bronorganisatie);
+ Assert.Equal(fields["verantwoordelijkeOrganisatie"], request.VerantwoordelijkeOrganisatie);
+ Assert.Equal(fields["vertrouwelijkheidaanduiding"], request.Vertrouwelijkheidaanduiding);
+ Assert.Equal(fields["zaaktype"], request.Zaaktype.ToString());
+ Assert.Equal(fields["startdatum"], request.Startdatum.ToString("yyyy-MM-dd"));
+ }
+
+ [Then("the ACL returns the URL of the created zaak")]
+ public void ThenTheAclReturnsTheUrlOfTheCreatedZaak()
+ => Assert.Equal(InMemoryZaakGateway.CreatedZaakUrl, _returnedUrl);
+
+ private static Dictionary ToFieldMap(DataTable table)
+ => table.Rows.ToDictionary(r => r["field"], r => r["value"]);
+}
diff --git a/tests/acceptance/Support/FixedClock.cs b/tests/acceptance/Support/FixedClock.cs
new file mode 100644
index 0000000..b6d8dce
--- /dev/null
+++ b/tests/acceptance/Support/FixedClock.cs
@@ -0,0 +1,10 @@
+using Acl.Application;
+
+namespace Acceptance.Support;
+
+/// A clock pinned to a known date so startdatum is deterministic
+/// in scenarios (ADR-0003).
+public sealed class FixedClock(DateOnly today) : IClock
+{
+ public DateOnly Today { get; } = today;
+}
diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs
new file mode 100644
index 0000000..a1dde7b
--- /dev/null
+++ b/tests/acceptance/Support/InMemoryZaakGateway.cs
@@ -0,0 +1,20 @@
+using Acl.Application;
+
+namespace Acceptance.Support;
+
+/// An in-memory stand-in for the OpenZaak Zaken API. It captures the
+/// fully default-filled the ACL builds and returns a
+/// fixed zaak URL, so the acceptance scenario can verify the use case without a
+/// running OpenZaak (real-OpenZaak verification is a separate slice).
+public sealed class InMemoryZaakGateway : IZaakGateway
+{
+ public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123");
+
+ public ZaakRequest? Captured { get; private set; }
+
+ public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
+ {
+ Captured = request;
+ return Task.FromResult(CreatedZaakUrl);
+ }
+}