diff --git a/docs/architecture/adr-0012-citizen-reference-correlation.md b/docs/architecture/adr-0012-citizen-reference-correlation.md new file mode 100644 index 0000000..eb56d7c --- /dev/null +++ b/docs/architecture/adr-0012-citizen-reference-correlation.md @@ -0,0 +1,104 @@ +# ADR-0012: One citizen-facing reference across self-service and the openbaar register + +- **Status:** Accepted +- **Date:** 2026-07-14 +- **Deciders:** Respellion engineering +- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation) + +## Context + +A citizen submits through the self-service portal and is shown a confirmation with a +**reference** so they can find their registration back in the public register. But the two +sides showed **different identifiers**: + +- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the + domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak + exists. +- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL, + assigned by OpenZaak when the ACL opens the zaak. + +These never match, so the reference on the confirmation was useless for looking the entry up. +The two identifiers live on opposite sides of the ACL boundary and are generated by different +systems at different times, so there is no way to reconcile them after the fact without a +correlating value carried across the boundary. + +The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed +`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the +`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any +such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up. +This is that follow-up, scoped to the one field the citizen actually needs. + +## Decision + +**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value +as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection +with the reference by reading the zaak through the ACL, and stores it in the replay log so +rebuild stays log-only.** + +Concretely, following the request path: + +1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes + `registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets + it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per + `bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that + constructs ZGW payloads (§8.1); the domain never sees a ZGW URL. +2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the + zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`), + which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber + still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP. +3. **Projection + replay log.** The reference is written both to the `register_projection` row + **and** to the `processed_notifications` replay log (a new nullable `reference` column on + each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not + OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log + without re-reading the ACL. +4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes + `id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either + `id` or `reference`. The openbaar register's "Referentie" column now renders `reference`. + +The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured +from the submit confirmation must appear as a cell in the public register. + +### Why HTTP to the ACL, not the ACL as a library + +ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead +call the ACL **over HTTP**, consistent with every other cross-service hop in this system +(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the +ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption +boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config. + +## Consequences + +**Positive** + +- One reference, end to end: the citizen's confirmation value is exactly what the public + register shows and searches by. +- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not + OpenZaak. +- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`, + so `/admin/rebuild` needs no ACL/ZGW access. +- The column additions are nullable and additive; older rows without a reference are tolerated. + +**Negative / costs** + +- A new coupling: the Event Subscriber now depends on the ACL being reachable + (`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will + need the notification redelivered (NRC already redelivers; the projection upsert is + idempotent). +- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded: + one small GET per zaak, off the citizen's request path. +- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak + were ever configured to auto-generate `identificatie`, the correlation would break; the ACL + setting it explicitly is now load-bearing. + +## Alternatives considered + +- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and + the notification content is not ours to extend; it would also couple the projection to a + bespoke notification shape. +- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when + the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has + no zaak id to show at submit time. +- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make + rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant. +- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second + source of truth for a value that can simply be the same on both sides. diff --git a/docs/demo-script.md b/docs/demo-script.md index f178a00..2426f9e 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -227,3 +227,24 @@ curl -fsS http://localhost:8140/openbaar/register | jq > **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from > INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the > approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+). + +## #78 — One reference across both portals (ADR-0012) + +Before this change the self-service confirmation and the openbaar register showed **different** +identifiers, so a citizen could not look their registration back up. Now both show the same +**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the +Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing +it in the replay log so rebuild stays log-only (ADR-0008). + +**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC → +Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register. + +```bash +# Submit as in S-09 and note the reference on the confirmation, then find it in the public register: +ref="" +curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq +# → [ { "id": "", "status": "INGEDIEND", "reference": "" } ] +``` + +> The openbaar register's "Referentie" column and its search now use this reference — the exact value +> the citizen saw on submit. Asserted end-to-end by the Playwright happy path. diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 48c3853..167de80 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -396,6 +396,9 @@ services: image: register-referentie/event-subscriber:dev environment: ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection + # The subscriber enriches the projection with each zaak's reference (identificatie) by asking + # the ACL — the only code allowed to read ZGW (§8.1, #78). + Acl__BaseUrl: http://acl:8080/ # The bearer Open Notificaties must present on the abonnement callback. NRC's # registration probe expects a 401 without it (ADR-0007). Dev-only token. EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} @@ -410,6 +413,8 @@ services: depends_on: projection-db: condition: service_healthy + acl: + condition: service_healthy networks: [cg] # The read side of the projection. Shares Projection.ReadModel, so build context is root. diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index c2d604b..88c2de9 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -36,6 +36,10 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .toBeGreaterThan(0); + // #78: the reference shown in the public register must be the exact one the citizen saw on the + // submit confirmation — no mismatch between the two portals. + await expect(page.getByRole('cell', { name: reference })).toBeVisible(); + // Approve via the temporary admin endpoint (reached directly on the compose network, as a // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request // path by the worker, so wait for it before approving.