Files
register-referentie/docs/architecture/adr-0028-objecten-holds-the-register.md
T
not 4a047c618c fix(infra): let Objecten actually accept the register record (refs #149)
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.
2026-08-14 09:41:00 +02:00

7.7 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.

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

verify-domain (infra/run-domain-check.sh) drives a real approval end-to-end and then 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.