CI / lint (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 1m14s
CI / unit (pull_request) Successful in 1m26s
CI / frontend (pull_request) Successful in 3m7s
CI / mutation (pull_request) Successful in 6m13s
CI / verify-stack (pull_request) Successful in 9m32s
The ACL upserts with PATCH, so every approval notification carries actie `partial_update`. Accepting it makes the INGEDIEND → INGESCHREVEN transition project. `update` stays accepted so a PUT-shaped write behaves the same; `destroy` deliberately does not — removing a registration from the public register is its own decision, not a side effect of this one. ADR-0030 records why the actie list is what it is.
142 lines
8.0 KiB
Markdown
142 lines
8.0 KiB
Markdown
# ADR-0030: The read projection is sourced from the register, not from ZGW
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-28
|
|
- **Deciders:** Respellion engineering
|
|
- **Slice:** S-19b-2 (#153), second of the S-19b (#150) split
|
|
- **Builds on:** ADR-0008 (read projection store), ADR-0028 (Objecten holds the register), ADR-0029 (Objecten publishes to NRC)
|
|
|
|
## Context
|
|
|
|
ADR-0028 moved the authoritative register record into the Objecten API, and said what should
|
|
follow: "the read projection can become a cache of Objecten rather than a re-derivation of
|
|
ZGW." Until this slice it was still the latter — the Event Subscriber listened on the `zaken`
|
|
kanaal and inferred register state from case events:
|
|
|
|
- a `zaak`/`create` meant INGEDIEND;
|
|
- any `status`/`create` was taken to be the approval, so meant INGESCHREVEN — the subscriber
|
|
may not read OpenZaak (§8.1), so it could not tell one statustype from another;
|
|
- the citizen-facing reference was not in the notification at all, so every projection had a
|
|
second hop: ask the ACL for the zaak's identificatie (#78).
|
|
|
|
So the register — a fact about a person — was reconstructed by guessing at the lifecycle of the
|
|
case that happened to produce it. ADR-0029 made the register itself publish. This ADR switches
|
|
the projection over to it.
|
|
|
|
## Decision
|
|
|
|
**The Event Subscriber listens on the `objecten` kanaal and projects the `RegisterRecord` the
|
|
notification points at. The projection is a cache of the register; ZGW is no longer a source.**
|
|
|
|
- The subscriber's abonnement moves from `zaken` to `objecten` (`register-abonnement.py`, and
|
|
the CI projection check).
|
|
- An Objecten notification carries **no record data** — only the object URL and the objecttype
|
|
as a kenmerk — so the record is read back through the ACL (`POST /register-records/read`).
|
|
§8.1 applies to Objecten exactly as ADR-0028 established: the ACL is the only code that talks
|
|
to it.
|
|
- The accepted acties are `create`, `update` and `partial_update`. The last one is not
|
|
defensive breadth: the ACL upserts with PATCH, and DRF routes a PATCH through the notifying
|
|
`update()` while naming the action `partial_update` — which is what Objecten publishes. So
|
|
every approval arrives as `partial_update`, and accepting only `create`/`update` drops the
|
|
one state change this slice exists to project. `destroy` is deliberately not accepted:
|
|
removing a registration from the public register is its own decision.
|
|
- The record already carries `id`, `status` and `reference`, so the row is the record. The
|
|
zaak-shaped surface goes: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId`, and
|
|
`ToEntry`'s `Resource == "status"` inference are replaced by `IsRegisterRecordWritten` +
|
|
`ObjectUrl`, and the ACL enrichment hop disappears.
|
|
|
|
### The ACL writes an INGEDIEND record on submit
|
|
|
|
Before this slice only approval wrote a record, so re-sourcing alone would have silently
|
|
dropped every INGEDIEND row from the public register. `OpenZaakAsync` therefore upserts a
|
|
record with status INGEDIEND after opening the zaak, keyed on the same zaak id that approval
|
|
later upserts to INGESCHREVEN.
|
|
|
|
This is the same two-writes-converging posture ADR-0028 already accepted for approval, now on
|
|
the submit path too: both writes are idempotent, so a retried submit updates the record rather
|
|
than adding a second one (§8.6). The reference comes from the registration itself, so unlike
|
|
approval this path needs no ZGW read-back.
|
|
|
|
The alternative — a register holding only INGESCHREVEN — is arguably the more correct reading
|
|
of "public register", but it narrows what the openbaar portal shows and reads against PRD §68
|
|
("~50 register entries with diverse statuses"). Rejected as a behaviour change this slice was
|
|
not asked to make.
|
|
|
|
### The dedup key is the projected row, not the notification
|
|
|
|
NRC carries no notification id and may redeliver, so the idempotency key is derived from
|
|
content (as before). The obvious candidates both break here:
|
|
|
|
- **the object URL alone** — 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;
|
|
- **object URL + actie** — a retried approval is a second `update`, so it would be dropped
|
|
while genuinely being the same state (harmless), but a *third* distinct state would collide
|
|
with it (not harmless).
|
|
|
|
The key is therefore the object plus the state that write puts in the projection —
|
|
`objecten:object:{url}:{status}:{reference}`. A redelivery collapses; a genuine state change
|
|
does not. That is exactly the property §8.6 asks for, and it needs no version field from
|
|
Objecten's internals.
|
|
|
|
### The notification log holds the row, not the event
|
|
|
|
`processed_notifications` stops describing ZGW events (`actie`, `zaak_id`, `resource`) and
|
|
holds the projected row itself (`register_id`, `status`, `reference`). A rebuild becomes a
|
|
replay with no mapping rules and no upstream reads at all — §8.4 held before via the ACL hop;
|
|
now it holds outright.
|
|
|
|
The migration **drops** the old columns rather than renaming them. EF scaffolded renames
|
|
(`resource` → `register_id`, `zaak_id` → `status`) that would have carried ZGW values into
|
|
columns meaning something else entirely, and a rebuild would then have projected that garbage.
|
|
|
|
- ponytail ceiling: the migration empties both tables. A pre-slice row describes a zaak event
|
|
the new projector cannot reproject, and the registrations behind those rows have no
|
|
RegisterRecord in Objecten (only approvals wrote one), so they are not re-derivable from the
|
|
new source either.
|
|
- Upgrade path: fine while stacks are ephemeral. If a long-lived environment ever needs to keep
|
|
them, backfill by walking Objecten's objects rather than replaying the log.
|
|
|
|
## Consequences
|
|
|
|
**Positive**
|
|
|
|
- The register is read from the register. The projection is a derived cache of a first-class
|
|
record, not an inference over someone else's lifecycle.
|
|
- The "any status-create is the approval" guess is gone — a real source of wrongness the moment
|
|
the zaaktype grows a second statustype.
|
|
- One hop fewer per notification: the record carries its own reference, so the ACL enrichment
|
|
call disappears.
|
|
- A rebuild needs nothing but its own log (§8.4).
|
|
|
|
**Negative / costs**
|
|
|
|
- Submission is now two writes across two modules and eventually consistent. A failure between
|
|
them leaves a zaak with no register record until the submit is retried; nothing repairs that
|
|
automatically yet — the same gap ADR-0028 recorded for approval, now on a second path.
|
|
- The projection lags the register by a notification round trip, where it used to lag the zaak
|
|
by one. In practice the same order of magnitude.
|
|
- Projecting now depends on the ACL being reachable, where the reference enrichment used to be
|
|
the only ACL dependency. A failed read means the notification is not logged and not
|
|
projected — NRC retries, so it converges, but the failure mode is now on the main path.
|
|
- OpenZaak still publishes to `zaken` and nothing in the product listens. Kept because the
|
|
`verify-nrc` check asserts that path, and turning off a working publisher to save nothing
|
|
would be its own risk.
|
|
|
|
## Coupling rules touched (CLAUDE.md §8)
|
|
|
|
None bent. §8.1 holds — the subscriber reaches Objecten only through the ACL. §8.4 is
|
|
strengthened: the projection is rebuildable from its own log, with no upstream reads at all.
|
|
§8.6 is what the dedup-key discussion above is about.
|
|
|
|
## Verification
|
|
|
|
`make verify-projection` (`infra/run-projection-check.sh`, in CI's `verify-stack`) opens a zaak
|
|
**through the ACL** and asserts projection-api serves a row for it with status INGEDIEND — the
|
|
whole new chain in one assertion: ACL → Objecten → `objecten-celery` → NRC → `nrc-beat` →
|
|
Event Subscriber → projection → projection-api. A zaak created behind the ACL's back produces
|
|
no row, which is the re-source working rather than a gap.
|
|
|
|
`RegisterProjectieBijwerken.feature` covers the use case in business language, including the
|
|
approval case — the same row moving INGEDIEND → INGESCHREVEN, which is now one registration's
|
|
record being updated rather than two unrelated ZGW events.
|