diff --git a/docs/architecture/adr-0008-read-projection-store.md b/docs/architecture/adr-0008-read-projection-store.md new file mode 100644 index 0000000..17dcda0 --- /dev/null +++ b/docs/architecture/adr-0008-read-projection-store.md @@ -0,0 +1,89 @@ +# 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. diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 0000000..a9e5396 --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,35 @@ +# Demo script + +A running log of demoable outcomes, one section per slice. Each entry is a short, +copy-pasteable walkthrough against a local `make up` stack. + +--- + +## S-06 — Event Subscriber + read projection + +**Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which +projects it into a rebuildable read projection the projection-api serves. + +**The path:** OpenZaak → (notification) NRC → (abonnement callback) Event Subscriber → +`register_projection` → projection-api `GET /register`. + +```bash +# 1. Bring the full stack up (seeds config, builds our services, waits for health). +make up + +# 2. Register the Event Subscriber's abonnement and create a zaak, then read it back. +# (The verify-projection check does exactly this end-to-end and asserts the result.) +make verify-projection # → "OK — projection-api serves zaak with status INGEDIEND" + +# 3. Observe the projection directly via the read API (host port 8120). +curl -fsS http://localhost:8120/register | jq +# → [ { "id": "", "status": "INGEDIEND", "bsn": null, "naamPlaceholder": null } ] + +# 4. Idempotency + rebuild: replays don't duplicate; a rebuild repopulates from the +# notification log (no OpenZaak access needed — ADR-0008). +curl -fsS -X POST http://localhost:8110/admin/rebuild # Event Subscriber, host port 8110 +curl -fsS http://localhost:8120/register | jq 'length' # → unchanged +``` + +> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and +> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice. diff --git a/mkdocs.yml b/mkdocs.yml index b4b3949..32e7b71 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,7 +29,9 @@ nav: - "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md - "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md - "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md + - "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md - Working in Gitea: gitea-workflow.md + - Demo script: demo-script.md - Runbooks: - CI: runbooks/ci.md