S-19a · ACL writes the RegisterRecord to Objecten on approval (closes #149) #151

Merged
not merged 11 commits from feat/149-acl-writes-registerrecord into main 2026-08-14 09:34:05 +00:00
Contributor

Closes #149.

Outcome: approving a registration now writes the canonical register record to the Objecten API as a RegisterRecord object, alongside the ZGW eindstatus. OpenZaak holds the process, Objecten holds the register (ADR-0028). The write goes through the ACL (§8.1) and is idempotent on the zaak id, so a replayed approval updates the existing object rather than creating a second one.

S-19 (#20) was split first (CLAUDE.md §13) — it bundled this with re-sourcing the read projection, which is now #150.

What landed

  • IRegisterRecordGateway + RegisterRecord in Acl.Application; ObjectenGateway in Acl.Infrastructure (static Token auth, CRS headers, objecttype resolved by name to its highest published version).
  • AclService.ApproveZaakAsync writes the record after the eindstatus, keyed on the zaak UUID with the zaak's identificatie as reference.
  • Compose wiring for both stacks; ADR-0028; demo note; PRD §15 out-of-scope line retired.

Three things only a live stack found

Running the gateway against a real Objecten + Objecttypen pair while writing this turned up blockers CI would have hit after the fact:

  1. Objecten rejects an objecttype it has not been configured with, by UUID — assigned at seed time by a one-shot that runs after Objecten's static setup_configuration. The UUID is now pinned on both sides.
  2. Objecten 500s on every write when its Notificaties config is absent (notifications_api_common raises rather than skipping). Objecten → NRC has no broker, worker, kanaal or abonnement, so notifications are disabled rather than wired to drop every message; #150 turns them on for real.
  3. Objecttypen echoes the request Host into the objecttype url, and Objecten only accepts the one matching its configured api_root — so the ACL must read Objecttypen at http://objecttypen:8000. This is why the new integration test only passes inside the compose network.

All three are recorded in ADR-0028.

Verification

  • ObjectenGatewayIntegrationTests (verify-acl, in-network): two writes for one id leave exactly one object with the second write's status. Passing locally against live Objecten.
  • The Playwright happy path asserts, after the behandelaar approves, that Objecten holds exactly one RegisterRecord for that reference — missing, duplicated, or non-public-safe all fail.
  • ACL mutation score 92.23% (baseline 91.37%, break 90).
  • make lint / make unit green locally; full-stack make verify runs in CI.

Definition of Done

  • A linked Gitea issue exists (#149).
  • Failing test written and committed first.
  • Implementation makes the test pass.
  • Refactor commit follows if structure improved.
  • Conventional Commit messages referencing the issue (refs #149).
  • All Gitea Actions CI jobs green (run 684).
  • docker compose up from a fresh clone reaches green health checks within 3 minutes (verify-stack step 1).
  • Docs touched — ADR-0028, demo note, PRD §15, BACKLOG.
  • ADR added: docs/architecture/adr-0028-objecten-holds-the-register.md.
  • Demo note appended to docs/demo-script.md.
  • Closed by the merging PR (closes #149).

🤖 Generated with Claude Code

Closes #149. **Outcome:** approving a registration now writes the canonical register record to the **Objecten** API as a `RegisterRecord` object, alongside the ZGW eindstatus. OpenZaak holds the process, Objecten holds the register (ADR-0028). The write goes through the ACL (§8.1) and is idempotent on the zaak id, so a replayed approval updates the existing object rather than creating a second one. S-19 (#20) was split first (CLAUDE.md §13) — it bundled this with re-sourcing the read projection, which is now #150. ### What landed - `IRegisterRecordGateway` + `RegisterRecord` in `Acl.Application`; `ObjectenGateway` in `Acl.Infrastructure` (static Token auth, CRS headers, objecttype resolved by name to its highest **published** version). - `AclService.ApproveZaakAsync` writes the record after the eindstatus, keyed on the zaak UUID with the zaak's identificatie as reference. - Compose wiring for both stacks; `ADR-0028`; demo note; PRD §15 out-of-scope line retired. ### Three things only a live stack found Running the gateway against a real Objecten + Objecttypen pair while writing this turned up blockers CI would have hit after the fact: 1. **Objecten rejects an objecttype it has not been configured with**, by UUID — assigned at seed time by a one-shot that runs *after* Objecten's static setup_configuration. The UUID is now pinned on both sides. 2. **Objecten 500s on every write when its Notificaties config is absent** (`notifications_api_common` raises rather than skipping). Objecten → NRC has no broker, worker, kanaal or abonnement, so notifications are **disabled** rather than wired to drop every message; #150 turns them on for real. 3. **Objecttypen echoes the request Host into the objecttype `url`**, and Objecten only accepts the one matching its configured `api_root` — so the ACL must read Objecttypen at `http://objecttypen:8000`. This is why the new integration test only passes inside the compose network. All three are recorded in ADR-0028. ### Verification - `ObjectenGatewayIntegrationTests` (verify-acl, in-network): two writes for one id leave exactly one object with the second write's status. **Passing locally against live Objecten.** - The **Playwright happy path** asserts, after the behandelaar approves, that Objecten holds exactly one `RegisterRecord` for *that* reference — missing, duplicated, or non-public-safe all fail. - ACL mutation score **92.23%** (baseline 91.37%, break 90). - `make lint` / `make unit` green locally; full-stack `make verify` runs in CI. ## Definition of Done - [x] A linked Gitea issue exists (#149). - [x] Failing test written and committed first. - [x] Implementation makes the test pass. - [x] Refactor commit follows if structure improved. - [x] Conventional Commit messages referencing the issue (`refs #149`). - [x] All Gitea Actions CI jobs green (run 684). - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (verify-stack step 1). - [x] Docs touched — ADR-0028, demo note, PRD §15, BACKLOG. - [x] ADR added: `docs/architecture/adr-0028-objecten-holds-the-register.md`. - [x] Demo note appended to `docs/demo-script.md`. - [x] Closed by the merging PR (`closes #149`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
not added this to the Iteration 4 — Objecten milestone 2026-08-14 07:45:24 +00:00
not added 9 commits 2026-08-14 07:45:24 +00:00
Ports and failing tests for the Objecten hop, ahead of the implementation:

- IRegisterRecordGateway + RegisterRecord — the Application-side port; the
  record mirrors the objecttype schema registered in S-18c (ADR-0027).
- AclService takes the port but does not yet call it, so the approval test
  fails on an empty upsert list.
- ObjectenGateway is a shell throwing NotImplementedException; its tests pin
  the contract: resolve the objecttype by name, search by data attribute,
  POST when absent / PATCH when present, static Token auth per API, the CRS
  headers the geo API requires, and a surfaced error body.

Also splits S-19 (#20) into #149/#150 in BACKLOG.md — the approval-side write
and the projection re-sourcing are independently deployable (CLAUDE.md §13).
ApproveZaakAsync now does two writes: the ZGW eindstatus (the process) and the
register record in Objecten (the register). The record is keyed on the zaak
UUID — the same key the read projection rows carry — and its reference is the
zaak's identificatie, so nothing personal crosses into the world-readable
register (ADR-0027).

ObjectenGateway resolves the objecttype by name (its URL and version are
assigned at seed time, as with ADR-0021), searches for an existing object by
data attribute, then POSTs or PATCHes. Resolution is lazy, so the ACL needs no
depends_on on Objecten and does not crash-loop when it boots first.
Counting the `versions` URLs assumed a contiguous, all-published list. Read the
objecttype's versions collection instead and take the highest one whose status
is `published`, so a draft version — whose schema is still being shaped — is
never written against.
verify-domain already drives a full approval; it now also asserts Objecten holds
exactly one RegisterRecord for that registration — matched on its own reference,
because the shared verify stack carries records from earlier runs. The check
covers the three things that can silently go wrong: the record is missing (the
ACL's Objecten hop never ran), duplicated (the upsert is not idempotent), or
carries a field outside the public-safe schema.
ADR-0028 records why the register record lives in Objecten rather than as zaak
eigenschappen, why the ACL owns the hop, and how two non-atomic writes are made
to converge instead. Also retires the PRD §15 out-of-scope line the slice
supersedes.
The new gateway landed at 77.6%, dragging the ACL score under its 90 break
threshold. The gaps were all real behaviour nobody was asserting: a failed or
empty read being mistaken for "nothing there yet" and followed by a blind
write, a `results`-less response taking down the resolve with an
ArgumentNullException, the CRS headers going to Objecttypen (which is not a geo
API), and the write body being sent chunked. ACL score 86.63% → 92.08%.
The version resolve assumed `GET {objecttype}/versions` returns a bare list.
Every other collection in the Objecttypen API returns a paginated envelope, and
nothing in the repo exercises that endpoint, so the shape was a guess. Follow
the path infra/registerrecord-check.py already proves against the real API
instead: read the `versions` URLs off the objecttype and fetch each for its
status. Costs a request per version, once per gateway instance.

ACL mutation score 92.23% (baseline 91.37%).
Replaying the gateway's calls against a live Objecten + Objecttypen pair turned
up two blockers CI would only have found after the fact:

- Objecten rejects an objecttype it has not been configured with, and it
  identifies one by uuid — assigned at seed time by a one-shot that runs after
  Objecten's static setup_configuration. Pin the uuid on both sides instead.
- Objecten notifies on every write and notifications_api_common *raises* when
  that config is absent, so every POST 500'd after rolling the object back.
  Objecten → NRC has no broker, worker, kanaal or abonnement yet, so disable
  notifications rather than wire a client that drops every message; S-19b turns
  them on for real.

With both in place the full exchange verifies end to end: lookup → version →
search → create → update (still one object), and a record carrying a bsn is
rejected by the schema. ADR-0028 records both.
test(acl): ObjectenGateway integration test against live Objecten (refs #149)
CI / build (pull_request) Successful in 4m20s
CI / lint (pull_request) Successful in 4m38s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 4m1s
CI / mutation (pull_request) Successful in 6m52s
CI / verify-stack (pull_request) Failing after 18m4s
2bb7d9c165
Drives the real gateway against a running 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. Runs under verify-acl,
inside the compose network — which it must, because Objecttypen echoes the
request Host into the objecttype `url` and Objecten only accepts the one
matching its configured api_root. ADR-0028 records that constraint.
not added the type:slicearea:acl labels 2026-08-14 07:45:26 +00:00
not added 1 commit 2026-08-14 08:20:31 +00:00
fix(infra): reach Objecten by service name in the register-record check (refs #149)
CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m23s
CI / unit (pull_request) Successful in 1m23s
CI / frontend (pull_request) Successful in 2m59s
CI / mutation (pull_request) Successful in 6m8s
CI / verify-stack (pull_request) Failing after 7m24s
10b784cc05
CI caught my own check falling into the constraint ADR-0028 documents: it looked
Objecttypen up by container IP, so the objecttype URL came back IP-addressed and
Objecten rejected it as "not one of the available choices". Reach both by
service name — compose DNS resolves them, and neither request has OpenZaak's
URL-validity constraint that made IPs necessary elsewhere in this script.

The 400 also spent the full 60s timeout disguised as "transport:" because
HTTPError is a URLError subclass. Handle it separately: a 4xx now fails
immediately with the response body, which is where the real reason was.

Verified both ways against a live Objecten: absent record → exit 1 with the
reason, present record → exit 0.
not added 1 commit 2026-08-14 08:43:39 +00:00
fix(e2e): assert the register record where a real approval happens (refs #149)
CI / build (pull_request) Successful in 1m6s
CI / lint (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m24s
CI / verify-stack (pull_request) Successful in 7m49s
CI / frontend (pull_request) Successful in 2m57s
CI / mutation (pull_request) Successful in 6m7s
2d783448b7
verify-domain was the wrong home for the assertion, and CI was right to fail it.
That check completes the Beoordelen task straight through Flowable REST — on
purpose, it exists to exercise the Workflow Client's REST contract — which
bypasses the domain `decide` path that calls the ACL. No approval reached the
ACL there, so no record was ever written.

The Playwright happy path is the only check that drives a real approval
(behandel portal → BFF → domain → ACL), and it already knows its own reference.
Assert there instead: exactly one RegisterRecord for that reference,
INGESCHREVEN, carrying nothing outside the public-safe schema. Drops
register-record-check.py and the verify-domain block.

The helper was run under real Playwright against a live Objecten before
committing — one record found, none for an unknown reference.
Author
Contributor

CI green on run 684 — all six jobs, all 18 verify-stack steps.

Two corrections landed after the first push, both caught by CI, both real bugs in this branch rather than flake:

  1. register-record-check.py looked Objecttypen up by container IP, so the objecttype URL came back IP-addressed and Objecten rejected it with 400 — exactly the host-reflection constraint ADR-0028 documents. It also spent the full 60s timeout disguised as transport:, because HTTPError subclasses URLError.
  2. The assertion was in the wrong check. verify-domain completes the Beoordelen task straight through Flowable REST — deliberately, to exercise the Workflow Client's REST contract — which bypasses the domain decide path that calls the ACL. No approval reached the ACL there, so no record was ever written. Moved to the Playwright happy path, the only check that drives a real approval; register-record-check.py is gone.

Both times the cause was reasoning about a check instead of running it. The helper that replaced it was run under real Playwright against a live Objecten before pushing (one record for a known reference, none for an unknown one). ADR-0028's verification section records why the e2e is the right home and verify-domain isn't.

Not merging — leaving the review gate to a human (CLAUDE.md §16).

**CI green on run 684** — all six jobs, all 18 verify-stack steps. Two corrections landed after the first push, both caught by CI, both real bugs in this branch rather than flake: 1. **`register-record-check.py` looked Objecttypen up by container IP**, so the objecttype URL came back IP-addressed and Objecten rejected it with 400 — exactly the host-reflection constraint ADR-0028 documents. It also spent the full 60s timeout disguised as `transport:`, because `HTTPError` subclasses `URLError`. 2. **The assertion was in the wrong check.** `verify-domain` completes the Beoordelen task straight through Flowable REST — deliberately, to exercise the Workflow Client's REST contract — which bypasses the domain `decide` path that calls the ACL. No approval reached the ACL there, so no record was ever written. Moved to the Playwright happy path, the only check that drives a real approval; `register-record-check.py` is gone. Both times the cause was reasoning about a check instead of running it. The helper that replaced it was run under real Playwright against a live Objecten before pushing (one record for a known reference, none for an unknown one). ADR-0028's verification section records why the e2e is the right home and `verify-domain` isn't. Not merging — leaving the review gate to a human (CLAUDE.md §16).
not merged commit 0cd70ae8c3 into main 2026-08-14 09:34:05 +00:00
Sign in to join this conversation.