From 62fb98670176f58f8a58fbc4e0e299785768b828 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 12:36:52 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-0030=20=E2=80=94=20the=20read=20pro?= =?UTF-8?q?jection=20is=20sourced=20from=20the=20register=20(refs=20#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the re-source and the three decisions inside it: the ACL writing an INGEDIEND record on submit (without which re-sourcing silently drops every submitted registration), the dedup key being the projected row rather than the notification, and the notification log holding the row rather than the event. Closes out ADR-0028's stated direction and the caveat it left open — the register record was written but not yet read, and the two had to agree; there is now one source. --- BACKLOG.md | 6 +- .../adr-0028-objecten-holds-the-register.md | 9 +- ...30-projection-sourced-from-the-register.md | 135 ++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/adr-0030-projection-sourced-from-the-register.md diff --git a/BACKLOG.md b/BACKLOG.md index 0d0304b..f707d0f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -296,9 +296,9 @@ 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-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-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1. +- **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-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. --- diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index 624eaed..82bab55 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -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. - 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). +- 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** @@ -142,8 +142,9 @@ every message was dropped on the floor — a delivery path that looks wired and (`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. +- ~~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) diff --git a/docs/architecture/adr-0030-projection-sourced-from-the-register.md b/docs/architecture/adr-0030-projection-sourced-from-the-register.md new file mode 100644 index 0000000..6bf0bb4 --- /dev/null +++ b/docs/architecture/adr-0030-projection-sourced-from-the-register.md @@ -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.