feat: read projection sourced from the register in Objecten (closes #153) #155
+3
-3
@@ -296,9 +296,9 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||||
|
|
||||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||||
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
- **S-19b** (#150, ✅) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
||||||
- **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
- **S-19b-1** (#152, ✅) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
||||||
- **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1.
|
- **S-19b-2** (#153, ✅) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. The ACL also writes an INGEDIEND record on submit, so the register holds the whole lifecycle. Carries ADR-0030.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ every message was dropped on the floor — a delivery path that looks wired and
|
|||||||
independent of the case that produced it.
|
independent of the case that produced it.
|
||||||
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
|
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
|
||||||
discipline in projection code.
|
discipline in projection code.
|
||||||
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW
|
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW —
|
||||||
(S-19b, #150).
|
done in S-19b-2 (#153), ADR-0030.
|
||||||
|
|
||||||
**Negative / costs**
|
**Negative / costs**
|
||||||
|
|
||||||
@@ -142,8 +142,9 @@ every message was dropped on the floor — a delivery path that looks wired and
|
|||||||
(`Acl__Objecten__Token`) in compose.
|
(`Acl__Objecten__Token`) in compose.
|
||||||
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
||||||
name (compose + `register.py`).
|
name (compose + `register.py`).
|
||||||
- Until S-19b lands, the public register is still read from the NRC-derived projection, so
|
- ~~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.
|
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)
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# 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 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.
|
||||||
Reference in New Issue
Block a user