# 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-26/#162 — the werkbak refreshes itself (ADR-0032) **Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open **appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a background refresh swaps the rows in without flashing the loading state, and a transient failure no longer strands the view on its error message until someone reloads. ```bash # 1. Two windows. Left: the behandel werkbak, already open and idle. python3 infra/keycloak/check_realms.py otp # a code, valid right now open http://localhost:8142 # merel-behandelaar / test123 + that code # # 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen). open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF # # 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was # never reloaded and never left the werkbak. # # 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the # absence of the reload IS the assertion. make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN" # # 5. Component level (background refresh, failure recovery, teardown): pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards) ``` **The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`. **Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path. --- ## S-19a — approval writes the register record to Objecten (#149, ADR-0028) **Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process, Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an approval updates the existing object instead of creating a second one. ```bash # 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it). make up # # 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then # asserts Objecten holds exactly one RegisterRecord for *that* registration: make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN" # # 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object): make verify-acl # → "Writes a register record and updates it in place on a second write" # # 4. See it for yourself — every register record currently in Objecten: curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \ -H 'Accept-Crs: EPSG:4326' \ 'http://localhost:8021/api/v2/objects' | python3 -m json.tool ``` Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything else (ADR-0027), so no personal data can reach the world-readable register even by mistake. **The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW `resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time is pinned in config (ADR-0028, same reasoning as ADR-0021). **Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten is S-19b (#150). --- ## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027) **Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the Objecttypen API at startup. The schema is public-safe by construction — `id`, `status`, `reference` only, mirroring the BFF's `OpenbaarEntry` (no `bsn`/`naam`), `dataClassification: open`. This is the schema S-19 writes register records against on approval. A `registerrecord-init` one-shot creates it over the API once Objecttypen is healthy (the Objecttypen `setup_configuration` has no objecttype step), idempotently. ```bash make up # The RegisterRecord objecttype exists with a published version: curl -s -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \ "http://localhost:8020/api/v2/objecttypes" | python3 -c \ 'import sys,json; o=[x for x in json.load(sys.stdin)["results"] if x["name"]=="RegisterRecord"][0]; print(o["name"], o["dataClassification"], o["versions"])' # → RegisterRecord open ['http://.../objecttypes//versions/1'] # # Automated (a CI verify-stack step): asserts the objecttype exists, has a published version, and # that version's schema carries id/status/reference. make verify-registerrecord # → OK — RegisterRecord v1 published, fields=['id', 'reference', 'status'] ``` **The path:** `infra/objecttypen-registerrecord/registerrecord.schema.json` (the reviewed public-safe contract) + `register.py` are streamed into an external config volume by `infra/seed-config.sh registerrecord` (bind-mounted locally); the `registerrecord-init` one-shot POSTs the objecttype + a published version. Re-running is a no-op. S-19 (#20) writes records against this schema in Objecten. --- ## S-18b — Objecten API up in compose, wired to Objecttypen (#140) **Outcome:** the upstream Maykin **Objecten API** runs in the stack — own **PostGIS** DB + redis, config seeded like the other CG modules (`objecten-init` runs `setup_configuration` from the `rr-objecten-config` volume: migrate + provision a dev **static API token** + register the **Objecttypen API** (S-18a) as a trusted service), a health-checked `objecten` web on host `:8021`. An object can now reference its objecttype; the ACL writes register records here on approval (S-19). ```bash make up # 1. The API is up; the seeded token authenticates (401 without, 200 with): curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8021/api/v2/objects # 401 curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 1234567890abcdef1234567890abcdef12345678" \ http://localhost:8021/api/v2/objects # 200 # # 2. It trusts Objecttypen — the seeded zgw_consumers service points at the Objecttypen API: docker exec infra-objecten-1 python src/manage.py shell -c \ "from zgw_consumers.models import Service; print(*[(s.slug,s.api_root) for s in Service.objects.all()])" # → ('objecttypen', 'http://objecttypen:8000/api/v2/') # # 3. Automated (a CI verify-stack step): asserts unauth 401 + token 200, against the running stack. make verify-objecten # → OK — no-auth 401, token 200 ``` **The path:** verbatim upstream image (`maykinmedia/objects-api`, pinned 3.4.0) + the same seed pattern as S-18a — `infra/seed-config.sh objecten` streams `data.yaml` into an external config volume, `objecten-init` (RUN_SETUP_CONFIG) applies it. Its `zgw_consumers` step registers Objecttypen (`api_type: orc`, api-key auth with the S-18a dev token). The RegisterRecord objecttype (S-18c) and the ACL write path (S-19) build on this. --- ## S-18a — Objecttypen API up in compose (#139) **Outcome:** the upstream Maykin **Objecttypen API** runs in the stack — own Postgres + redis, config seeded like the other CG modules (`objecttypen-init` runs `setup_configuration` from the `rr-objecttypen-config` volume: migrate + provision a dev **static API token**), a health-checked `objecttypen` web service on host `:8020`. This is the objecttype catalogue the register record (S-18b/S-18c, S-19) will use. ```bash make up # 1. The API is up; the seeded token authenticates (401 without, 200 with): curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8020/api/v2/objecttypes # 401 curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \ http://localhost:8020/api/v2/objecttypes # 200 # # 2. Automated (a CI verify-stack step): asserts both, against the running stack. make verify-objecttypen # → OK — no-auth 401, token 200 ``` **The path:** verbatim upstream image (`maykinmedia/objecttypes-api`, pinned) + the same seed pattern as OpenZaak/NRC — `infra/seed-config.sh objecttypen` streams `data.yaml` into an external config volume, `objecttypen-init` (RUN_SETUP_CONFIG) applies it. Objecten (S-18b) and the RegisterRecord objecttype (S-18c) build on this. --- ## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026) **Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie, verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill` (beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart. ```bash make up # 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`) # → "Default-fill" tab → change a value → Opslaan. open http://localhost:8143/default-fill # # 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the # beheerder role (BFF unit): # Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update # Bff.Tests → BeheerDefaultFillEndpointTests ``` --- ## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025) **Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus — the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm + `beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next. ```bash make up # 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`) # → the catalogus lists the published zaaktypen. open http://localhost:8143 # # 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE. make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus" # # 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests): # Bff.Tests → BeheerEndpointTests. ``` **Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm (`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its realm-role lifting, requiring `beheerder` rather than `behandelaar`. --- ## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023) **Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics` — ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path — golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency** (p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load. ```bash # 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the # golden-signal metric from every service. make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...] # 2. By hand: drive the stack, generate some load, then open the dashboard. make up for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals" open http://localhost:9090/targets # Prometheus → every service target UP ``` **The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation + AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes `:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over OTLP — Prometheus pulls, so there is no collector hop (ADR-0023). --- ## S-16b — distributed traces across the .NET services (#123, ADR-0023) **Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo. Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak; bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023). ```bash # 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace # spanning multiple services. make verify-tracing # → OK — trace spans ['bff', 'projection-api'] # 2. By hand: drive the stack, then explore traces in Grafana. make up curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace ``` **The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT` come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo datasource. --- ## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023) **Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned. Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the backplane up and proves Grafana can reach both datasources. Config is baked into small built images (`infra/observability/`) — no collector, no config-volume seeding. ```bash # 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus # datasources reachable through Grafana). This is a CI verify-stack step. make up make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable # 2. Or just the backplane, no full stack needed (no external egress): docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo open http://localhost:9090 # Prometheus ``` **The path:** services will export OTLP → **Tempo:4317** and expose `/metrics` ← **Prometheus** scrapes; **Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`. --- ## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022) **Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged. The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues, ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a log line. ```bash # 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep): cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \ --filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep" # → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped # on the next sweep; the sweep flags + persists every due inscription and returns their ids. # 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on: curl -s localhost:8000/registrations/ | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}' # → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept. ``` **The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob` → `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log. --- ## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020) **Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the `verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed empty, and the openbaar register showed nothing.) ```bash # 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init). make local # 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to # the werkbak, and the reference appears in the openbaar register: make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..." # 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit + # upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar / # test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at # http://localhost:8141. ``` > The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now > carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a > single-label host on zaak-create (ADR-0020 + ADR-0021). --- ## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021) **Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype (by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs. No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400. ```bash # The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak: make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key" # End-to-end unchanged (the ACL self-discovers the zaaktype during the flow): make verify-local # local stack — still green, now with no zaaktype-URL injection make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject) ``` > The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's > URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL > injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021). --- ## S-08d — Walking skeleton complete: browser → submit, end-to-end **Outcome:** the self-service portal is served in the stack and the full front-of-house happy path runs in a real browser — **mock DigiD login → submit → confirmation** — closing the walking skeleton (portal → BFF → domain → Flowable → ACL → OpenZaak, with the openbaar register reading the projection). ```bash # 1. Bring the whole stack up (portal served on :8140, BFF :8080, Keycloak :8180). make up # 2. Automated happy path — Playwright, inside the compose network (issuer-consistent): make verify-e2e # → login as jan-burger → submit → "ontvangen" confirmation # 3. By hand: open the portal, log in as jan-burger / test123, click "Registratie indienen". open http://localhost:8140 ``` > The portal is served same-origin with the BFF (Caddy proxies `/self-service` + `/openbaar`), so no > CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`. --- ## S-08c — Self-service submit form (NL Design System + DigiD) **Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the self-service portal (NL Design System styling); the page confirms with the reference returned by the BFF. The bsn comes from the DigiD token, so it's a confirm-and-submit flow (no bsn field). ```bash # 1. Bring the backend + Keycloak up (BFF on :8080, Keycloak on :8180). make up # 2. Serve the portal (dev server); it redirects to Keycloak for DigiD login. pnpm nx serve self-service # → http://localhost:4200 # 3. In the browser: log in as the mock DigiD user jan-burger / test123, then submit. # The page shows the returned registration reference. ``` > First real UI. The full **login → submit → success** happy path is automated in **S-08d** > (Playwright, against the compose-served app). Component tests + an axe WCAG 2.1 AA check on the > submit page run headless in the `frontend` CI lane. See `docs/frontend-decisions.md`. --- ## S-08a — Nx workspace + self-service portal skeleton **Outcome:** the frontend foundation — an Nx (pnpm) monorepo with the `self-service` Angular app (standalone + signals), lint/test/build green in a CI Node lane. The login + submit form follow in S-08c. ```bash # From a fresh clone (Node 24 + pnpm 11): pnpm install # native builds are pre-approved in pnpm-workspace.yaml pnpm nx test self-service # Vitest component test pnpm nx build self-service # production build pnpm nx serve self-service # → http://localhost:4200 (placeholder page) # Or the CI-equivalent one-shot: make frontend # install + nx lint/test/build ``` > Nx manages only `apps/`+`libs/`; the .NET services stay on `dotnet`/the Makefile. NL Design System > and the real form arrive in S-08c (#67); see `docs/frontend-decisions.md`. --- ## S-07 — BFF: the portals' single backend **Outcome:** the BFF validates Keycloak `digid` tokens on the self-service submit (forwarding the bsn to the domain) and serves the openbaar register anonymously with only public-safe fields — the front door the portals (S-08/S-09) will talk to. **The path:** portal → BFF `POST /self-service/registrations` (token-gated) → domain; and BFF `GET /openbaar/register` (anonymous) → projection-api. See ADR-0010. ```bash # 1. Bring the full stack up. make up # 2. Drive the BFF end-to-end (401 without a token, 202 with a real digid token, anonymous openbaar). make verify-bff # → "OK — BFF: 401 without token, 202 with a digid token, anonymous ..." # 3. Try it by hand (BFF on host port 8080). # a) A digid access token for the mock user jan-burger (bsn 123456782): tok=$(curl -s -X POST http://localhost:8180/realms/digid/protocol/openid-connect/token \ -d grant_type=password -d client_id=big-portal -d username=jan-burger -d password=test123 \ | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") # b) Submit — without the token it is 401; with it, 202: curl -s -o /dev/null -w "no token -> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations curl -s -o /dev/null -w "with token-> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations \ -H "Authorization: Bearer $tok" # c) The openbaar register is anonymous and exposes only id + status (never the bsn): curl -fsS http://localhost:8080/openbaar/register | jq ``` > The self-service token is validated against Keycloak's `digid` realm; the openbaar lookup needs no > token (S-09). The generated contract lives at `services/bff/openapi.json` — S-08's client is built > from it. --- ## S-05 — BIG Domain Service: submit a registration **Outcome:** submitting a registration starts a Flowable process; the external-task worker opens a zaak via the ACL and records it on the aggregate — the upstream half of the skeleton that produces the zaak S-06 then projects. **The path:** domain `POST /registrations` → Flowable `registratie` process → `OpenZaakAanmaken` worker → ACL → OpenZaak; `GET /registrations/{id}` shows the opened zaak (ADR-0009). ```bash # 1. Bring the full stack up (seeds config, builds our services, waits for health). make up # 2. Drive the full path end-to-end. This also seeds a published BIG zaaktype and points the # ACL at it (the zaak's zaaktype URL is server-assigned, so it isn't known at bring-up). make verify-domain # → "OK — the domain opened a zaak and recorded it on the registration" # 3. Submit one yourself (domain on host port 8130). Returns 202 + a Location to read back. loc=$(curl -fsS -D - -o /dev/null -X POST http://localhost:8130/registrations \ -H 'Content-Type: application/json' -d '{"bsn":"123456782"}' | sed -n 's/\r$//; s/^[Ll]ocation: //p') # 4. The worker opens the zaak off the request path (eventual consistency, ADR-0009); poll # until zaakUrl is filled. (Step 2 must have run first, so the ACL knows the zaaktype.) curl -fsS "http://localhost:8130$loc" | jq # → { "registrationId": "...", "status": "Ingediend", "zaakUrl": "http://.../zaken/api/v1/zaken/" } ``` > Registration state is in-memory for this slice (ADR-0009); the rebuildable read model is the > projection (S-06), fed by the very zaak this flow opens. --- ## 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. --- ## S-09 — Openbaar Register portal (public visibility) **Outcome:** the entry a zorgprofessional submits via self-service becomes publicly visible in the anonymous openbaar register portal — closing the walking-skeleton loop (submit → process → projection → public visibility). **The path:** self-service submit → BFF → domain → (zaak) OpenZaak → NRC → Event Subscriber → projection → openbaar portal reads the BFF's public-safe `GET /openbaar/register`. ```bash # 1. Bring the full stack up (self-service :8140, openbaar :8141). make up # 2. Submit a registration via the self-service portal (mock DigiD: jan-burger / test123), # or drive the whole happy path automatically (login → submit → public visibility): make verify-e2e # 3. Open the public register — no login. It lists the submitted entry (id + status only). # Only public-safe fields cross the BFF: bsn / naam never appear. open http://localhost:8141/ # search box; searches the BFF by referentie curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view via the BFF proxy # → [ { "id": "", "status": "INGEDIEND" } ] ``` > The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below. --- ## S-09b — Approval flow (public visibility flips to INGESCHREVEN) **Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`. **The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register. ```bash # 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN: make up make verify-e2e # 2. Or by hand: submit (as in S-09), note the reference, then approve it. # The zaak is opened off the request path, so approve once GET shows a zaakUrl. ref="" curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content # 3. The public register now shows the entry as approved. curl -fsS http://localhost:8140/openbaar/register | jq # → [ { "id": "", "status": "INGESCHREVEN" } ] ``` > **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. ## S-12 — Behandel portal: werkbak + beoordeling (#13, ADR-0013) A behandelaar now works submitted registrations in a real portal instead of the temporary admin endpoint. After a citizen submits (as above), the workflow parks the registration at the Flowable `Beoordelen` user task, and it shows up in the **werkbak**. The behandelaar logs in against the Keycloak `medewerker` realm and decides — **goedkeuren** (→ INGESCHREVEN via the ACL, per ADR-0011) or **afwijzen** — which also completes the Beoordelen task so the process advances. ```text # 1. Open the behandel portal and log in as a behandelaar (medewerker realm): # http://localhost:8142/ → merel-behandelaar / test123 + OTP # # 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status). # Find the reference from the submit confirmation and click "Goedkeuren" on that row. # # 3. The row drops off the werkbak (its Beoordelen task is completed) and the openbaar register # (http://localhost:8141/) now shows that reference as INGESCHREVEN. ``` **The path:** behandel portal → BFF `POST /behandel/registrations/{id}/decide` (behandelaar policy, `medewerker` realm) → domain applies the decision + completes the Flowable `Beoordelen` task → ACL → NRC → event-subscriber → projection → openbaar register shows INGESCHREVEN. > The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak → > public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now > drives the behandel portal in place of the old admin endpoint. ## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014) A zorgprofessional can withdraw their own still-open registration from the self-service portal. The withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the behandelaar's werkbak. ```text # 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123), # note the "Referentie" on the confirmation. # 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken. # 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer # appears — its Beoordelen task was cancelled. ``` **The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped) → domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the interrupting boundary event ends it → the werkbak drops the case. > DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path > (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the > `Een registratie intrekken` acceptance scenarios and the domain live check. ## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015) A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task; the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to `teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps its identity throughout — only who may claim it changes. The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the verify-domain check automates): ```bash # 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case # parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar) # but NOT claimed. # # 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early: FL=http://localhost:8090/flowable-rest/service PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])') TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \ -H 'Content-Type: application/json' -d '{"action":"move"}' AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \ -H 'Content-Type: application/json' -d '{"action":"execute"}' # # 3. Within a couple of poll cycles the task's candidate group flips to teamlead: curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}] ``` **The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren` external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's candidate group behandelaar → teamlead (§8.2). > Both branches (escalate after 14 days; no-op when completed in time) are covered by the > `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing > and reassignment are asserted live by the verify-domain check. ## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016) A registration's diploma origin decides its route. A DMN service task in the registratie process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a **foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the DMN, not in code — a beheerder can read and adjust the decision table directly. The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to the domain directly, so the demo drives it through the domain endpoint: ```bash # 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference): DOM=http://localhost:8080 # domain service curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \ -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:' # # 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task # (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable: FL=http://localhost:8090/flowable-rest/service curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool # # 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task: TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \ -H 'Content-Type: application/json' -d '{"action":"complete"}' # A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen. ``` **The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`; domestic: `Beoordelen` directly (§8.2, ADR-0016). > The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance > scenarios and unit tests (the origin is carried into the process); the DMN decision and the > foreign→CBGV routing are asserted live by the verify-domain check. ## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017) After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting for the citizen's documents (their diploma). Two things can happen: - **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility routing (S-13) → beoordeling. - **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the `RegistratieVerlopen` external task, and the domain expires the registration to the terminal status **VERLOPEN** (the case is cancelled). The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a **"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is demonstrated by firing the 30-day timer early via the management API. ```bash DOM=http://localhost:8080 # domain service FL=http://localhost:8090/flowable-rest/service # flowable-rest # 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten: curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \ -d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/ reference WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}' # 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling. TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \ -H 'Content-Type: application/json' -d '{"action":"complete"}' # 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the # instance's timer job, "move" it to executable; the async executor fires the interrupting event. PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ -d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])') JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \ -H 'Content-Type: application/json' -d '{"action":"move"}' # The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN: curl -s "$DOM/registrations/" # → {"status":"Verlopen", ...} ``` **The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→ routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task → domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017). > Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker + > aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the > verify-domain check. ## S-10b — Diploma upload stored in the ZGW Documenten API (#103, ADR-0018) The self-service "Documenten aanleveren" action (S-10a) is now a **real file upload**: after submitting, the citizen picks a PDF and uploads it. The portal base64-encodes the file client-side and posts it to the BFF; the BFF forwards it to the domain, which stores it via the **ACL** as a ZGW `enkelvoudiginformatieobject` in the **Documenten (DRC) API** and relates it to the zaak — then completes the `WachtOpDocumenten` wait so beoordeling can proceed. Per §8.1 only the ACL talks to ZGW. ```bash make up # 1. Log in as jan-burger / test123, submit, then — once the openbaar register shows the row — # choose a PDF under "Documenten aanleveren" and upload it. The page confirms "aangeleverd". open http://localhost:8140 # # 2. Automated: the walking-skeleton e2e now uploads a real PDF before the behandelaar approves. make verify-e2e # # 3. The ACL integration test proves the document is really created in the Documenten API and # related to the zaak (against a live OpenZaak): make verify-acl # → "Storing a diploma creates a real informatieobject related to the zaak" ``` **The path:** portal (base64) → BFF `POST /self-service/registrations/{id}/documents` → domain `ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` + `zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018). ## S-10c — the ZGW zaak is cancelled when the document term lapses (#106) When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` — it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019). ```bash # 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat # against a live OpenZaak: make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat" # # 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts # the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd): make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak" ``` **The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL `POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to `Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the job for redelivery rather than diverging the two (ADR-0019). --- ## S-15c — MFA on the medewerker realm (#132, ADR-0031) **Outcome:** staff logins (behandel + beheer portals) need a **second factor**. The medewerker realm seeds every medewerker with a TOTP credential, so Keycloak's conditional-OTP step challenges them in both the browser flow and the direct grant; a password alone no longer yields a token. `CONFIGURE_TOTP` is a default required action, so a medewerker added later must enrol first. Citizen realms (digid, eherkenning, eidas) are unchanged — they mock brokers that carry their own assurance. ```bash # 1. Manual: log in to the behandel portal. After username + password Keycloak asks for a code. python3 infra/keycloak/check_realms.py otp # a valid code, right now open http://localhost:8142 # merel-behandelaar / test123 + that code # # 2. Automated: the realm smoke check asserts the password alone is REFUSED, then that # password + TOTP succeeds and still carries the behandelaar role: make keycloak-smoke # → "medewerker merel-behandelaar password-only login refused [OK]" # # 3. End-to-end: every staff login in the e2e goes through the OTP prompt (loginMedewerker): make verify-e2e # → registration.spec (behandelaar approves), catalogus.spec, default-fill.spec ``` **The path:** the seeded `otp` credential in `infra/keycloak/realms/medewerker-realm.json` activates Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture secret is shared and committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators (ADR-0031).