diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md
index b521cc8..4214969 100644
--- a/docs/architecture/adr-0028-objecten-holds-the-register.md
+++ b/docs/architecture/adr-0028-objecten-holds-the-register.md
@@ -93,6 +93,19 @@ declared up front, both stay idempotent, and neither has to wait for the other.
The cost is a constant duplicated across two files that must be kept in step; each carries
a comment pointing at the other.
+### The ACL must reach Objecttypen at the URL Objecten knows it by
+
+Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
+matches an incoming object's `type` against the `api_root` it was configured with. So an
+ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
+URL that Objecten then rejects as "not one of the available choices" — even though it is
+the same objecttype.
+
+`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
+`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
+ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
+integration tests only pass from inside the compose network.
+
### Objecten's notifications are off for this slice
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
@@ -145,7 +158,11 @@ asserts, via `infra/register-record-check.py`, that Objecten holds exactly one
`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside
the public-safe schema.
-Every HTTP exchange the gateway performs was additionally replayed by hand against a live
-Objecten + Objecttypen pair while writing this slice — objecttype lookup by name, version
-status, `data_attrs` search, create, update, and a rejected write carrying a `bsn`. Both
-findings above came out of that replay rather than out of CI.
+`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
+inside the compose network) drives the real gateway against a live Objecten + Objecttypen
+pair: two writes for the same id leave exactly one object, carrying the second write's
+status and nothing outside the public-safe schema.
+
+All three findings above — the pinned UUID, the notifications block, and the base-URL
+constraint — came out of running the gateway against those live modules while writing the
+slice, not out of CI.
diff --git a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs
new file mode 100644
index 0000000..6ded7c1
--- /dev/null
+++ b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs
@@ -0,0 +1,105 @@
+using Acl.Application;
+using Acl.Infrastructure;
+
+namespace Acl.IntegrationTests;
+
+///
+/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed
+/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one
+/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype
+/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being
+/// idempotent (ADR-0028).
+///
+[Trait("Category", "Integration")]
+public sealed class ObjectenGatewayIntegrationTests
+{
+ private static string Env(string key, string fallback) =>
+ Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
+
+ private static ObjectenGateway Gateway() => new(
+ new HttpClient(),
+ new ObjectenOptions
+ {
+ BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")),
+ Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
+ ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
+ ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
+ ObjecttypeName = "RegisterRecord",
+ },
+ new SystemClock());
+
+ [Fact]
+ public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write()
+ {
+ var gateway = Gateway();
+ // A key no other run shares: the verify stack is shared and keeps records between checks.
+ var id = Guid.NewGuid().ToString();
+
+ await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1"));
+ await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1"));
+
+ var records = await ReadAllAsync(id);
+ var only = Assert.Single(records);
+ // Re-approving updates the existing object rather than creating a second one (§8.6).
+ Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status);
+ Assert.Equal("INT-TEST-1", only.Reference);
+ }
+
+ [Fact]
+ public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe()
+ {
+ // The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the
+ // guarantee from the other side: Objecten itself refuses anything the schema does not sanction
+ // (ADR-0027). Posted raw, exactly as the gateway would post a record.
+ var gateway = Gateway();
+ var id = Guid.NewGuid().ToString();
+ await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2"));
+
+ var stored = Assert.Single(await ReadAllAsync(id));
+ Assert.Null(stored.Bsn);
+ }
+
+ // Reads the register records for a given id straight from Objecten, so the assertions do not go
+ // back through the gateway they are checking.
+ private static async Task> ReadAllAsync(string id)
+ {
+ using var http = new HttpClient();
+ var objecttype = await ResolveObjecttypeUrlAsync(http);
+ var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")),
+ "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
+ "&data_attrs=id__exact__" + Uri.EscapeDataString(id));
+
+ using var message = new HttpRequestMessage(HttpMethod.Get, query);
+ message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}");
+ message.Headers.Add("Accept-Crs", "EPSG:4326");
+
+ using var response = await http.SendAsync(message);
+ response.EnsureSuccessStatusCode();
+
+ using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ return document.RootElement.GetProperty("results").EnumerateArray()
+ .Select(o => o.GetProperty("record").GetProperty("data"))
+ .Select(d => new StoredRecord(
+ d.GetProperty("status").GetString()!,
+ d.GetProperty("reference").GetString(),
+ d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null))
+ .ToList();
+ }
+
+ private static async Task ResolveObjecttypeUrlAsync(HttpClient http)
+ {
+ var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes");
+ using var message = new HttpRequestMessage(HttpMethod.Get, query);
+ message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}");
+
+ using var response = await http.SendAsync(message);
+ response.EnsureSuccessStatusCode();
+
+ using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ return document.RootElement.GetProperty("results").EnumerateArray()
+ .First(o => o.GetProperty("name").GetString() == "RegisterRecord")
+ .GetProperty("url").GetString()!;
+ }
+
+ private sealed record StoredRecord(string Status, string? Reference, string? Bsn);
+}