diff --git a/BACKLOG.md b/BACKLOG.md
index 476d485..8a5ddbe 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -287,12 +287,17 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
-### S-19 · ACL extension: write register-record to Objecten on approval
+### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
+Split into independently deployable sub-slices (CLAUDE.md §13):
+
+- **S-19a** (#149) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR.
+- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a.
+
---
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs
index 28e83f9..37f2950 100644
--- a/services/acl/Acl.Application/AclService.cs
+++ b/services/acl/Acl.Application/AclService.cs
@@ -2,7 +2,12 @@ namespace Acl.Application;
/// The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).
-public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock)
+public sealed class AclService(
+ IZaakGateway gateway,
+ IRegisterRecordGateway register,
+ IDefaultFillStore fill,
+ IZaaktypeCatalog catalog,
+ IClock clock)
{
public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
{
diff --git a/services/acl/Acl.Application/IRegisterRecordGateway.cs b/services/acl/Acl.Application/IRegisterRecordGateway.cs
new file mode 100644
index 0000000..4540861
--- /dev/null
+++ b/services/acl/Acl.Application/IRegisterRecordGateway.cs
@@ -0,0 +1,30 @@
+namespace Acl.Application;
+
+///
+/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028).
+/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the
+/// upstream Common Ground module (§8.1).
+///
+public interface IRegisterRecordGateway
+{
+ ///
+ /// Write the register record for a registration, creating it if absent and updating it if it
+ /// already exists. Idempotent on : a replayed approval updates
+ /// the existing object instead of creating a second one (§8.6).
+ ///
+ Task UpsertAsync(RegisterRecord record, CancellationToken ct = default);
+}
+
+///
+/// The public-safe register record, matching the RegisterRecord objecttype schema registered
+/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable.
+///
+public sealed record RegisterRecord(string Id, string Status, string? Reference);
+
+/// The register statuses the RegisterRecord objecttype's schema allows (ADR-0027).
+public static class RegisterRecordStatus
+{
+ public const string Ingediend = "INGEDIEND";
+
+ public const string Ingeschreven = "INGESCHREVEN";
+}
diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs
new file mode 100644
index 0000000..50fc445
--- /dev/null
+++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs
@@ -0,0 +1,10 @@
+using Acl.Application;
+
+namespace Acl.Infrastructure;
+
+/// The only code that talks to the Objecten API (ADR-0028).
+public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
+{
+ public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) =>
+ throw new NotImplementedException();
+}
diff --git a/services/acl/Acl.Infrastructure/ObjectenOptions.cs b/services/acl/Acl.Infrastructure/ObjectenOptions.cs
new file mode 100644
index 0000000..9e46d13
--- /dev/null
+++ b/services/acl/Acl.Infrastructure/ObjectenOptions.cs
@@ -0,0 +1,20 @@
+namespace Acl.Infrastructure;
+
+///
+/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a
+/// static Authorization: Token … (they are not ZGW JWT APIs), so there is no client-id/secret
+/// pair as with OpenZaak.
+///
+public sealed class ObjectenOptions
+{
+ public required Uri BaseUrl { get; init; }
+ public required string Token { get; init; }
+
+ /// Objecttypen API root — the ACL resolves the objecttype URL + version from it by name
+ /// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021).
+ public required Uri ObjecttypenBaseUrl { get; init; }
+ public required string ObjecttypenToken { get; init; }
+
+ /// The objecttype the register record is written as (S-18c registers "RegisterRecord").
+ public required string ObjecttypeName { get; init; }
+}
diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs
index 6b726f8..158be35 100644
--- a/services/acl/Acl.Tests/AclServiceTests.cs
+++ b/services/acl/Acl.Tests/AclServiceTests.cs
@@ -75,6 +75,17 @@ public class AclServiceTests
Task.FromResult(Zaaktypen);
}
+ private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway
+ {
+ public readonly List Upserted = [];
+
+ public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
+ {
+ Upserted.Add(record);
+ return Task.CompletedTask;
+ }
+ }
+
private static AclDefaults Defaults() => new()
{
Bronorganisatie = "517439943",
@@ -88,7 +99,10 @@ public class AclServiceTests
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
- new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
+ ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
+
+ private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) =>
+ new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
private sealed class FixedClock(DateOnly today) : IClock
{
@@ -161,10 +175,42 @@ public class AclServiceTests
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
- var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
+ var register = new FakeRegisterRecordGateway();
+ var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
+ Assert.Empty(register.Upserted);
+ }
+
+ [Fact]
+ public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */)
+ {
+ var gateway = new FakeGateway();
+ var register = new FakeRegisterRecordGateway();
+ var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
+
+ await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
+
+ var record = Assert.Single(register.Upserted);
+ // The record is keyed on the zaak id — the same key the read projection rows carry (S-19b).
+ Assert.Equal("abc", record.Id);
+ Assert.Equal("INGESCHREVEN", record.Status);
+ // The public-safe reference comes from the zaak's identificatie, never from the domain payload.
+ Assert.Equal("REG-FROM-ZAAK", record.Reference);
+ }
+
+ [Fact]
+ public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */)
+ {
+ var gateway = new FakeGateway();
+ var register = new FakeRegisterRecordGateway();
+ var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
+
+ await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
+
+ // Only an approval enters the register; a cancelled zaak never becomes a register record.
+ Assert.Empty(register.Upserted);
}
[Fact]
diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs
new file mode 100644
index 0000000..bbedc84
--- /dev/null
+++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs
@@ -0,0 +1,184 @@
+using System.Net;
+using System.Net.Http.Json;
+using Acl.Application;
+using Acl.Infrastructure;
+
+namespace Acl.Tests;
+
+public class ObjectenGatewayTests
+{
+ private sealed class StubHandler(Func> onSend)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ => onSend(request);
+ }
+
+ private sealed class FixedClock(DateOnly today) : IClock
+ {
+ public DateOnly Today { get; } = today;
+ }
+
+ private sealed record Sent(HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs);
+
+ private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1";
+
+ private static ObjectenGateway Gateway(List sent, Func respond) =>
+ new(
+ new HttpClient(new StubHandler(async req =>
+ {
+ sent.Add(new Sent(
+ req.Method,
+ req.RequestUri!,
+ req.Content is null ? null : await req.Content.ReadAsStringAsync(),
+ req.Headers.Authorization?.ToString(),
+ req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null,
+ req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null));
+ return respond(req);
+ })),
+ new ObjectenOptions
+ {
+ BaseUrl = new("http://objecten:8000"),
+ Token = "objecten-token",
+ ObjecttypenBaseUrl = new("http://objecttypen:8000"),
+ ObjecttypenToken = "objecttypen-token",
+ ObjecttypeName = "RegisterRecord",
+ },
+ new FixedClock(new DateOnly(2026, 6, 4)));
+
+ // The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every
+ // write. `respondToWrite` decides what the POST/PATCH returns.
+ private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
+ req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = JsonContent.Create(new
+ {
+ results = new[]
+ {
+ new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } },
+ new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } },
+ },
+ }),
+ }
+ : req.Method == HttpMethod.Get
+ ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) }
+ : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
+
+ private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
+
+ [Fact]
+ public async Task Creates_the_object_when_none_exists_for_the_registration()
+ {
+ var sent = new List();
+
+ await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
+
+ var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
+ Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
+ Assert.Contains("\"typeVersion\":2", write.Body);
+ Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
+ Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
+ Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body);
+ Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body);
+ }
+
+ [Fact]
+ public async Task Updates_the_existing_object_instead_of_creating_a_second_one()
+ {
+ var sent = new List();
+ object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }];
+
+ await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record());
+
+ Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
+ var write = sent.Single(s => s.Method == HttpMethod.Patch);
+ Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString());
+ Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
+ }
+
+ [Fact]
+ public async Task Searches_objecten_for_the_registration_id_within_the_objecttype()
+ {
+ var sent = new List();
+
+ await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
+
+ var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects");
+ Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query);
+ Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query);
+ }
+
+ [Fact]
+ public async Task Authenticates_with_the_static_token_of_each_api()
+ {
+ var sent = new List();
+
+ await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
+
+ Assert.All(
+ sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
+ s => Assert.Equal("Token objecttypen-token", s.Auth));
+ Assert.All(
+ sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)),
+ s => Assert.Equal("Token objecten-token", s.Auth));
+ }
+
+ [Fact]
+ public async Task Sends_the_geo_crs_headers_the_objecten_api_requires()
+ {
+ var sent = new List();
+
+ await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
+
+ var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList();
+ Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs));
+ Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs));
+ }
+
+ [Fact]
+ public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes()
+ {
+ var sent = new List();
+ var gateway = Gateway(sent, req => Route(req, []));
+
+ await gateway.UpsertAsync(Record());
+ await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
+
+ Assert.Single(sent, s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task Fails_loudly_when_the_objecttype_is_not_registered()
+ {
+ var sent = new List();
+ var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = JsonContent.Create(new { results = Array.Empty