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/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/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/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/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/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/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/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();
}
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/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/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);
}
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('/'));
+}
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");
}
diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts
index c2d604b..9c678ab 100644
--- a/tests/e2e/registration.spec.ts
+++ b/tests/e2e/registration.spec.ts
@@ -26,15 +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();
+
+ // #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
.poll(async () => {
await page.reload();
- return page.getByRole('cell', { name: 'INGEDIEND' }).count();
+ 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
@@ -49,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);
});