diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 4cc6928..f8b500b 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -8,6 +8,13 @@
"dotnet-stryker"
],
"rollForward": false
+ },
+ "dotnet-ef": {
+ "version": "10.0.0",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
}
}
}
\ No newline at end of file
diff --git a/register-referentie.slnx b/register-referentie.slnx
index cd8e0c2..3a5661a 100644
--- a/register-referentie.slnx
+++ b/register-referentie.slnx
@@ -12,9 +12,14 @@
+
+
+
+
+
diff --git a/services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj b/services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj
new file mode 100644
index 0000000..168c420
--- /dev/null
+++ b/services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/services/event-subscriber/EventSubscriber.Api/Program.cs b/services/event-subscriber/EventSubscriber.Api/Program.cs
new file mode 100644
index 0000000..a2f7e55
--- /dev/null
+++ b/services/event-subscriber/EventSubscriber.Api/Program.cs
@@ -0,0 +1,56 @@
+using EventSubscriber.Application;
+using Projection.ReadModel;
+
+var builder = WebApplication.CreateBuilder(args);
+
+var connectionString = builder.Configuration.GetConnectionString("Projection")
+ ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
+// The exact Authorization header value Open Notificaties sends on each abonnement callback.
+// NRC probes the callback during registration and refuses it unless it returns 401 without
+// this value (ADR-0007), so the webhook enforces it.
+var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
+ ?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'");
+
+builder.Services.AddProjectionReadModel(connectionString);
+builder.Services.AddProjectionWriteSide();
+
+var app = builder.Build();
+
+// Apply migrations on start so a fresh stack reaches a usable schema unattended.
+await app.Services.MigrateProjectionAsync();
+
+app.MapGet("/health", () => "Healthy");
+
+// The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it.
+// Auth-on-callback is mandatory: without the configured Authorization, return 401 (NRC's
+// registration probe depends on this — ADR-0007).
+app.MapPost("/notifications", async (
+ HttpRequest request,
+ NotificationDto body,
+ NotificationProjector projector,
+ CancellationToken ct) =>
+{
+ if (request.Headers.Authorization != webhookToken)
+ return Results.Unauthorized();
+
+ await projector.HandleAsync(body.ToNotification(), ct);
+ return Results.NoContent();
+});
+
+// Admin: rebuild the projection from the durable notification log (PRD §8.4 — rebuildable).
+app.MapPost("/admin/rebuild", async (NotificationProjector projector, CancellationToken ct) =>
+{
+ await projector.RebuildAsync(ct);
+ return Results.NoContent();
+});
+
+await app.RunAsync();
+
+/// The NRC notification body, as Open Notificaties POSTs it. Only the fields the
+/// projection needs are bound; aanmaakdatum/kenmerken are ignored for the minimal slice.
+public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl)
+{
+ public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl);
+}
+
+public partial class Program;
diff --git a/services/event-subscriber/EventSubscriber.Api/appsettings.json b/services/event-subscriber/EventSubscriber.Api/appsettings.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/services/event-subscriber/EventSubscriber.Api/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs b/services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs
new file mode 100644
index 0000000..f64526e
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Projection.ReadModel;
+
+/// Lets dotnet ef migrations build the context at design time without a running
+/// database or the host's DI. The connection string is a placeholder — migrations only need the
+/// provider to emit Postgres-shaped SQL.
+public sealed class DesignTimeProjectionDbContextFactory : IDesignTimeDbContextFactory
+{
+ public ProjectionDbContext CreateDbContext(string[] args)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql("Host=localhost;Database=projection;Username=projection;Password=projection")
+ .Options;
+ return new ProjectionDbContext(options);
+ }
+}
diff --git a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs
new file mode 100644
index 0000000..5a25bc1
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs
@@ -0,0 +1,39 @@
+using EventSubscriber.Application;
+using Microsoft.EntityFrameworkCore;
+
+namespace Projection.ReadModel;
+
+/// EF Core implementation of the notification log. Idempotency is enforced atomically
+/// by the primary key on key: a duplicate insert raises a unique violation, which is
+/// caught and reported as "already recorded" rather than failing the request.
+public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
+{
+ public async Task TryRecordAsync(RecordedNotification notification, CancellationToken ct = default)
+ {
+ db.ProcessedNotifications.Add(new ProcessedNotificationRow
+ {
+ Key = notification.Key,
+ Actie = notification.Actie,
+ ZaakId = notification.ZaakId,
+ ReceivedAt = DateTimeOffset.UtcNow,
+ });
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ return true;
+ }
+ catch (DbUpdateException)
+ {
+ // Already recorded by an earlier (or concurrent) delivery — drop this duplicate.
+ db.ChangeTracker.Clear();
+ return false;
+ }
+ }
+
+ public async Task> AllAsync(CancellationToken ct = default)
+ => await db.ProcessedNotifications
+ .OrderBy(r => r.ReceivedAt)
+ .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId))
+ .ToListAsync(ct);
+}
diff --git a/services/projection-api/Projection.ReadModel/EfProjectionStore.cs b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs
new file mode 100644
index 0000000..e1d5896
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs
@@ -0,0 +1,40 @@
+using EventSubscriber.Application;
+using Microsoft.EntityFrameworkCore;
+
+namespace Projection.ReadModel;
+
+/// EF Core implementation of the projection store over .
+public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
+{
+ public async Task UpsertAsync(RegisterEntry entry, CancellationToken ct = default)
+ {
+ var row = await db.RegisterEntries.FindAsync([entry.Id], ct);
+ if (row is null)
+ {
+ db.RegisterEntries.Add(new RegisterEntryRow
+ {
+ Id = entry.Id,
+ Status = entry.Status,
+ Bsn = entry.Bsn,
+ NaamPlaceholder = entry.NaamPlaceholder,
+ });
+ }
+ else
+ {
+ row.Status = entry.Status;
+ row.Bsn = entry.Bsn;
+ row.NaamPlaceholder = entry.NaamPlaceholder;
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task ClearAsync(CancellationToken ct = default)
+ => await db.RegisterEntries.ExecuteDeleteAsync(ct);
+
+ 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))
+ .ToListAsync(ct);
+}
diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs
new file mode 100644
index 0000000..e0c9dc6
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs
@@ -0,0 +1,79 @@
+//
+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("20260630125055_InitialProjection")]
+ partial class InitialProjection
+ {
+ ///
+ 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("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("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/20260630125055_InitialProjection.cs b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.cs
new file mode 100644
index 0000000..de5cbba
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.cs
@@ -0,0 +1,53 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Projection.ReadModel.Migrations
+{
+ ///
+ public partial class InitialProjection : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "processed_notifications",
+ columns: table => new
+ {
+ key = table.Column(type: "text", nullable: false),
+ actie = table.Column(type: "text", nullable: false),
+ zaak_id = table.Column(type: "text", nullable: false),
+ received_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_processed_notifications", x => x.key);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "register_projection",
+ columns: table => new
+ {
+ id = table.Column(type: "text", nullable: false),
+ status = table.Column(type: "text", nullable: false),
+ bsn = table.Column(type: "text", nullable: true),
+ naam_placeholder = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_register_projection", x => x.id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "processed_notifications");
+
+ migrationBuilder.DropTable(
+ name: "register_projection");
+ }
+ }
+}
diff --git a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs
new file mode 100644
index 0000000..4bcd24c
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs
@@ -0,0 +1,76 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Projection.ReadModel;
+
+#nullable disable
+
+namespace Projection.ReadModel.Migrations
+{
+ [DbContext(typeof(ProjectionDbContext))]
+ partial class ProjectionDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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("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("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/Projection.ReadModel.csproj b/services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj
new file mode 100644
index 0000000..8192c41
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+ direct
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs
new file mode 100644
index 0000000..b260576
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs
@@ -0,0 +1,59 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Projection.ReadModel;
+
+///
+/// The read projection's persistence. This single store is the system's rebuildable read model
+/// (PRD §8.4): the Event Subscriber writes to it (projector) and the projection-api reads it
+/// (query). Both processes are the one "Read Projection" bounded context and share this schema —
+/// not a cross-domain DB share (ADR-0008 reconciles this with CLAUDE.md §8.5).
+///
+public sealed class ProjectionDbContext(DbContextOptions options) : DbContext(options)
+{
+ /// The public-facing register rows (public-safe filtering is tightened in S-09).
+ public DbSet RegisterEntries => Set();
+
+ /// The Event Subscriber's durable log of accepted notifications — idempotency guard and rebuild source.
+ public DbSet ProcessedNotifications => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(e =>
+ {
+ e.ToTable("register_projection");
+ e.HasKey(r => r.Id);
+ e.Property(r => r.Id).HasColumnName("id");
+ e.Property(r => r.Status).HasColumnName("status").IsRequired();
+ e.Property(r => r.Bsn).HasColumnName("bsn");
+ e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.ToTable("processed_notifications");
+ e.HasKey(r => r.Key);
+ e.Property(r => r.Key).HasColumnName("key");
+ e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
+ e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
+ e.Property(r => r.ReceivedAt).HasColumnName("received_at");
+ });
+ }
+}
+
+/// A register projection row. Bsn/NaamPlaceholder are deferred (ADR-0008).
+public sealed class RegisterEntryRow
+{
+ public required string Id { get; set; }
+ public required string Status { get; set; }
+ public string? Bsn { get; set; }
+ public string? NaamPlaceholder { get; set; }
+}
+
+/// An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1).
+public sealed class ProcessedNotificationRow
+{
+ public required string Key { get; set; }
+ public required string Actie { get; set; }
+ public required string ZaakId { get; set; }
+ public DateTimeOffset ReceivedAt { get; set; }
+}
diff --git a/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs b/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..1779f21
--- /dev/null
+++ b/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs
@@ -0,0 +1,30 @@
+using EventSubscriber.Application;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Projection.ReadModel;
+
+public static class ServiceCollectionExtensions
+{
+ /// Register the projection against Postgres.
+ public static IServiceCollection AddProjectionReadModel(this IServiceCollection services, string connectionString)
+ => services.AddDbContext(o => o.UseNpgsql(connectionString));
+
+ /// Register the write-side ports (projector store + notification log) used by the Event Subscriber.
+ public static IServiceCollection AddProjectionWriteSide(this IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ return services;
+ }
+
+ /// Apply any pending EF migrations. Called once on service start so a fresh stack
+ /// reaches a usable schema without a manual migration step (DoD: compose up reaches green).
+ public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
+ {
+ await using var scope = services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync(ct);
+ }
+}
diff --git a/services/projection-api/ProjectionApi.Api/Program.cs b/services/projection-api/ProjectionApi.Api/Program.cs
new file mode 100644
index 0000000..74a24be
--- /dev/null
+++ b/services/projection-api/ProjectionApi.Api/Program.cs
@@ -0,0 +1,43 @@
+using Microsoft.EntityFrameworkCore;
+using Projection.ReadModel;
+
+var builder = WebApplication.CreateBuilder(args);
+
+var connectionString = builder.Configuration.GetConnectionString("Projection")
+ ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
+
+builder.Services.AddProjectionReadModel(connectionString);
+
+var app = builder.Build();
+
+// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the
+// migrations-history lock, so it is safe that the Event Subscriber migrates too.
+await app.Services.MigrateProjectionAsync();
+
+app.MapGet("/health", () => "Healthy");
+
+// The read side of the projection. Public-safe field filtering is tightened in S-09; for now
+// the minimal projection only carries id + status (bsn/naam deferred — ADR-0008).
+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))
+ .ToListAsync(ct);
+ return Results.Ok(rows);
+});
+
+app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, CancellationToken ct) =>
+{
+ var row = await db.RegisterEntries
+ .Where(r => r.Id == id)
+ .Select(r => new RegisterEntryDto(r.Id, r.Status, 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 partial class Program;
diff --git a/services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj b/services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj
new file mode 100644
index 0000000..b91c803
--- /dev/null
+++ b/services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/services/projection-api/ProjectionApi.Api/appsettings.json b/services/projection-api/ProjectionApi.Api/appsettings.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/services/projection-api/ProjectionApi.Api/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}