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.