docs: ADR-0028 + demo note — Objecten holds the register (refs #149)
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.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user