S-19a · ACL writes the RegisterRecord to Objecten on approval (closes #149) (#151)
CI / build (push) Successful in 1m8s
CI / lint (push) Successful in 1m23s
CI / unit (push) Successful in 1m22s
CI / frontend (push) Successful in 3m3s
CI / mutation (push) Successful in 6m22s
CI / verify-stack (push) Successful in 7m54s

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)Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
not
2026-08-14 09:34:04 +00:00
parent d37d4c96c6
commit 0cd70ae8c3
20 changed files with 1052 additions and 10 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ A slice is done when:
## 15. Out of scope for v1
- OpenMetadata data governance module (v3 slice).
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
- Production-grade Helm chart (sketch only).
- Multi-tenancy.
- Real outbound notifications (email/SMS) — logged to console in v1.
@@ -0,0 +1,174 @@
# ADR-0028: Objecten holds the register, OpenZaak holds the process
- **Status:** Accepted
- **Date:** 2026-08-14
- **Deciders:** Respellion engineering
- **Slice:** S-19a (#149), first of the S-19 (#20) split
## Context
Until this slice the register existed only as a **derived** thing: the read projection
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
system anywhere that holds "who is registered" as a first-class record — drop the
projection database and the only way back is to replay ZGW history and re-derive it.
That is the wrong shape for a register. A BIG registration is a **fact about a person**
that outlives the case that produced it: it is looked up, corrected, superseded, and
retained on its own schedule. The zaak that produced it is a **process record** — it
opens, moves through statussen, and closes. Storing the fact inside the process record
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
lifecycles together: the register can then never be read, retained, or corrected without
going through the case system that happened to create it.
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
register record lives, and who writes it.**
## Decision
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
register record.**
### Not zaak eigenschappen
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
They inherit the zaak's lifecycle and its archiving regime, and they give the public
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
lifecycle the zaak cannot drag around with it.
### The ACL writes it, not the domain or the Event Subscriber
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
Event Subscriber write the record when it sees the status notification — would make the
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
removes.
### Two writes, converging rather than transactional
Approval is now two writes across two modules, so it cannot be atomic. Both are made
idempotent instead:
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
existing object with that `id`, then PATCH it or POST a new one.
A caller that retries a half-failed approval therefore converges. This is the same
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
not an exception carved out for this path.
### The objecttype is resolved by name, lazily
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
cannot be pinned in config — the ACL resolves them by the configured name
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
same reasoning as ADR-0021 for zaaktypen.
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
is not cached, so it is retried on the next approval.
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
transient typed `HttpClient` — in practice one extra GET per approval against a
neighbouring container.
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
if approvals ever get hot enough for that GET to matter.
### The objecttype's UUID is pinned, not server-assigned
Objecten refuses to store an object whose objecttype it has not been configured with
(`ObjectType with url=… is not configured`), and its configuration identifies an
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
when the container starts, before the `registerrecord-init` one-shot has run.
Rather than thread a seed-time UUID from one container into another's config, the UUID is
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
fixed UUID (the Objecttypen API accepts a client-supplied one), and
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
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`
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
no abonnement for it. Configuring only the client side would make writes succeed while
every message was dropped on the floor — a delivery path that looks wired and isn't. So
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
register write yet.
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
## Consequences
**Positive**
- The register is a first-class record with its own schema, lifecycle and query surface,
independent of the case that produced it.
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
discipline in projection code.
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW
(S-19b, #150).
**Negative / costs**
- Approval writes to two modules and is eventually consistent; a failure between them
leaves a zaak in eindstatus without a register record until the approval is retried.
Nothing repairs that automatically yet.
- One more upstream module on the approval path, and one more dev credential
(`Acl__Objecten__Token`) in compose.
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
name (compose + `register.py`).
- Until S-19b lands, the public register is still read from the NRC-derived projection, so
the register record is written but not yet read — the two must agree.
## Coupling rules touched (CLAUDE.md §8)
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
and no service reaches Objecten's database.
## Verification
The end-to-end assertion lives in the Playwright happy path
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
public-safe schema.
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
calls the ACL. The e2e is the only check that drives a real approval.
`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.
+37
View File
@@ -5,6 +5,43 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process,
Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an
approval updates the existing object instead of creating a second one.
```bash
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
make up
#
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
#
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
make verify-acl # → "Writes a register record and updates it in place on a second write"
#
# 4. See it for yourself — every register record currently in Objecten:
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
-H 'Accept-Crs: EPSG:4326' \
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
```
Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything
else (ADR-0027), so no personal data can reach the world-readable register even by mistake.
**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW
`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the
register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time
is pinned in config (ADR-0028, same reasoning as ADR-0021).
**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten
is S-19b (#150).
---
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the