Files
register-referentie/docs/architecture/adr-0028-objecten-holds-the-register.md
T
not 94742a261f
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m26s
CI / unit (push) Successful in 1m37s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 6m42s
CI / verify-stack (push) Failing after 11m26s
feat: read projection sourced from the register in Objecten (closes #153) (#155)
## What & why

S-19b-2, closing out ADR-0028's stated direction: **the read projection is now derived from the
`RegisterRecord` in Objecten, not from ZGW zaak events.**

Until now the subscriber listened on `zaken` and *inferred* register state from case events — a
`zaak/create` meant INGEDIEND, and any `status/create` was assumed to be the approval (it may not
read OpenZaak, so it could not tell statustypen apart). The reference wasn't in the notification
at all, so every projection made a second hop to the ACL. The register — a fact about a person —
was being reconstructed by guessing at the lifecycle of the case that produced it.

- The subscriber's abonnement moves to the `objecten` kanaal (S-19b-1 made it publish).
- An Objecten notification carries **no record data**, only the object URL, so the record is read
  back through the ACL (`POST /register-records/read`) — §8.1 applies to Objecten exactly as
  ADR-0028 established.
- The record carries `id`, `status` and `reference`, so the row *is* the record: `IsZaakCreated`,
  `IsZaakStatusSet`, `ZaakUrl`, `ZaakId` and `ToEntry`'s `Resource == "status"` inference are all
  gone, and so is the ACL enrichment hop.
- **The ACL now writes an INGEDIEND record on submit.** Without it, re-sourcing would silently
  drop every submitted registration from the public register, since only approval wrote a record.
- `processed_notifications` holds the projected row (`register_id`, `status`, `reference`) instead
  of the ZGW event, so a rebuild is a replay with no mapping rules and no upstream reads at all.

**ADR-0030** records it. ADR-0028's open caveat — record written but not yet read, "the two must
agree" — is closed: there is one source now.

Closes #153

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing tests committed before the implementation — two red/green pairs, ACL side
      (06c0444566ef7d) and subscriber side (142ed458af09b2).
- [x] Refactor commit follows (b496ac9).
- [x] Conventional Commits referencing the issue (`refs #153`).
- [x] CI green — all six jobs on b30fa66, `verify-stack` end to end including the e2e.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes
      (`verify-stack`'s bring-up step — see the wait-healthy fix below).
- [x] Docs updated — ADR-0030 added, ADR-0028's consequence + caveat annotated, BACKLOG.md,
      e2e header comment.
- [x] ADR added in `docs/architecture/`.
- [x] Demo note in `docs/demo-script.md` — n/a: no user-visible change. The openbaar register
      shows the same two statuses for the same registrations; only where they come from changed.

## Notes for reviewers

**The decision I'd most like a second opinion on** is the one the issue didn't settle: what
happens to INGEDIEND. Objecten held only INGESCHREVEN records, so re-sourcing forced a choice
between (a) the ACL also writing on submit, (b) a public register that lists only actual
registrations, or (c) a hybrid keeping both kanalen. I took (a): visible behaviour is unchanged
and the register holds the whole lifecycle. (b) is arguably the better *semantics* for a public
register but narrows what the portal shows and reads against PRD §68 ("~50 register entries with
diverse statuses"); (c) leaves the projection half-derived from ZGW, which is the coupling
ADR-0028 set out to remove. All three are laid out in ADR-0030.

**The dedup key is the projected row**, `objecten:object:{url}:{status}:{reference}` — not the
object URL (the ACL upserts *one object per registration*, so submit and approval notify about
the same URL and the approval would be swallowed as a duplicate) and not URL+actie (a retried
approval is a second `update`). Redeliveries collapse, genuine state changes don't. §8.6.

**The migration drops columns rather than renaming them.** EF scaffolded renames — `resource` →
`register_id`, `zaak_id` → `status` — which would have carried ZGW values into columns meaning
something else, and a rebuild would then have projected that garbage. It also empties both
tables: a pre-slice row describes a zaak event the new projector can't reproject, and those
registrations have no RegisterRecord in Objecten either, so they're not re-derivable from the new
source. Stated as a ceiling in the ADR — fine while stacks are ephemeral, backfill from Objecten
if a long-lived environment ever needs it.

**`run-projection-check.sh` now opens its zaak through the ACL** instead of straight against
OpenZaak, because the ACL is what writes the record. A zaak created behind the ACL's back
produces no projection row — that's the re-source working, not a gap.

## Three fixes CI found, none of them in the projection logic

1. **`wait-healthy.sh` matched the wrong container** (744f91a). Bring-up timed out with
   `TIMEOUT: 'objecten' not healthy (status=none)` while the `docker ps` it dumps showed
   objecten `Up 9 minutes (healthy)`. `--filter name=` is a substring match, so `objecten` also
   matches `objecten-db`/`objecten-redis`/`objecten-celery`, and `head -1` took whichever docker
   listed first — the celery worker has no healthcheck, hence `status=none`. Latent since those
   services landed and decided purely by listing order; `objecttypen` matches `objecttypen-db`
   the same way. Anchored on the compose replica suffix, which the verify scripts already do.
2. **The ACL had to be repointed at OpenZaak's IP** (7e0897a). Opening the zaak through the ACL
   put this check in the same bind run-domain-check.sh already handles:
   `400 {"name":"zaaktype","code":"bad-url","reason":"Voer een geldige URL in."}`. OpenZaak
   reflects the request Host into the zaaktype URL and then rejects it on zaak-create when
   single-label — the mechanism compose already documents on `ACL_OPENZAAK_BASEURL`.
3. **Approval arrives as `partial_update`, not `update`** (0dd26a7b30fa66) — the one real bug
   in the slice. The ACL upserts with PATCH; DRF routes it through the notifying `update()` but
   names the action `partial_update`, so the projector dropped every approval. Only the e2e could
   catch it: `verify-projection` drives a submit, and per ADR-0028 the e2e is the only check that
   drives a *real* approval.

`verify-tracing` also failed once (run 722) on a path this PR doesn't touch, and passed on a
plain re-run of the same commit. Tempo logged `pusher failed to consume trace data` /
`distributor_pool failing healthcheck` — it dropped spans under runner load rather than the trace
chain being broken. Filed as **#156** rather than absorbed here.

**Correction to the #152 PR notes:** I wrote there that celery concurrency was "the next knob" if
verify-stack got tight. It isn't — `CELERY_WORKER_CONCURRENCY` already defaults to 1 in the Maykin
image, so `objecten-celery` is already a single-process worker. Noted in #156.

**Possible follow-up, deliberately not done here:** an `openzaak.local` network alias mirroring
`objecten.local` would remove the ACL-repoint dance from both run-domain-check.sh and
run-projection-check.sh. It changes the host in every zaak URL the system produces, which is too
broad a ripple to land inside an unrelated slice — worth its own issue.

**Known costs, all in the ADR:** submission is now two writes across two modules and eventually
consistent (same posture ADR-0028 accepted for approval); projecting now depends on the ACL being
reachable on the main path, not just for enrichment (NRC retries, so it converges); and OpenZaak
still publishes to `zaken` with nothing in the product listening — kept because `verify-nrc`
asserts that path.Reviewed-on: #155
2026-09-01 07:26:33 +00:00

9.1 KiB

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.
  • Lifted by ADR-0029 (S-19b-1, #152): broker, worker, objecten kanaal and notifications config now exist, and NOTIFICATIONS_DISABLED is false.

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 — done in S-19b-2 (#153), ADR-0030.

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. Closed by ADR-0030: the projection is now derived from the register, so there is only one source to agree with.

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.