## What & why Two changes, made and verified together on a real cluster. **S-24 / #25 — a Helm chart for the platform.** One chart, `infra/helm/big-reference`, whose `values.yaml` is a near-literal transcription of `infra/docker-compose.yml`, rendered by three generic templates (Deployment, Job, Service) over a `workloads` map. Adding a service is a values edit. `make k8s-lint` renders and schema-checks the whole stack without a cluster. The issue asked for a *sketch*; this is deployed and verified end to end (see below), which is more than it asked for — the part it asked for that is **not** here is the production-posture write-up (HA, secrets, backup), see Known gaps. **#166 — Caddy replaces nginx in the portals.** nginx resolves a variable `proxy_pass` upstream itself, using only the `resolver` directive and never `/etc/resolv.conf`'s search domains. That had cost two workarounds in one script: rewriting the resolver address for rootless podman, and injecting a full FQDN so the bare `bff` name could resolve on Kubernetes. Caddy dials per request through the system resolver, so `reverse_proxy bff:8080` works on every engine unchanged; `apps/portal-nginx-resolver.sh` and the chart's `BFF_HOST` env are deleted. Closes #25 Closes #166 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation — twice: the Caddyfile contract test before the Caddyfiles, `make k8s-lint` before the chart. - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issues (`refs #25` / `refs #166`). - [ ] CI green — awaiting the run on this PR (`make k8s-lint`, `dotnet format` and the new unit self-check pass locally; the compose e2e and mutation lanes are CI's). - [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes — the portal images were rebuilt and verified standalone, but a full `make up` run has not been done on this branch. Please confirm in review or let CI's smoke test speak. - [x] Docs updated — `docs/runbooks/kubernetes-talos.md` (new), `frontend-decisions.md`, `demo-script.md`, and the docs that named nginx. - [x] ADR added — ADR-0033 (chart) and ADR-0034 (Caddy). - [ ] Demo note in `docs/demo-script.md` — not added: the deployment target is not a user-visible slice, and the Caddy swap is invisible to the demo script beyond the wording fix included here. ## How it was verified Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk) under virt-manager: **29 pods ready and four bootstrap Jobs complete in under three minutes, zero restarts**, using ~4.4 GB of the VM's 10 GB. - Full Common Ground path: portal Caddy → BFF → domain → Flowable → ACL → OpenZaak + Objecten → NRC → event-subscriber → projection → public register (`INGEDIEND`, reference matching the submitted registration). - Werkbak read with an MFA'd medewerker token → 200. - The browser flow driven with Playwright against `http://localhost:30140`: secure context, `crypto.subtle` present, Keycloak form reached, login completed, **no console errors**. - Routing checked against a stub BFF: SPA fallback serves deep links, each portal proxies its own groups, and a portal does *not* proxy a neighbour's group. ## Notes for reviewers Three bugs this shook out, each fixed at the cause rather than the symptom: 1. **`command` vs `args`.** Compose's `command:` replaces the image CMD; Kubernetes' replaces the ENTRYPOINT. Transcribing one to the other broke every upstream image that relies on its entrypoint — postgres refused to run as root, Keycloak tried to exec `start-dev`. The chart now `fail`s at render time on `command`. 2. **Concurrent migrations.** Both `/setup_configuration.sh` and `/start.sh` run `manage.py migrate`; compose serialises them with `depends_on`, Kubernetes has no such edge, so the init Job and its web pod raced (`relation "zgw_consumers_service" already exists`). The four Django services now do both steps in order in the web pod — which also deletes four workloads. 3. **`emptyDir` databases are wiped by any pod-template change.** `make k8s-reseed` now also restarts `event-subscriber` and `projection-api`, which create the projection schema on start and otherwise keep writing to a schema-less database. Known gaps / follow-ups: - **Secrets.** `values.yaml` carries the dev credentials in plain text (`admin/admin`, the ZGW client secret, the two Objecten tokens) and the chart has no `Secret` objects. Fine for a laptop demo, and exactly what #25's "production posture" ADR should address — I suggest a follow-up issue rather than stretching this PR. - **No CI gate for the chart yet.** `make k8s-lint` exists but is not wired into `.gitea/workflows/ci.yaml`, and nothing enforces that the chart and the compose file stay in step. Worth a small follow-up. - **This is two slices in one PR.** They were built and verified together and the diff is entangled (the chart was written against Caddy from the start), so splitting now would mean re-creating an nginx-shaped chart to throw away. Happy to split if you'd rather. - **Rebased onto #161** (merged as #165) rather than merged, to keep the history linear. One conflict, in the `unit:` target where both branches add a self-check line — resolved by keeping both. #161's `infra/host-browser.yml` arrived with `/usr/share/nginx/html/config.json` and is fixed to `/usr/share/caddy/` inside the `feat(portals)` commit, so no commit on this branch leaves that overlay pointing at a path the images no longer have.Reviewed-on: #167
881 lines
48 KiB
Markdown
881 lines
48 KiB
Markdown
# 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/<uuid>/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
|
|
`<service>: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 <id> 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/<id> | 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/<uuid>" }
|
|
```
|
|
|
|
> 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 <uuid> with status INGEDIEND"
|
|
|
|
# 3. Observe the projection directly via the read API (host port 8120).
|
|
curl -fsS http://localhost:8120/register | jq
|
|
# → [ { "id": "<zaak-uuid>", "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": "<zaak-uuid>", "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="<registration-reference-from-the-confirmation>"
|
|
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": "<zaak-uuid>", "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="<registration-reference-from-the-confirmation>"
|
|
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
|
|
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
|
|
```
|
|
|
|
> 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/<id> 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/<id>" # → {"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).
|
|
|