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('/'));
+}