Files
register-referentie/docs/architecture/adr-0030-projection-sourced-from-the-register.md
T
not 94742a261f
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m26s
CI / unit (push) Successful in 1m37s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 6m42s
CI / verify-stack (push) Failing after 11m26s
feat: read projection sourced from the register in Objecten (closes #153) (#155)
## What & why

S-19b-2, closing out ADR-0028's stated direction: **the read projection is now derived from the
`RegisterRecord` in Objecten, not from ZGW zaak events.**

Until now the subscriber listened on `zaken` and *inferred* register state from case events — a
`zaak/create` meant INGEDIEND, and any `status/create` was assumed to be the approval (it may not
read OpenZaak, so it could not tell statustypen apart). The reference wasn't in the notification
at all, so every projection made a second hop to the ACL. The register — a fact about a person —
was being reconstructed by guessing at the lifecycle of the case that produced it.

- The subscriber's abonnement moves to the `objecten` kanaal (S-19b-1 made it publish).
- An Objecten notification carries **no record data**, only the object URL, so the record is read
  back through the ACL (`POST /register-records/read`) — §8.1 applies to Objecten exactly as
  ADR-0028 established.
- The record carries `id`, `status` and `reference`, so the row *is* the record: `IsZaakCreated`,
  `IsZaakStatusSet`, `ZaakUrl`, `ZaakId` and `ToEntry`'s `Resource == "status"` inference are all
  gone, and so is the ACL enrichment hop.
- **The ACL now writes an INGEDIEND record on submit.** Without it, re-sourcing would silently
  drop every submitted registration from the public register, since only approval wrote a record.
- `processed_notifications` holds the projected row (`register_id`, `status`, `reference`) instead
  of the ZGW event, so a rebuild is a replay with no mapping rules and no upstream reads at all.

**ADR-0030** records it. ADR-0028's open caveat — record written but not yet read, "the two must
agree" — is closed: there is one source now.

Closes #153

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing tests committed before the implementation — two red/green pairs, ACL side
      (06c0444566ef7d) and subscriber side (142ed458af09b2).
- [x] Refactor commit follows (b496ac9).
- [x] Conventional Commits referencing the issue (`refs #153`).
- [x] CI green — all six jobs on b30fa66, `verify-stack` end to end including the e2e.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes
      (`verify-stack`'s bring-up step — see the wait-healthy fix below).
- [x] Docs updated — ADR-0030 added, ADR-0028's consequence + caveat annotated, BACKLOG.md,
      e2e header comment.
- [x] ADR added in `docs/architecture/`.
- [x] Demo note in `docs/demo-script.md` — n/a: no user-visible change. The openbaar register
      shows the same two statuses for the same registrations; only where they come from changed.

## Notes for reviewers

**The decision I'd most like a second opinion on** is the one the issue didn't settle: what
happens to INGEDIEND. Objecten held only INGESCHREVEN records, so re-sourcing forced a choice
between (a) the ACL also writing on submit, (b) a public register that lists only actual
registrations, or (c) a hybrid keeping both kanalen. I took (a): visible behaviour is unchanged
and the register holds the whole lifecycle. (b) is arguably the better *semantics* for a public
register but narrows what the portal shows and reads against PRD §68 ("~50 register entries with
diverse statuses"); (c) leaves the projection half-derived from ZGW, which is the coupling
ADR-0028 set out to remove. All three are laid out in ADR-0030.

**The dedup key is the projected row**, `objecten:object:{url}:{status}:{reference}` — not the
object URL (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) and not URL+actie (a retried
approval is a second `update`). Redeliveries collapse, genuine state changes don't. §8.6.

**The migration drops columns rather than renaming them.** EF scaffolded renames — `resource` →
`register_id`, `zaak_id` → `status` — which would have carried ZGW values into columns meaning
something else, and a rebuild would then have projected that garbage. It also empties both
tables: a pre-slice row describes a zaak event the new projector can't reproject, and those
registrations have no RegisterRecord in Objecten either, so they're not re-derivable from the new
source. Stated as a ceiling in the ADR — fine while stacks are ephemeral, backfill from Objecten
if a long-lived environment ever needs it.

**`run-projection-check.sh` now opens its zaak through the ACL** instead of straight against
OpenZaak, because the ACL is what writes the record. A zaak created behind the ACL's back
produces no projection row — that's the re-source working, not a gap.

## Three fixes CI found, none of them in the projection logic

1. **`wait-healthy.sh` matched the wrong container** (744f91a). Bring-up timed out with
   `TIMEOUT: 'objecten' not healthy (status=none)` while the `docker ps` it dumps showed
   objecten `Up 9 minutes (healthy)`. `--filter name=` is a substring match, so `objecten` also
   matches `objecten-db`/`objecten-redis`/`objecten-celery`, and `head -1` took whichever docker
   listed first — the celery worker has no healthcheck, hence `status=none`. Latent since those
   services landed and decided purely by listing order; `objecttypen` matches `objecttypen-db`
   the same way. Anchored on the compose replica suffix, which the verify scripts already do.
2. **The ACL had to be repointed at OpenZaak's IP** (7e0897a). Opening the zaak through the ACL
   put this check in the same bind run-domain-check.sh already handles:
   `400 {"name":"zaaktype","code":"bad-url","reason":"Voer een geldige URL in."}`. OpenZaak
   reflects the request Host into the zaaktype URL and then rejects it on zaak-create when
   single-label — the mechanism compose already documents on `ACL_OPENZAAK_BASEURL`.
3. **Approval arrives as `partial_update`, not `update`** (0dd26a7b30fa66) — the one real bug
   in the slice. The ACL upserts with PATCH; DRF routes it through the notifying `update()` but
   names the action `partial_update`, so the projector dropped every approval. Only the e2e could
   catch it: `verify-projection` drives a submit, and per ADR-0028 the e2e is the only check that
   drives a *real* approval.

`verify-tracing` also failed once (run 722) on a path this PR doesn't touch, and passed on a
plain re-run of the same commit. Tempo logged `pusher failed to consume trace data` /
`distributor_pool failing healthcheck` — it dropped spans under runner load rather than the trace
chain being broken. Filed as **#156** rather than absorbed here.

**Correction to the #152 PR notes:** I wrote there that celery concurrency was "the next knob" if
verify-stack got tight. It isn't — `CELERY_WORKER_CONCURRENCY` already defaults to 1 in the Maykin
image, so `objecten-celery` is already a single-process worker. Noted in #156.

**Possible follow-up, deliberately not done here:** an `openzaak.local` network alias mirroring
`objecten.local` would remove the ACL-repoint dance from both run-domain-check.sh and
run-projection-check.sh. It changes the host in every zaak URL the system produces, which is too
broad a ripple to land inside an unrelated slice — worth its own issue.

**Known costs, all in the ADR:** submission is now two writes across two modules and eventually
consistent (same posture ADR-0028 accepted for approval); projecting now depends on the ACL being
reachable on the main path, not just for enrichment (NRC retries, so it converges); and OpenZaak
still publishes to `zaken` with nothing in the product listening — kept because `verify-nrc`
asserts that path.Reviewed-on: #155
2026-09-01 07:26:33 +00:00

8.0 KiB

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 (resourceregister_id, zaak_idstatus) 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.