# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader - **Status:** Accepted - **Date:** 2026-06-30 - **Deciders:** Respellion engineering - **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo ## Context S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status` — and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable** (a derived artefact, never a write-only source of truth). Two design questions had no obvious answer: 1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`). It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`, not the ACL. 2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its schema." Two deployables on one table looks like a violation. ## Decision **One Postgres database is the read projection. The Event Subscriber writes it (projector) and the projection-api reads it (query); both are processes of the single "Read Projection" bounded context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is deferred.** - **Schema ownership.** The read model — `register_projection` plus the subscriber's `processed_notifications` log — lives in `services/projection-api/Projection.ReadModel` (EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split (one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5 forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still holds for every domain database. - **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key derived from the immutable notification content). A duplicate insert raises a unique violation, caught and reported as "already recorded", so the duplicate never reaches the projection. The projection upsert is itself idempotent on the zaak id, a second line of defence. - **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection` and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access (§8.1) and no ACL dependency — keeping S-06 within its stated scope. - **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape is in place so that change is additive. - **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a migrated relational schema, LINQ queries, and a clean port implementation. What we'd write instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the audited, shipped graph. The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`, the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all in-network, reaching services by container IP (ADR-0006/0007). ## Consequences - **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is ready for S-09 to tighten public-safe field filtering. - **Negative / deferred:** - `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL. - The abonnement is registered by the verify harness (by container IP), not provisioned persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked as a follow-up; a plain `make up` therefore needs the abonnement registered before the event path flows. - Two services share one database. Acceptable for a derived read model; revisit if the read and write sides ever need independent scaling or storage. ## Alternatives considered - **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks to ZGW) and would need its own ADR to bend the rule. - **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward; deferred to a follow-up. - **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** — rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no current benefit over a shared, rebuildable read model. - **Separate databases for the log and the projection** — rejected as premature: both are the read side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.