From ffbd8072b24d46cf9bb78ce619e71031e1693886 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 14:46:32 +0200 Subject: [PATCH 1/6] feat(acl): set and read the zaak identificatie as the citizen reference (refs #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACL now writes the domain registrationId as the zaak's identificatie on POST /zaken, and exposes GET-through POST /zaken/reference to read a zaak's identificatie back. Only the ACL touches ZGW (§8.1); the reference is the single value shown on both the self-service confirmation and the openbaar register. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/acl/Acl.Api/Program.cs | 14 ++++- services/acl/Acl.Application/AclService.cs | 11 +++- .../acl/Acl.Application/DomainRegistration.cs | 5 +- services/acl/Acl.Application/IZaakGateway.cs | 4 ++ services/acl/Acl.Application/ZaakRequest.cs | 3 +- .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 27 ++++++++- .../OpenZaakGatewayIntegrationTests.cs | 10 +++- services/acl/Acl.Tests/AclServiceTests.cs | 35 ++++++++++- .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 59 ++++++++++++++++++- tests/acceptance/Steps/EenZaakOpenenSteps.cs | 2 +- .../acceptance/Support/InMemoryZaakGateway.cs | 3 + 11 files changed, 159 insertions(+), 14 deletions(-) diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index d601366..f220cb1 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -20,7 +20,7 @@ app.MapGet("/health", () => "Healthy"); // The ACL's single operation, exposed as a service endpoint. app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => { - var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct); + var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct); return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); }); @@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella return Results.NoContent(); }); +// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich +// the read projection without reading ZGW itself (§8.1, #78). +app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) => +{ + var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct); + return Results.Ok(new { reference }); +}); + app.Run(); -public sealed record OpenZaakRequest(string Bsn); +public sealed record OpenZaakRequest(string Bsn, string Reference); public sealed record SetStatusRequest(string ZaakUrl); +public sealed record ZaakReferenceRequest(string ZaakUrl); + public partial class Program; diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index f9b866a..a70e7ca 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc defaults.VerantwoordelijkeOrganisatie, defaults.Vertrouwelijkheidaanduiding, defaults.ZaaktypeUrl, - clock.Today); + clock.Today, + registration.Reference); return gateway.OpenZaakAsync(request, ct); } @@ -28,4 +29,12 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); } + + /// The zaak's reference (its ZGW identificatie), for the read projection (#78). + public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + return gateway.GetZaakIdentificatieAsync(zaakUrl, ct); + } } diff --git a/services/acl/Acl.Application/DomainRegistration.cs b/services/acl/Acl.Application/DomainRegistration.cs index de985f0..6773443 100644 --- a/services/acl/Acl.Application/DomainRegistration.cs +++ b/services/acl/Acl.Application/DomainRegistration.cs @@ -1,4 +1,5 @@ namespace Acl.Application; -/// Domain-language payload handed to the ACL. No ZGW concepts here. -public sealed record DomainRegistration(string Bsn); +/// Domain-language payload handed to the ACL. No ZGW concepts here. +/// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78). +public sealed record DomainRegistration(string Bsn, string Reference); diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index c608c7a..583baf5 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -12,4 +12,8 @@ public interface IZaakGateway /// the catalogus and POSTs a status against the zaak, dated . /// Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); + + /// Read the zaak's identificatie — the public-safe reference the register shows. + /// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78). + Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default); } diff --git a/services/acl/Acl.Application/ZaakRequest.cs b/services/acl/Acl.Application/ZaakRequest.cs index 875d162..9e49ac1 100644 --- a/services/acl/Acl.Application/ZaakRequest.cs +++ b/services/acl/Acl.Application/ZaakRequest.cs @@ -6,4 +6,5 @@ public sealed record ZaakRequest( string VerantwoordelijkeOrganisatie, string Vertrouwelijkheidaanduiding, Uri Zaaktype, - DateOnly Startdatum); + DateOnly Startdatum, + string Identificatie); diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 523f215..92d85ea 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : request.Zaaktype.ToString(), request.VerantwoordelijkeOrganisatie, request.Startdatum.ToString("yyyy-MM-dd"), - request.Vertrouwelijkheidaanduiding)), + request.Vertrouwelijkheidaanduiding, + request.Identificatie)), }; message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); @@ -61,6 +62,24 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : "Setting the zaak status", ct); } + public async Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl); + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + // The zaak is a geo resource; the Zaken API requires the CRS header even on GET. + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, "Reading the zaak", ct); + + var zaak = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("OpenZaak returned an empty zaak response"); + return zaak.Identificatie; + } + // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets // a Content-Length instead of a chunked body (as with zaak-create). private async Task PostAsync(string path, object dto, string action, CancellationToken ct) @@ -132,11 +151,15 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : [property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, [property: JsonPropertyName("startdatum")] string Startdatum, - [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); + [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding, + [property: JsonPropertyName("identificatie")] string Identificatie); private sealed record ZaakCreatedDto( [property: JsonPropertyName("url")] string Url); + private sealed record ZaakReadDto( + [property: JsonPropertyName("identificatie")] string Identificatie); + private sealed record StatusDto( [property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("statustype")] string Statustype, diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs index dfe88b2..9cdac3c 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) "seed it with OZ_PUBLISH=1 (`make integration` does this)."); var gateway = new OpenZaakGateway(stack.Http, stack.Options); + var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie var request = new ZaakRequest( Bronorganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943", Vertrouwelijkheidaanduiding: "openbaar", Zaaktype: zaaktype!, - Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)); + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow), + Identificatie: reference); var zaakUrl = await gateway.OpenZaakAsync(request); @@ -36,11 +38,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(), zaakUrl.ToString()); - // ...and that zaak is really persisted with the default-filled fields. + // ...and that zaak is really persisted with the default-filled fields + the reference identificatie. var zaak = await stack.GetZaakAsync(zaakUrl); Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString()); Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); + Assert.Equal(reference, zaak.GetProperty("identificatie").GetString()); } [Fact] @@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) VerantwoordelijkeOrganisatie: "517439943", Vertrouwelijkheidaanduiding: "openbaar", Zaaktype: zaaktype!, - Startdatum: DateOnly.FromDateTime(DateTime.UtcNow))); + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow), + Identificatie: Guid.NewGuid().ToString())); await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index a51a132..2e74c8f 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -22,6 +22,14 @@ public class AclServiceTests Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); return Task.CompletedTask; } + + public Uri? ReadReferenceFor; + + public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) + { + ReadReferenceFor = zaakUrl; + return Task.FromResult("REG-FROM-ZAAK"); + } } private static AclDefaults Defaults() => new() @@ -50,7 +58,7 @@ public class AclServiceTests }; var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); - var url = await service.OpenZaakAsync(new DomainRegistration("123456782")); + var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77")); Assert.Equal(gateway.Result, url); var req = Assert.IsType(gateway.Captured); @@ -59,6 +67,8 @@ public class AclServiceTests Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); + // The registration reference becomes the zaak identificatie (#78). + Assert.Equal("reg-77", req.Identificatie); } [Fact] @@ -103,4 +113,27 @@ public class AclServiceTests await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); Assert.Null(gateway.Approved); } + + [Fact] + public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie() + { + var gateway = new FakeGateway(); + var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + var reference = await service.GetZaakReferenceAsync(zaak); + + Assert.Equal("REG-FROM-ZAAK", reference); + Assert.Equal(zaak, gateway.ReadReferenceFor); + } + + [Fact] + public async Task Reading_a_null_zaak_reference_is_rejected() + { + var gateway = new FakeGateway(); + var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + + await Assert.ThrowsAsync(() => service.GetZaakReferenceAsync(null!)); + Assert.Null(gateway.ReadReferenceFor); + } } diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index 7ca0271..af568c8 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -22,7 +22,7 @@ public class OpenZaakGatewayTests private static ZaakRequest SampleRequest() => new( "517439943", "517439943", "openbaar", - new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4)); + new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1"); private static StubHandler Created(out RequestCapture capture) { @@ -67,6 +67,8 @@ public class OpenZaakGatewayTests Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body); Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body); + // The registration reference is set as the zaak identificatie (#78). + Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body); } [Fact] @@ -357,6 +359,61 @@ public class OpenZaakGatewayTests Assert.Equal(4, rec.Requests.Count); } + [Fact] + public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs() + { + RequestCapture? capture = null; + var handler = new StubHandler(req => + { + capture = new RequestCapture { Seen = req }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }), + }); + }); + + var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)); + + Assert.Equal("REG-XYZ", reference); + Assert.Equal(HttpMethod.Get, capture!.Seen!.Method); + Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString()); + Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme); + Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs"))); + } + + [Fact] + public async Task Reading_a_zaak_throws_when_openzaak_rejects_it() + { + var handler = new StubHandler(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") })); + + var ex = await Assert.ThrowsAsync( + () => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl))); + Assert.Contains("Reading the zaak", ex.Message); + } + + [Fact] + public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body() + { + var handler = new StubHandler(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"), + })); + + var ex = await Assert.ThrowsAsync( + () => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl))); + Assert.Contains("empty zaak response", ex.Message); + } + + [Fact] + public async Task Reading_a_null_zaak_is_rejected() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent")); + + await Assert.ThrowsAsync(() => Gateway(handler).GetZaakIdentificatieAsync(null!)); + } + [Fact] public async Task Approving_rejects_a_null_zaak_or_zaaktype() { diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index 8c191bc..04780e1 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -18,7 +18,7 @@ public sealed class EenZaakOpenenSteps [Given("a domain registration for BSN \"(.*)\"")] public void GivenADomainRegistrationForBsn(string bsn) - => _registration = new DomainRegistration(bsn); + => _registration = new DomainRegistration(bsn, "ACC-REF-1"); [Given("the ACL is configured with these defaults:")] public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults) diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index 683e0d7..09c6414 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -24,4 +24,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); return Task.CompletedTask; } + + public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) + => Task.FromResult("ACC-REF-1"); } -- 2.49.1 From 7b841d6e628a31ccf00d7aef74571f77a3d67bb5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 14:46:40 +0200 Subject: [PATCH 2/6] feat(domain): pass the registrationId to the ACL as the zaak reference (refs #78) OpenZaakWorker forwards registration.Id to IAclClient.OpenZaakAsync so the zaak's identificatie equals the reference the citizen sees on the submit confirmation. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Application/OpenZaakWorker.cs | 2 +- services/domain/Big.Application/Ports.cs | 5 ++++- services/domain/Big.Infrastructure/AclHttpClient.cs | 8 +++++--- services/domain/Big.Tests/AclHttpClientTests.cs | 7 ++++--- services/domain/Big.Tests/Fakes.cs | 4 +++- services/domain/Big.Tests/OpenZaakWorkerTests.cs | 3 +++ tests/acceptance/Support/InMemoryDomainPorts.cs | 4 +++- 7 files changed, 23 insertions(+), 10 deletions(-) diff --git a/services/domain/Big.Application/OpenZaakWorker.cs b/services/domain/Big.Application/OpenZaakWorker.cs index be0688f..b488ef8 100644 --- a/services/domain/Big.Application/OpenZaakWorker.cs +++ b/services/domain/Big.Application/OpenZaakWorker.cs @@ -28,7 +28,7 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl) if (registration.ZaakUrl is not null) return registration.ZaakUrl; - var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct); + var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, registration.Id.ToString(), ct); registration.AttachZaak(zaakUrl); await store.SaveAsync(registration, ct); return zaakUrl; diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 044777d..12350c6 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -24,7 +24,10 @@ public interface IWorkflowClient /// public interface IAclClient { - Task OpenZaakAsync(string bsn, CancellationToken ct = default); + /// Open a zaak for the registration. is the registration's + /// own reference (its id); the ACL records it as the zaak's identificatie so the openbaar register + /// can show the same reference the zorgprofessional was given (S-09b follow-up, adr-proposal #78). + Task OpenZaakAsync(string bsn, string reference, CancellationToken ct = default); /// /// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs index c4b3129..217f9dc 100644 --- a/services/domain/Big.Infrastructure/AclHttpClient.cs +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -11,10 +11,10 @@ namespace Big.Infrastructure; /// public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient { - public async Task OpenZaakAsync(string bsn, CancellationToken ct = default) + public async Task OpenZaakAsync(string bsn, string reference, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync( - new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct); + new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn, reference), ct); response.EnsureSuccessStatusCode(); var opened = await response.Content.ReadFromJsonAsync(ct) @@ -31,7 +31,9 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli response.EnsureSuccessStatusCode(); } - private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); + private sealed record OpenZaakRequest( + [property: JsonPropertyName("bsn")] string Bsn, + [property: JsonPropertyName("reference")] string Reference); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index 71311ea..1d093fb 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -15,12 +15,13 @@ public class AclHttpClientTests var client = Client(capture.Responds(HttpStatusCode.OK, """{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}""")); - var url = await client.OpenZaakAsync("123456782"); + var url = await client.OpenZaakAsync("123456782", "reg-42"); Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString()); Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString()); Assert.Contains("\"bsn\":\"123456782\"", capture.Body); + Assert.Contains("\"reference\":\"reg-42\"", capture.Body); } [Fact] @@ -29,7 +30,7 @@ public class AclHttpClientTests var capture = new RequestCapture(); var client = Client(capture.Responds(HttpStatusCode.BadGateway)); - await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782", "reg-1")); } [Fact] @@ -38,7 +39,7 @@ public class AclHttpClientTests var capture = new RequestCapture(); var client = Client(capture.Responds(HttpStatusCode.OK, "null")); - var ex = await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + var ex = await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782", "reg-1")); Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); } diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index 2377b7b..cf0a37d 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -50,15 +50,17 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl; public string? OpenedForBsn { get; private set; } + public string? OpenedWithReference { get; private set; } public int CallCount { get; private set; } public Uri? ApprovedZaakUrl { get; private set; } public int ApproveCallCount { get; private set; } - public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + public Task OpenZaakAsync(string bsn, string reference, CancellationToken ct = default) { CallCount++; OpenedForBsn = bsn; + OpenedWithReference = reference; return Task.FromResult(_zaakUrl); } diff --git a/services/domain/Big.Tests/OpenZaakWorkerTests.cs b/services/domain/Big.Tests/OpenZaakWorkerTests.cs index a57af38..3ed2863 100644 --- a/services/domain/Big.Tests/OpenZaakWorkerTests.cs +++ b/services/domain/Big.Tests/OpenZaakWorkerTests.cs @@ -25,6 +25,9 @@ public class OpenZaakWorkerTests Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl); Assert.Equal("123456782", acl.OpenedForBsn); + // The registration's own id is handed to the ACL as the zaak reference (identificatie), + // so the openbaar register can show the reference the zorgprofessional received (#78). + Assert.Equal(registration.Id.ToString(), acl.OpenedWithReference); var saved = await store.GetAsync(registration.Id); Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl); Assert.Equal(1, store.SaveCount); diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 7cc171e..fefb2e9 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -26,11 +26,13 @@ public sealed class InMemoryAclClient : IAclClient public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak"); public string? OpenedForBsn { get; private set; } + public string? OpenedWithReference { get; private set; } public Uri? ApprovedZaakUrl { get; private set; } - public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + public Task OpenZaakAsync(string bsn, string reference, CancellationToken ct = default) { OpenedForBsn = bsn; + OpenedWithReference = reference; return Task.FromResult(OpenedZaakUrl); } -- 2.49.1 From 566fc9bcea83ab2d4dedd41ad1e48fc991b9a22f Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 14:46:48 +0200 Subject: [PATCH 3/6] feat(event-subscriber): enrich the projection with the zaak reference via the ACL (refs #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On each notification the subscriber reads the zaak's reference (identificatie) through the ACL — the only code allowed to talk to ZGW (§8.1) — and persists it on the register_projection row and in the processed_notifications replay log. Storing it in the log keeps rebuild log-only (ADR-0008): no ACL/ZGW access on rebuild. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EventSubscriber.Api/AclHttpClient.cs | 30 ++++++ .../EventSubscriber.Api/Program.cs | 6 ++ .../Notification.cs | 10 +- .../NotificationProjector.cs | 15 +-- .../EventSubscriber.Application/Ports.cs | 17 +++- .../RegisterEntry.cs | 1 + .../AclHttpClientTests.cs | 86 +++++++++++++++++ .../EventSubscriber.Tests.csproj | 1 + .../EventSubscriber.Tests/InMemoryStores.cs | 13 +++ .../NotificationProjectorTests.cs | 20 +++- .../Projection.ReadModel/EfNotificationLog.cs | 3 +- .../Projection.ReadModel/EfProjectionStore.cs | 4 +- ...0714101642_AddReferenceColumns.Designer.cs | 92 +++++++++++++++++++ .../20260714101642_AddReferenceColumns.cs | 38 ++++++++ .../ProjectionDbContextModelSnapshot.cs | 8 ++ .../ProjectionDbContext.cs | 9 ++ .../ProjectionApi.Api/Program.cs | 6 +- .../Steps/RegisterProjectieBijwerkenSteps.cs | 3 +- .../Support/InMemoryProjectionStores.cs | 8 ++ 19 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs create mode 100644 services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs diff --git a/services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs b/services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs new file mode 100644 index 0000000..c24bad2 --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs @@ -0,0 +1,30 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using EventSubscriber.Application; + +namespace EventSubscriber.Api; + +/// +/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference +/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading +/// OpenZaak itself (adr-proposal #78). +/// +public sealed class AclHttpClient(HttpClient http) : IAclClient +{ + public async Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var response = await http.PostAsJsonAsync( + new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("The ACL returned an empty reference response."); + return body.Reference; + } + + private sealed record ReferenceRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + + private sealed record ReferenceResponse([property: JsonPropertyName("reference")] string Reference); +} diff --git a/services/event-subscriber/EventSubscriber.Api/Program.cs b/services/event-subscriber/EventSubscriber.Api/Program.cs index ee27d70..d71b7eb 100644 --- a/services/event-subscriber/EventSubscriber.Api/Program.cs +++ b/services/event-subscriber/EventSubscriber.Api/Program.cs @@ -11,9 +11,15 @@ var connectionString = builder.Configuration.GetConnectionString("Projection") // this value (ADR-0007), so the webhook enforces it. var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"] ?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'"); +// The ACL is the only code that may read ZGW (§8.1); the subscriber enriches the projection with the +// zaak reference through it (adr-proposal #78). +var aclBaseUrl = builder.Configuration["Acl:BaseUrl"] + ?? throw new InvalidOperationException("Missing configuration 'Acl:BaseUrl'"); builder.Services.AddProjectionReadModel(connectionString); builder.Services.AddProjectionWriteSide(); +builder.Services.AddHttpClient( + c => c.BaseAddress = new Uri(aclBaseUrl)); var app = builder.Build(); diff --git a/services/event-subscriber/EventSubscriber.Application/Notification.cs b/services/event-subscriber/EventSubscriber.Application/Notification.cs index c57884d..ac1d8ff 100644 --- a/services/event-subscriber/EventSubscriber.Application/Notification.cs +++ b/services/event-subscriber/EventSubscriber.Application/Notification.cs @@ -24,10 +24,12 @@ public sealed record Notification( public bool IsZaakStatusSet => Kanaal == "zaken" && Resource == "status" && Actie == "create"; - /// The zaak UUID used as the projection key. For a status notification the resource URL is - /// the status, so the zaak comes from hoofdObject (which, for a zaak-create, equals the - /// resource URL) — see the NRC payload note. - public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/'); + /// The zaak URL this notification concerns — hoofdObject (the zaak) for a status + /// notification, else the resource URL (which, for a zaak-create, is the zaak). + public Uri ZaakUrl => HoofdObject ?? ResourceUrl; + + /// The zaak UUID used as the projection key — the trailing segment of . + public string ZaakId => ZaakUrl.Segments[^1].Trim('/'); /// /// A deterministic dedup key. Open Notificaties carries no notification id and may diff --git a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs index 2779e5c..2d08908 100644 --- a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs +++ b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs @@ -5,17 +5,19 @@ namespace EventSubscriber.Application; /// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection /// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log. /// -public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) +public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl) { /// Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a - /// status being set (INGESCHREVEN); ignores everything else. + /// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's + /// reference via the ACL (§8.1) and records it so a rebuild needs no ZGW access (#78). public async Task HandleAsync(Notification notification, CancellationToken ct = default) { if (!notification.IsZaakCreated && !notification.IsZaakStatusSet) return; + var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct); var recorded = new RecordedNotification( - notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource); + notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference); // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped // before it touches the projection, so the projection stays a faithful derived artefact. @@ -36,7 +38,8 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore /// The projection row for an accepted notification: a status-set maps to INGESCHREVEN, /// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008). private static RegisterEntry ToEntry(RecordedNotification recorded) - => new(recorded.ZaakId, recorded.Resource == "status" - ? RegistrationStatus.Ingeschreven - : RegistrationStatus.Ingediend); + => new( + recorded.ZaakId, + recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend, + Reference: recorded.Reference); } diff --git a/services/event-subscriber/EventSubscriber.Application/Ports.cs b/services/event-subscriber/EventSubscriber.Application/Ports.cs index a2a3cf6..7e73309 100644 --- a/services/event-subscriber/EventSubscriber.Application/Ports.cs +++ b/services/event-subscriber/EventSubscriber.Application/Ports.cs @@ -20,9 +20,20 @@ public interface INotificationLog } /// A notification that has been accepted, retaining what a rebuild needs to recompute its -/// projection row — including the ZGW resource, which distinguishes a zaak-create (INGEDIEND) -/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status. -public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource); +/// projection row — the ZGW resource (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and +/// the zaak reference (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78). +public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference); + +/// +/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's +/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW +/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78). +/// +public interface IAclClient +{ + /// The zaak's reference (identificatie) for the read projection. + Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default); +} /// The read projection store. Owned by the projection bounded context (ADR-0008); the /// subscriber writes to it and the projection-api reads it. diff --git a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs index de1eeb8..52eccc5 100644 --- a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs +++ b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs @@ -9,6 +9,7 @@ namespace EventSubscriber.Application; public sealed record RegisterEntry( string Id, string Status, + string? Reference = null, string? Bsn = null, string? NaamPlaceholder = null); diff --git a/services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs b/services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs new file mode 100644 index 0000000..ef170df --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Text; +using EventSubscriber.Api; + +namespace EventSubscriber.Tests; + +/// +/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through +/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no +/// real ACL is required. +/// +public class AclHttpClientTests +{ + private static AclHttpClient Client(StubHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") }); + + [Fact] + public async Task Reads_a_zaak_reference_by_posting_the_zaak_url_and_returns_it() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-42"}""")); + + var reference = await client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.Equal("REG-42", reference); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); + } + + [Fact] + public async Task Throws_when_the_acl_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.BadGateway)); + + await Assert.ThrowsAsync( + () => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + } + + [Fact] + public async Task Throws_when_the_acl_returns_an_empty_body() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, "null")); + + var ex = await Assert.ThrowsAsync( + () => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Rejects_a_null_zaak_url_without_sending_a_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-1"}""")); + + await Assert.ThrowsAsync(() => client.GetZaakReferenceAsync(null!)); + Assert.Null(capture.Seen); + } +} + +/// A test double for that records the last request and +/// returns a scripted response — mirrors the ACL/domain gateway tests. +internal sealed class StubHandler(Func> onSend) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); +} + +/// Captures the request a client sent, including the (buffered) body. +internal sealed class RequestCapture +{ + public HttpRequestMessage? Seen { get; private set; } + public string? Body { get; private set; } + + public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req => + { + Seen = req; + Body = req.Content is null ? null : await req.Content.ReadAsStringAsync(); + var response = new HttpResponseMessage(status); + if (json is not null) + response.Content = new StringContent(json, Encoding.UTF8, "application/json"); + return response; + }); +} diff --git a/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj b/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj index 6b32f79..7505d9a 100644 --- a/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj +++ b/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs b/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs index 618e78f..5cbad6e 100644 --- a/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs +++ b/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs @@ -5,6 +5,19 @@ namespace EventSubscriber.Tests; /// In-memory stand-ins for the projection store and notification log, so the /// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's /// convention — no mocking library). +/// A fake ACL client that returns a fixed reference derived from the zaak, and records +/// how many times it was called (to prove a rebuild does not re-read via the ACL). +internal sealed class FakeAclClient : IAclClient +{ + public int CallCount { get; private set; } + + public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + { + CallCount++; + return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/')); + } +} + internal sealed class InMemoryNotificationLog : INotificationLog { private readonly Dictionary _byKey = []; diff --git a/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs b/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs index c472c9a..ed2a677 100644 --- a/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs +++ b/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs @@ -12,8 +12,9 @@ public sealed class NotificationProjectorTests private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryProjectionStore _store = new(); + private readonly FakeAclClient _acl = new(); - private NotificationProjector Projector() => new(_log, _store); + private NotificationProjector Projector() => new(_log, _store, _acl); private static Notification ZaakCreated(string url = ZaakUrl) => new("zaken", "zaak", "create", new Uri(url)); @@ -30,6 +31,23 @@ public sealed class NotificationProjectorTests var entry = Assert.Single(await _store.AllAsync()); Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id); Assert.Equal(RegistrationStatus.Ingediend, entry.Status); + // Enriched with the zaak's reference (identificatie), fetched via the ACL (#78). + Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference); + } + + [Fact] + public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl() + { + var projector = Projector(); + await projector.HandleAsync(ZaakCreated()); + var callsAfterProjection = _acl.CallCount; + + await projector.RebuildAsync(); + + var entry = Assert.Single(await _store.AllAsync()); + Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference); + // Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008). + Assert.Equal(callsAfterProjection, _acl.CallCount); } [Fact] diff --git a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs index d3d2a00..b46f510 100644 --- a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs +++ b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs @@ -16,6 +16,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog Actie = notification.Actie, ZaakId = notification.ZaakId, Resource = notification.Resource, + Reference = notification.Reference, ReceivedAt = DateTimeOffset.UtcNow, }); @@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog public async Task> AllAsync(CancellationToken ct = default) => await db.ProcessedNotifications .OrderBy(r => r.ReceivedAt) - .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource)) + .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference)) .ToListAsync(ct); } diff --git a/services/projection-api/Projection.ReadModel/EfProjectionStore.cs b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs index e1d5896..1027fab 100644 --- a/services/projection-api/Projection.ReadModel/EfProjectionStore.cs +++ b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs @@ -15,6 +15,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore { Id = entry.Id, Status = entry.Status, + Reference = entry.Reference, Bsn = entry.Bsn, NaamPlaceholder = entry.NaamPlaceholder, }); @@ -22,6 +23,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore else { row.Status = entry.Status; + row.Reference = entry.Reference; row.Bsn = entry.Bsn; row.NaamPlaceholder = entry.NaamPlaceholder; } @@ -35,6 +37,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore public async Task> AllAsync(CancellationToken ct = default) => await db.RegisterEntries .OrderBy(r => r.Id) - .Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .Select(r => new RegisterEntry(r.Id, r.Status, Reference: r.Reference, Bsn: r.Bsn, NaamPlaceholder: r.NaamPlaceholder)) .ToListAsync(ct); } diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs new file mode 100644 index 0000000..b36ce90 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs @@ -0,0 +1,92 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Projection.ReadModel; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + [DbContext(typeof(ProjectionDbContext))] + [Migration("20260714101642_AddReferenceColumns")] + partial class AddReferenceColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Actie") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actie"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("Resource") + .IsRequired() + .HasColumnType("text") + .HasColumnName("resource"); + + b.Property("ZaakId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zaak_id"); + + b.HasKey("Key"); + + b.ToTable("processed_notifications", (string)null); + }); + + modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Bsn") + .HasColumnType("text") + .HasColumnName("bsn"); + + b.Property("NaamPlaceholder") + .HasColumnType("text") + .HasColumnName("naam_placeholder"); + + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("register_projection", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs new file mode 100644 index 0000000..c76a4b5 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + /// + public partial class AddReferenceColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "reference", + table: "register_projection", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "reference", + table: "processed_notifications", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "reference", + table: "register_projection"); + + migrationBuilder.DropColumn( + name: "reference", + table: "processed_notifications"); + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs index 39692a2..182add1 100644 --- a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs +++ b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs @@ -37,6 +37,10 @@ namespace Projection.ReadModel.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("received_at"); + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + b.Property("Resource") .IsRequired() .HasColumnType("text") @@ -66,6 +70,10 @@ namespace Projection.ReadModel.Migrations .HasColumnType("text") .HasColumnName("naam_placeholder"); + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + b.Property("Status") .IsRequired() .HasColumnType("text") diff --git a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs index b826ccf..d9df709 100644 --- a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs +++ b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs @@ -24,6 +24,7 @@ public sealed class ProjectionDbContext(DbContextOptions op e.HasKey(r => r.Id); e.Property(r => r.Id).HasColumnName("id"); e.Property(r => r.Status).HasColumnName("status").IsRequired(); + e.Property(r => r.Reference).HasColumnName("reference"); e.Property(r => r.Bsn).HasColumnName("bsn"); e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder"); }); @@ -36,6 +37,7 @@ public sealed class ProjectionDbContext(DbContextOptions op e.Property(r => r.Actie).HasColumnName("actie").IsRequired(); e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired(); e.Property(r => r.Resource).HasColumnName("resource").IsRequired(); + e.Property(r => r.Reference).HasColumnName("reference"); e.Property(r => r.ReceivedAt).HasColumnName("received_at"); }); } @@ -46,6 +48,10 @@ public sealed class RegisterEntryRow { public required string Id { get; set; } public required string Status { get; set; } + + /// The citizen-facing reference (the zaak's identificatie) — matches the submit confirmation (#78). + public string? Reference { get; set; } + public string? Bsn { get; set; } public string? NaamPlaceholder { get; set; } } @@ -61,5 +67,8 @@ public sealed class ProcessedNotificationRow /// the right status without reading OpenZaak (S-09b). public required string Resource { get; set; } + /// The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78). + public string? Reference { get; set; } + public DateTimeOffset ReceivedAt { get; set; } } diff --git a/services/projection-api/ProjectionApi.Api/Program.cs b/services/projection-api/ProjectionApi.Api/Program.cs index bc13277..a2b88aa 100644 --- a/services/projection-api/ProjectionApi.Api/Program.cs +++ b/services/projection-api/ProjectionApi.Api/Program.cs @@ -23,7 +23,7 @@ app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) => { var rows = await db.RegisterEntries .OrderBy(r => r.Id) - .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder)) .ToListAsync(ct); return Results.Ok(rows); }); @@ -32,13 +32,13 @@ app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, Cancellat { var row = await db.RegisterEntries .Where(r => r.Id == id) - .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder)) .SingleOrDefaultAsync(ct); return row is null ? Results.NotFound() : Results.Ok(row); }); await app.RunAsync(); -public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder); +public sealed record RegisterEntryDto(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder); public partial class Program; diff --git a/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs b/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs index d65729f..39f485b 100644 --- a/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs +++ b/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs @@ -17,7 +17,8 @@ public sealed class RegisterProjectieBijwerkenSteps private readonly NotificationProjector _projector; private Notification? _notification; - public RegisterProjectieBijwerkenSteps() => _projector = new NotificationProjector(_log, _store); + public RegisterProjectieBijwerkenSteps() + => _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient()); [Given("a zaak is created in OpenZaak with id \"(.*)\"")] public void GivenAZaakIsCreatedInOpenZaakWithId(string id) diff --git a/tests/acceptance/Support/InMemoryProjectionStores.cs b/tests/acceptance/Support/InMemoryProjectionStores.cs index 67a7fd9..ade1106 100644 --- a/tests/acceptance/Support/InMemoryProjectionStores.cs +++ b/tests/acceptance/Support/InMemoryProjectionStores.cs @@ -38,3 +38,11 @@ public sealed class InMemoryProjectionStore : IProjectionStore public IReadOnlyList RowsFor(string id) => [.. _byId.Values.Where(e => e.Id == id)]; } + +/// A fake ACL client for the projection acceptance scenario: returns a reference derived +/// from the zaak, so the projector can enrich rows without a running ACL (#78). +public sealed class InMemoryAclReferenceClient : IAclClient +{ + public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + => Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/')); +} -- 2.49.1 From 44e6f81f81bf90777e2b0b93f545a4d3295f26f3 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 14:46:55 +0200 Subject: [PATCH 4/6] feat(portal-openbaar): surface the citizen reference in the public register (refs #78) The BFF's public view exposes id/status/reference (never bsn/naam) and searches by id or reference; the openbaar register's Referentie column and search now show the reference the citizen saw on submit. api-client + openapi.json regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/app/register/register-page.html | 2 +- .../src/app/register/register-page.spec.ts | 10 +++-- libs/api-client/src/lib/generated/bff-api.ts | 2 + services/bff/Bff.Api/DownstreamClients.cs | 7 ++-- services/bff/Bff.Api/OpenbaarProjection.cs | 6 ++- .../bff/Bff.Tests/OpenbaarEndpointTests.cs | 10 +++-- .../bff/Bff.Tests/OpenbaarProjectionTests.cs | 42 +++++++++++++------ services/bff/openapi.json | 11 ++++- tests/acceptance/Steps/BffToegangSteps.cs | 2 +- 9 files changed, 62 insertions(+), 30 deletions(-) diff --git a/apps/openbaar/src/app/register/register-page.html b/apps/openbaar/src/app/register/register-page.html index 2051621..4f76ffb 100644 --- a/apps/openbaar/src/app/register/register-page.html +++ b/apps/openbaar/src/app/register/register-page.html @@ -43,7 +43,7 @@ @for (entry of entries(); track entry.id) { - {{ entry.id }} + {{ entry.reference }} {{ entry.status }} } diff --git a/apps/openbaar/src/app/register/register-page.spec.ts b/apps/openbaar/src/app/register/register-page.spec.ts index d9dd6c9..b333cc9 100644 --- a/apps/openbaar/src/app/register/register-page.spec.ts +++ b/apps/openbaar/src/app/register/register-page.spec.ts @@ -5,8 +5,8 @@ import { axe } from 'vitest-axe'; import { RegisterPage } from './register-page'; const sample: OpenbaarEntry[] = [ - { id: 'zaak-abc', status: 'INGEDIEND' }, - { id: 'zaak-def', status: 'INGESCHREVEN' }, + { id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' }, + { id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' }, ]; function providers(get = vi.fn().mockReturnValue(of(sample))) { @@ -22,9 +22,11 @@ describe('RegisterPage', () => { await render(RegisterPage, { providers: providers(get).providers }); expect(get).toHaveBeenCalled(); - expect(await screen.findByText(/zaak-abc/)).toBeTruthy(); + // The Referentie column shows the citizen's reference (matches the submit confirmation, #78), + // not the internal zaak id. + expect(await screen.findByText(/REG-abc/)).toBeTruthy(); expect(screen.getByText(/INGEDIEND/)).toBeTruthy(); - expect(screen.getByText(/zaak-def/)).toBeTruthy(); + expect(screen.getByText(/REG-def/)).toBeTruthy(); }); it('searches by the entered term', async () => { diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index 40a825d..5f76b6b 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -27,6 +27,8 @@ import { export interface OpenbaarEntry { id: string; status: string; + /** @nullable */ + reference: string | null; } export interface SubmitAccepted { diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 2b66096..c03f166 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -6,11 +6,12 @@ namespace Bff.Api; public sealed record SubmitAccepted(string RegistrationId, string Status); /// A projection row as the projection-api serves it. Bsn/NaamPlaceholder are -/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). -public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder); +/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). +/// Reference is the public-safe citizen reference (the zaak identificatie, #78). +public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder); /// A public-safe openbaar register row — only non-sensitive fields leave the BFF. -public sealed record OpenbaarEntry(string Id, string Status); +public sealed record OpenbaarEntry(string Id, string Status, string? Reference); /// Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out). public interface IDomainClient diff --git a/services/bff/Bff.Api/OpenbaarProjection.cs b/services/bff/Bff.Api/OpenbaarProjection.cs index 7f1ae33..050c327 100644 --- a/services/bff/Bff.Api/OpenbaarProjection.cs +++ b/services/bff/Bff.Api/OpenbaarProjection.cs @@ -11,8 +11,10 @@ public static class OpenbaarProjection { var filtered = string.IsNullOrWhiteSpace(q) ? entries - : entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase)); + : entries.Where(e => + e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) || + (e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)); - return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))]; + return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))]; } } diff --git a/services/bff/Bff.Tests/OpenbaarEndpointTests.cs b/services/bff/Bff.Tests/OpenbaarEndpointTests.cs index 0fcb165..ee915b5 100644 --- a/services/bff/Bff.Tests/OpenbaarEndpointTests.cs +++ b/services/bff/Bff.Tests/OpenbaarEndpointTests.cs @@ -10,7 +10,7 @@ public class OpenbaarEndpointTests public async Task Serves_public_safe_rows_anonymously() { using var factory = new BffFactory(); - factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan")); + factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", "123456782", "Jan")); // No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09). var response = await factory.CreateClient().GetAsync("/openbaar/register"); @@ -19,7 +19,9 @@ public class OpenbaarEndpointTests var body = await response.Content.ReadAsStringAsync(); Assert.Contains("abc-111", body); Assert.Contains("INGEDIEND", body); - // The bsn must never appear in a public response. + // The public reference is surfaced (matches the submit confirmation, #78)... + Assert.Contains("REG-abc", body); + // ...but the bsn must never appear in a public response. Assert.DoesNotContain("123456782", body); } @@ -27,8 +29,8 @@ public class OpenbaarEndpointTests public async Task Filters_by_the_query_parameter() { using var factory = new BffFactory(); - factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null)); - factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null)); + factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", null, null)); + factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null)); var rows = await factory.CreateClient() .GetFromJsonAsync>("/openbaar/register?q=abc"); diff --git a/services/bff/Bff.Tests/OpenbaarProjectionTests.cs b/services/bff/Bff.Tests/OpenbaarProjectionTests.cs index a385808..666b94d 100644 --- a/services/bff/Bff.Tests/OpenbaarProjectionTests.cs +++ b/services/bff/Bff.Tests/OpenbaarProjectionTests.cs @@ -6,8 +6,8 @@ public class OpenbaarProjectionTests { private static readonly ProjectionEntry[] Sample = [ - new("abc-111", "INGEDIEND", "123456782", "Jan"), - new("def-222", "INGEDIEND", "987654321", "Piet"), + new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"), + new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"), ]; [Fact] @@ -18,31 +18,47 @@ public class OpenbaarProjectionTests Assert.Equal(2, view.Count); Assert.Equal("abc-111", view[0].Id); Assert.Equal("INGEDIEND", view[0].Status); + // The public reference (zaak identificatie) is surfaced so it matches the submit confirmation (#78). + Assert.Equal("REG-A", view[0].Reference); } [Fact] - public void Public_view_exposes_only_id_and_status() + public void Public_view_exposes_only_id_status_and_reference() { - // OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak. + // OpenbaarEntry structurally carries only public-safe fields — bsn/naam can never leak. var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray(); - Assert.Equal(["Id", "Status"], props); + Assert.Equal(["Id", "Status", "Reference"], props); } [Theory] - [InlineData("abc", 1)] - [InlineData("ABC", 1)] - [InlineData("2", 1)] - [InlineData("zzz", 0)] + [InlineData("abc", 1)] // by id + [InlineData("ABC", 1)] // by id, case-insensitive + [InlineData("def", 1)] // by id + [InlineData("zzz", 0)] // no match public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected) - { - var view = OpenbaarProjection.PublicView(Sample, q); + => Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count); - Assert.Equal(expected, view.Count); - } + [Theory] + [InlineData("REG-A", 1)] // by reference — the citizen's confirmation reference + [InlineData("reg-a", 1)] // by reference, case-insensitive + [InlineData("REG", 2)] // both share the prefix + public void Filters_by_reference_containing_the_query_case_insensitively(string q, int expected) + => Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count); [Fact] public void Blank_query_is_treated_as_no_filter() { Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count); } + + [Fact] + public void A_row_without_a_reference_never_matches_a_query() + { + // Older rows can have a null reference (the column is additive, #78/ADR-0012). A search must + // simply not match them — it must not throw and must not treat "no reference" as a match. + var entries = new[] { new ProjectionEntry("xyz-999", "INGEDIEND", Reference: null, Bsn: null, NaamPlaceholder: null) }; + + Assert.Empty(OpenbaarProjection.PublicView(entries, "REG")); + Assert.Equal("xyz-999", Assert.Single(OpenbaarProjection.PublicView(entries, "xyz")).Id); + } } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index f1305b2..92a9120 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -67,7 +67,8 @@ "OpenbaarEntry": { "required": [ "id", - "status" + "status", + "reference" ], "type": "object", "properties": { @@ -76,6 +77,12 @@ }, "status": { "type": "string" + }, + "reference": { + "type": [ + "null", + "string" + ] } } }, @@ -101,4 +108,4 @@ "name": "Bff.Api" } ] -} \ No newline at end of file +} diff --git a/tests/acceptance/Steps/BffToegangSteps.cs b/tests/acceptance/Steps/BffToegangSteps.cs index 7e19e04..165347b 100644 --- a/tests/acceptance/Steps/BffToegangSteps.cs +++ b/tests/acceptance/Steps/BffToegangSteps.cs @@ -31,7 +31,7 @@ public sealed class BffToegangSteps : IDisposable [Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")] public void GivenTheProjectionContains(string zaakId, string bsn) { - _host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null)); + _host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", "REG-" + zaakId, bsn, null)); _client = _host.CreateClient(); } -- 2.49.1 From 78e6c1d3d2cb036bdbded6b8638a13c48f133c91 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 14:47:03 +0200 Subject: [PATCH 5/6] docs(architecture): ADR-0012 reference correlation + wire ACL for the subscriber (refs #78) Records the domain->ACL (write) and event-subscriber->ACL (read) boundary for the single citizen reference, wires Acl__BaseUrl into the subscriber in compose, extends the e2e happy path to assert confirmation reference == register reference, and adds the demo note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr-0012-citizen-reference-correlation.md | 104 ++++++++++++++++++ docs/demo-script.md | 21 ++++ infra/docker-compose.yml | 5 + tests/e2e/registration.spec.ts | 4 + 4 files changed, 134 insertions(+) create mode 100644 docs/architecture/adr-0012-citizen-reference-correlation.md diff --git a/docs/architecture/adr-0012-citizen-reference-correlation.md b/docs/architecture/adr-0012-citizen-reference-correlation.md new file mode 100644 index 0000000..eb56d7c --- /dev/null +++ b/docs/architecture/adr-0012-citizen-reference-correlation.md @@ -0,0 +1,104 @@ +# ADR-0012: One citizen-facing reference across self-service and the openbaar register + +- **Status:** Accepted +- **Date:** 2026-07-14 +- **Deciders:** Respellion engineering +- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation) + +## Context + +A citizen submits through the self-service portal and is shown a confirmation with a +**reference** so they can find their registration back in the public register. But the two +sides showed **different identifiers**: + +- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the + domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak + exists. +- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL, + assigned by OpenZaak when the ACL opens the zaak. + +These never match, so the reference on the confirmation was useless for looking the entry up. +The two identifiers live on opposite sides of the ACL boundary and are generated by different +systems at different times, so there is no way to reconcile them after the fact without a +correlating value carried across the boundary. + +The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed +`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the +`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any +such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up. +This is that follow-up, scoped to the one field the citizen actually needs. + +## Decision + +**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value +as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection +with the reference by reading the zaak through the ACL, and stores it in the replay log so +rebuild stays log-only.** + +Concretely, following the request path: + +1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes + `registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets + it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per + `bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that + constructs ZGW payloads (§8.1); the domain never sees a ZGW URL. +2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the + zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`), + which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber + still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP. +3. **Projection + replay log.** The reference is written both to the `register_projection` row + **and** to the `processed_notifications` replay log (a new nullable `reference` column on + each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not + OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log + without re-reading the ACL. +4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes + `id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either + `id` or `reference`. The openbaar register's "Referentie" column now renders `reference`. + +The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured +from the submit confirmation must appear as a cell in the public register. + +### Why HTTP to the ACL, not the ACL as a library + +ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead +call the ACL **over HTTP**, consistent with every other cross-service hop in this system +(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the +ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption +boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config. + +## Consequences + +**Positive** + +- One reference, end to end: the citizen's confirmation value is exactly what the public + register shows and searches by. +- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not + OpenZaak. +- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`, + so `/admin/rebuild` needs no ACL/ZGW access. +- The column additions are nullable and additive; older rows without a reference are tolerated. + +**Negative / costs** + +- A new coupling: the Event Subscriber now depends on the ACL being reachable + (`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will + need the notification redelivered (NRC already redelivers; the projection upsert is + idempotent). +- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded: + one small GET per zaak, off the citizen's request path. +- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak + were ever configured to auto-generate `identificatie`, the correlation would break; the ACL + setting it explicitly is now load-bearing. + +## Alternatives considered + +- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and + the notification content is not ours to extend; it would also couple the projection to a + bespoke notification shape. +- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when + the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has + no zaak id to show at submit time. +- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make + rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant. +- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second + source of truth for a value that can simply be the same on both sides. diff --git a/docs/demo-script.md b/docs/demo-script.md index f178a00..2426f9e 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -227,3 +227,24 @@ curl -fsS http://localhost:8140/openbaar/register | jq > **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from > INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the > approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+). + +## #78 — One reference across both portals (ADR-0012) + +Before this change the self-service confirmation and the openbaar register showed **different** +identifiers, so a citizen could not look their registration back up. Now both show the same +**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the +Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing +it in the replay log so rebuild stays log-only (ADR-0008). + +**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC → +Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register. + +```bash +# Submit as in S-09 and note the reference on the confirmation, then find it in the public register: +ref="" +curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq +# → [ { "id": "", "status": "INGEDIEND", "reference": "" } ] +``` + +> The openbaar register's "Referentie" column and its search now use this reference — the exact value +> the citizen saw on submit. Asserted end-to-end by the Playwright happy path. diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 48c3853..167de80 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -396,6 +396,9 @@ services: image: register-referentie/event-subscriber:dev environment: ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection + # The subscriber enriches the projection with each zaak's reference (identificatie) by asking + # the ACL — the only code allowed to read ZGW (§8.1, #78). + Acl__BaseUrl: http://acl:8080/ # The bearer Open Notificaties must present on the abonnement callback. NRC's # registration probe expects a 401 without it (ADR-0007). Dev-only token. EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} @@ -410,6 +413,8 @@ services: depends_on: projection-db: condition: service_healthy + acl: + condition: service_healthy networks: [cg] # The read side of the projection. Shares Projection.ReadModel, so build context is root. diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index c2d604b..88c2de9 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -36,6 +36,10 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .toBeGreaterThan(0); + // #78: the reference shown in the public register must be the exact one the citizen saw on the + // submit confirmation — no mismatch between the two portals. + await expect(page.getByRole('cell', { name: reference })).toBeVisible(); + // Approve via the temporary admin endpoint (reached directly on the compose network, as a // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request // path by the worker, so wait for it before approving. -- 2.49.1 From f2d79919c45b56432f6de7344e852a3de8f70247 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 15:36:24 +0200 Subject: [PATCH 6/6] fix(e2e): poll the register by reference, not a shared INGEDIEND cell (refs #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify stack is shared: earlier verify-projection/verify-domain checks leave INGEDIEND rows in the register before the e2e submits. A status-only poll short-circuited on a stale row, then the reference assertion ran against a not-yet-reloaded page and timed out. Poll (with reload) on this submission's reference cell instead — stronger, and it directly proves the #78 correlation. Scope the INGEDIEND/INGESCHREVEN status checks to that reference row too. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/registration.spec.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 88c2de9..9c678ab 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -26,19 +26,22 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH // The openbaar register (anonymous, its own origin) shows the submitted entry once the projection // catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads - // on open, so reload until a submitted (INGEDIEND) row appears. + // on open, so reload until *this* submission's row appears. We poll on the reference cell (not a + // generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks, + // so a status-only poll would short-circuit on a stale row before our row is projected. await page.goto('http://openbaar/'); await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible(); - await expect - .poll(async () => { - await page.reload(); - return page.getByRole('cell', { name: 'INGEDIEND' }).count(); - }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) - .toBeGreaterThan(0); // #78: the reference shown in the public register must be the exact one the citizen saw on the // submit confirmation — no mismatch between the two portals. - await expect(page.getByRole('cell', { name: reference })).toBeVisible(); + await expect + .poll(async () => { + await page.reload(); + return page.getByRole('cell', { name: reference }).count(); + }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) + .toBeGreaterThan(0); + await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' })) + .toBeVisible(); // Approve via the temporary admin endpoint (reached directly on the compose network, as a // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request @@ -53,11 +56,12 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`); expect(approve.status()).toBe(204); - // The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN. + // The approval flows back to the projection; the openbaar register now shows *our* row (matched by + // its reference) as INGESCHREVEN. await expect .poll(async () => { await page.reload(); - return page.getByRole('cell', { name: 'INGESCHREVEN' }).count(); + return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count(); }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .toBeGreaterThan(0); }); -- 2.49.1