Compare commits

..
Author SHA1 Message Date
not 4ab2ef4285 fix(infra): cap observability containers' memory to protect the e2e browser (refs #123)
CI / frontend (pull_request) Successful in 2m55s
CI / mutation (pull_request) Successful in 6m17s
CI / verify-stack (pull_request) Successful in 5m31s
CI / lint (pull_request) Successful in 1m28s
CI / build (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m21s
verify-e2e (registration.spec) timed out on the shared CI runner — locator waits
failing at different steps across attempt/retry, the signature of memory
contention, not a logic bug (this e2e has an OOM history, d5e5fa2). The three
observability containers had no memory bound; cap them (tempo/prometheus 400m,
grafana 512m — generous vs idle) so they can't starve the app stack + the
Playwright chromium. Frees back more headroom than the OTel SDK added.

refs #123
2026-07-23 15:31:52 +02:00
not 8474b72bf4 docs: demo note + backlog tick for distributed tracing (refs #123)
CI / lint (pull_request) Successful in 4m29s
CI / build (pull_request) Successful in 59s
CI / unit (pull_request) Successful in 1m16s
CI / frontend (pull_request) Successful in 2m36s
CI / mutation (pull_request) Successful in 6m6s
CI / verify-stack (pull_request) Failing after 13m54s
refs #123
2026-07-23 14:37:50 +02:00
not 271c54197e feat(obs): OpenTelemetry distributed tracing across the five .NET services (refs #123)
Each host adds AddOpenTelemetry().WithTracing with ASP.NET Core + HttpClient
auto-instrumentation and an OTLP exporter to Tempo; service name + endpoint from
OTEL_* env set per app service in compose. Since every cross-service call goes
through a typed HttpClient, traceparent propagates for free, so a request is one
connected trace (bff → domain/projection → acl → openzaak). /health is filtered
out. The exporter no-ops harmlessly when Tempo is unreachable (verified: domain
boots healthy with no collector).

refs #123
2026-07-23 14:35:47 +02:00
not b32c352f20 test(infra): verify-tracing asserts one connected trace across services in Tempo (refs #123)
Generates anonymous BFF→projection-api traffic and asserts Tempo holds a single
trace containing both service.names — proving OTLP export plus traceparent
propagation across the HttpClient hop. Fails until the services are instrumented
(next commit). Runs in-network like the other verify checks.

refs #123
2026-07-23 14:30:59 +02:00
not 4274fd30d1 feat(infra): observability backplane — Tempo + Prometheus + Grafana (S-16a, closes #122) (#125)
CI / mutation (push) Successful in 6m22s
CI / verify-stack (push) Successful in 11m53s
CI / lint (push) Successful in 1m24s
CI / build (push) Successful in 1m6s
CI / unit (push) Successful in 1m23s
CI / frontend (push) Successful in 2m54s
## What & why

S-16a, the first of the **S-16 split** (#17 closed → #122/#123/#124, §13). Stands up a local, CI-friendly observability backplane so traces (S-16b) and metrics (S-16c) have somewhere to land, viewable in one Grafana.

- **Grafana Tempo** — OTLP trace ingest (gRPC 4317 / HTTP 4318), local storage.
- **Prometheus** — scrapes itself for now; service `/metrics` targets arrive in S-16c.
- **Grafana** — Tempo + Prometheus datasources auto-provisioned with fixed uids (`tempo`, `prometheus`), exposed on :3000.

All three are small **built images** with config baked in (`infra/observability/`), on the existing `cg` network. **No OTLP collector** (Tempo ingests OTLP directly; Prometheus scrapes) and **no config-volume seeding** — the tools aren't verbatim CG peer modules, so a 3-line `COPY` Dockerfile is the simpler path that still reaches sibling containers on the CI runner (**ADR-0023**).

### Verified, not assumed

`make verify-observability` (new CI `verify-stack` step, run early) asks Grafana to reach both datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's plugin implements no health method) — so it proves the datasources are wired, not merely that containers booted. Validated locally against the three containers (no external egress): Grafana healthy, both datasources reachable.

Closes #122

## Definition of Done

- [x] Failing test committed first (`verify-observability` fails with no backplane).
- [x] Implementation makes it pass; verified locally.
- [x] Conventional Commits referencing the issue (`refs #122`).
- [ ] CI green — awaiting Gitea Actions (verify-stack now includes the observability step; `docker compose config` validates locally).
- [ ] `docker compose up` reaches green health within 3 min — new containers are lightweight and off the health-gate list.
- [x] Docs — ADR-0023, demo-script, BACKLOG sync.
- [x] ADR added — `docs/architecture/adr-0023-observability-stack.md`.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **No app changes** — this is pure infra; the five services are untouched (instrumentation is #123/#124).
- **Ports:** Grafana 3000 (admin/admin, anonymous viewer on), Prometheus 9090; Tempo internal to `cg`.
- **CI:** the three containers are added to the failure log-dump list; deliberately **not** added to `WAIT_SVCS` (the check polls Grafana itself, so no in-image healthcheck tool is needed). Trades ~3 small image builds per run.
- **Next:** #123 wires OTLP export + `AddAspNetCoreInstrumentation`/`AddHttpClientInstrumentation` into the five hosts so a request becomes one connected trace in Tempo.

Reviewed-on: #125
2026-07-23 12:26:22 +00:00
not 4fe9915816 feat(domain): herregistratie reminder sweep on a Quartz cron (S-17, closes #18) (#121)
CI / verify-stack (push) Successful in 8m14s
CI / lint (push) Successful in 1m20s
CI / build (push) Successful in 59s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m38s
CI / mutation (push) Successful in 5m53s
## What & why

S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.

- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.

**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.

Closes #18
Closes #120

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.

Reviewed-on: #121
2026-07-23 10:31:56 +00:00
not 5f8ab4dbcd feat: self-service resume of an existing registration after refresh (S-26, closes #111) (#119)
CI / lint (push) Successful in 1m19s
CI / build (push) Successful in 1m7s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m41s
CI / mutation (push) Successful in 5m57s
CI / verify-stack (push) Successful in 8m24s
## What & why

After submitting, the self-service portal held the registration only in in-memory signals, so a **page refresh stranded an in-flight registration** — the reference and its "Documenten aanleveren" / "Trek aanvraag in" actions were lost, with no way back (the reference wasn't in the URL and there was no read endpoint). This is the gap a citizen hit in testing.

Now the portal **resumes on load**:
- **Domain:** `IRegistrationStore.FindOpenByBsnAsync` (the citizen's non-terminal INGEDIEND/IN_BEHANDELING registration) + `GET /registrations/current?bsn=`.
- **BFF:** owner-scoped `GET /self-service/registrations` (bsn from the DigiD token) → the current registration, or **204** when none. Regenerated `services/bff/openapi.json`.
- **Frontend:** `registration-page` calls it on init and restores the submitted view (reference + actions); 204 shows the submit form as before. api-client regenerated (orval).

Closes #111

## Definition of Done

- [x] Linked issue (#111).
- [x] TDD — store `FindOpenByBsnAsync` tests, BFF endpoint tests, an Angular component test (resume-on-load), a Playwright e2e (submit → reload → restored).
- [x] Conventional Commits referencing #111.
- [ ] CI green — validated locally (below); runner CI running.
- [x] `docker compose up` reaches green health — fresh stack + full e2e (3 specs) green.
- [x] Docs — `docs/synthetic-data.md` (new e2e users).
- [ ] ADR — N/A (follows existing BFF/domain patterns; no boundary change).
- [ ] Demo note — the flow is unchanged for the demo; no new demo-script section (happy to add one if wanted).

## Verified locally

- Unit: Big 141 (+7 store tests), Bff 36 (+3 endpoint tests), all suites green.
- Frontend: 12 self-service component tests (incl. resume-on-load); lint + build green.
- **e2e (fresh CI stack): all 3 specs pass** — `registration`, `resume`, `withdrawal` (29.5s, single worker).
- Mutation: domain **91.04%**, bff **100%** (break 90%). `make lint` clean.

## Notes for reviewers

- **Shared-stack isolation:** resume-on-load restores any open registration for the logged-in bsn, so the self-service e2e specs can no longer share `jan-burger` (the verify-* API checks submit as `jan-burger`/`123456782` before the e2e). Each spec now has its own DigiD citizen (`emma`/`sanne`/`lars`-burger); `jan-burger` stays the documented citizen for the verify checks. This is the fix for the two intermittent e2e failures seen during development.
- **Scope:** resumes the current **in-flight** registration only (terminal ones aren't resumed), per the issue's out-of-scope note.

Reviewed-on: #119
2026-07-23 07:22:08 +00:00
not 5de8c1e292 feat(acl): resolve the zaaktype by identificatie, not a pinned URL (S-27, closes #113) (#118)
CI / lint (push) Successful in 1m21s
CI / build (push) Successful in 58s
CI / unit (push) Successful in 1m7s
CI / frontend (push) Successful in 2m36s
CI / mutation (push) Successful in 5m36s
CI / verify-stack (push) Successful in 8m4s
## What & why

The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) + informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so every stack had to seed the catalogus and then capture + inject the resulting URLs out of band (CI's `run-domain-check.sh`; the local `local-seed`→`acl.env` bootstrap from ADR-0020). Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).

Now **the ACL resolves them itself** from OpenZaak's Catalogi API by stable business key:
- config `ZaaktypeIdentificatie` (`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`);
- a `CachedZaaktypeCatalog` resolves **lazily on first use** and caches (success only, so a pre-publish miss is retried — no startup ordering coupling);
- a clear "No published … found" error replaces the opaque placeholder 400.

Design in **ADR-0021** (proposed in #117).

Closes #113
Closes #117

## Consequences (the payoff)

No stack captures/injects a server-assigned URL any more — `docker-compose.yml`/`.local.yml`, `run-domain-check.sh` and `local-seed` all drop it; the local `acl.env` shrinks to a single line.

**One thing S-27 can't remove** (confirmed empirically during this work): OpenZaak validates the `zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host** (`http://openzaak:8000/…` → `zaaktype: bad-url`). So the ACL's **base URL** must still point at a URL-valid host (a container IP); that base-URL injection from ADR-0020 stays (local `acl.env` now carries only it; CI keeps `ACL_OPENZAAK_BASEURL`). ADR-0021 records this.

## Definition of Done

- [x] Linked issues (#113 slice, #117 adr-proposal).
- [x] TDD — resolver + gateway-lookup unit tests, updated `AclService` tests (50 unit tests green).
- [x] Implementation makes them pass; refactor of both compose stacks + verify scripts follows.
- [x] Conventional Commits referencing #113.
- [ ] CI green — see below.
- [x] `docker compose up` reaches green health — verified: fresh `make local` + `make verify-local` green with **no zaaktype-URL injection**; `acl.env` is base-URL-only.
- [x] Docs — ADR-0021 + demo-script S-27 note.
- [x] ADR added (ADR-0021).
- [x] Demo note appended.

## Verification done locally

- **50 unit tests** pass (resolver resolve/cache/retry-on-failure; gateway match/miss/blank-key; all `AclService` paths).
- **6 ACL integration tests** pass against a live seeded OpenZaak — incl. resolving the zaaktype + Diploma iot by business key, and a clear error for an unknown identificatie.
- **Fresh `make local` + `make verify-local`**: full flow (submit → werkbak → openbaar) green; `acl.env` = `Acl__OpenZaak__BaseUrl` only.
- `make lint` clean; ACL mutation ratchet run locally (see checks).

## Notes for reviewers

- `IZaakGateway` gains two resolve methods; `AclService` depends on the new `IZaaktypeCatalog` (singleton, so the cache persists).
- Supersedes the pinned-URL mechanism; ADR-0021 documents that ADR-0020's `seed-env`/entrypoint shim are **simplified** (base-URL only), not deleted, because of the URLValidator constraint above.

Reviewed-on: #118
2026-07-22 14:49:25 +00:00
not 183d0bce31 fix(infra): docker-compose.local self-seeds zaaktype, DMN + NRC abonnement (closes #110) (#114)
CI / lint (push) Successful in 1m20s
CI / build (push) Successful in 59s
CI / unit (push) Successful in 1m12s
CI / frontend (push) Successful in 2m42s
CI / mutation (push) Successful in 5m42s
CI / verify-stack (push) Successful in 9m21s
## What & why

The host-browser stack (`make local`) had drifted behind three slices, so a fresh bring-up couldn't complete the flow: registrations stuck at `OpenZaakAanmaken`, the behandel werkbak stayed empty, and the openbaar register showed nothing. The `verify-*` scripts do this setup for CI at test time; `make local` had no equivalent.

This makes the local stack **self-seed at bring-up** so it just works in a browser:

- **DMN** — `flowable-init` now also deploys `diploma-eligibility.dmn` (was BPMN-only), so completing `WachtOpDocumenten` routes through the DMN to `Beoordelen` instead of 404ing.
- **Zaaktype + ACL** — a `local-seed` one-shot publishes the BIG zaaktype (whose UUID is server-assigned, hence not static in the compose file) and writes the real URLs to `seed-env:/acl.env`; the ACL sources it on startup via an entrypoint override.
- **NRC abonnement** — an `nrc-subscribe` one-shot registers the `zaken` subscription at the event-subscriber callback, so notifications reach the projection/openbaar register.

Both one-shots reach OpenZaak/NRC by **container IP** (a single-label host fails their Django URLValidator), mirroring the CI verify scripts. Design + trade-offs in **ADR-0020**.

Closes #110

## Definition of Done

- [x] Linked Gitea issue (#110).
- [x] Failing test committed before the implementation — `test(infra): …` adds `infra/run-local-flow-check.sh` / `make verify-local`; the three gaps' failures were observed live on a fresh `make local` (red), and the fix turns it green.
- [x] Implementation makes the test pass; docs commit follows.
- [x] Conventional Commits referencing the issue (`refs #110`).
- [ ] CI green — running on the restored runner. Infra-only change; the CI `verify-stack` job uses `docker-compose.yml` (untouched). Also validated locally: `make verify-local` passes against a fresh `make local` (see below).
- [x] `docker compose up` from a fresh clone reaches green health checks — verified: `make local` healthy in ~2m20s, then `make verify-local` green.
- [x] Docs updated — ADR-0020 + demo-script note.
- [x] ADR added in `docs/architecture/` — ADR-0020.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Infra-only** — no service code changes; the ACL image and the CI stack (`docker-compose.yml`) are untouched.
- **Verified end-to-end on a fresh stack** (`make local-down && make local && make verify-local`):
  ```
  >> 2. zaak opened            (zaaktype seeded + wired)
  >> 3. documents accepted 204 (DMN deployed)
  >> 4. in the werkbak         (DMN routing → Beoordelen)
  >> 5. visible in the openbaar register (NRC abonnement)
  OK — a fresh local stack completed the flow with no manual seeding
  ```
- **Follow-up:** the cleaner design — ACL resolving its zaaktype by `identificatie` instead of a pinned server-assigned URL — is split out as **S-27 (#113)**; landing it would remove the `acl.env` injection here. ADR-0020 records this.
- The `seed-env` volume carries the generated `acl.env` from `local-seed` to the ACL; a `down --volumes` (as `make local-down` does) resets it cleanly.

Reviewed-on: #114
2026-07-22 12:44:29 +00:00
not d5e5fa254c fix(e2e): run Playwright single-worker to stop OOM page-crash in verify-stack (closes #115) (#116)
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m1s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m43s
CI / mutation (push) Successful in 5m53s
CI / verify-stack (push) Has been cancelled
## What & why

`verify-stack` was failing intermittently on the Playwright e2e with `Page crashed` mid-action (`locator.fill`) and 90s timeouts — the run logged **"2 workers"**, i.e. two full `channel: 'chromium'` browsers running alongside the entire compose stack on the 8 GB self-hosted runner. The renderer gets OOM-killed. Tests passed only when a retry happened to run alone.

Fix: pin `workers: 1` in `tests/e2e/playwright.config.ts` (there are only two long-running happy-path specs, so serial costs little) and add `--disable-dev-shm-usage`. This removes the memory contention at the source rather than leaning on `retries` (CLAUDE.md §15 — flaky tests are fixed, not retried).

Closes #115

## Definition of Done

- [x] Linked Gitea issue (#115).
- [ ] Failing test committed first — N/A: the "red" is the observed `verify-stack` e2e crash (`Page crashed`, 2 workers); this changes test-harness config to fix it. Verified green by re-running the e2e (see notes).
- [x] Conventional Commit referencing the issue (`refs #115`).
- [ ] CI green — the point of the change; `verify-stack` e2e should stop OOM-crashing.
- [x] Docs — none needed (test-config only; rationale in an inline comment).
- [ ] ADR — N/A.

## Notes for reviewers

- One-line-of-behaviour change: `workers: 1` + `--disable-dev-shm-usage`; no product or spec changes.
- `Page crashed` is a renderer OOM, not a product defect — the happy path passes when a browser runs alone (the flaky retries already showed this). Single-worker makes that the normal case.
- Independent of #110 (that PR fixes `docker-compose.local.yml`; this fixes the CI `verify-stack` e2e). Landing this first unblocks #110's `verify-stack`.

Reviewed-on: #116
2026-07-22 12:03:59 +00:00
not bf234e1322 docs(backlog): add S-26 self-service resume slice (refs #111) (#112)
CI / verify-stack (push) Successful in 11m16s
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m6s
CI / unit (push) Successful in 1m18s
CI / frontend (push) Successful in 3m7s
CI / mutation (push) Successful in 5m58s
## What & why

Mirror the new self-service **"resume after refresh"** slice into the Iteration 2 section of the curated backlog (`BACKLOG.md`), keeping it in sync with Gitea. Tracked as #111 (S-26).

Refs #111 — **does not close it**: the backlog is the curated mirror, the slice itself stays open for implementation.

## Definition of Done

- [x] Linked Gitea issue (#111).
- [ ] Failing test committed before the implementation — N/A (docs-only backlog mirror).
- [ ] Implementation makes the test pass; refactor commit if structure improved — N/A.
- [x] Conventional Commits referencing the issue (`refs #111`).
- [ ] CI green — no code paths touched; only `BACKLOG.md`.
- [ ] `docker compose up` reaches green health checks — N/A.
- [x] Docs updated (this IS the docs change).
- [ ] ADR added — N/A.
- [ ] Demo note in `docs/demo-script.md` — N/A (backlog entry, not a shipped user-visible change).

## Notes for reviewers

Single-file change: adds the `S-26` entry (Outcome + Acceptance) after S-14 in Iteration 2, matching the surrounding slice format. The `S-B04` local-stack bug (#110) is intentionally **not** added — the `S-B0N` bug-slices have never been mirrored in `BACKLOG.md` (they live only in Gitea).

Reviewed-on: #112
2026-07-22 09:12:19 +00:00
not c8fdfbb699 feat(acl,domain): cancel the ZGW zaak on document-timeout expiry (S-10c, closes #106) (#109)
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m1s
CI / frontend (push) Successful in 2m27s
CI / mutation (push) Successful in 5m34s
CI / verify-stack (push) Successful in 8m0s
CI / unit (push) Successful in 1m17s
## S-10c · Close the ZGW zaak on document-timeout expiry (closes #106)

Completes the S-10a/S-10b boundary flagged in ADR-0017: when a registration's 30-day document term lapses, the domain now cancels the **ZGW zaak** as well as marking the aggregate `Verlopen`, so OpenZaak and the register no longer diverge.

### What it does
On expiry the `ExpireRegistrationWorker` calls the ACL to set the zaak to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (vs the approval `Afgehandeld` + `Geregistreerd`), resolved **by omschrijving** in the ACL — the ACL-first ordering mirrors approval so a failed ZGW call leaves the job for redelivery rather than diverging the two.

**Path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL `POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd) → aggregate `Verlopen`.

### Layers touched (each red→green)
- **ACL gateway** — `SetZaakToCancellationStatusAsync` (Geannuleerd + Vervallen by name); approval now resolves its `Geregistreerd` resultaat by name too (a second resultaattype now exists).
- **ACL service/API** — `AclService.CancelZaakAsync` + `POST /annuleringen`.
- **Domain** — `IAclClient.CancelZaakAsync` + client; expiry worker cancels the zaak before advancing to `Verlopen`, guarded against redelivery double-cancel.
- **Seed** — non-terminal `Geannuleerd` statustype (volgnummer 2; `Afgehandeld` → 3) + `Vervallen` resultaattype, both idempotent by omschrijving and sharing the zaaktype's procestype.
- **Verify/integration** — ACL↔OpenZaak integration test (live `Geannuleerd` + resultaat); `run-domain-check.sh` fires the real P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD scenario asserts cancel-on-timeout vs untouched-when-in-time.
- **Docs** — ADR-0019 (cancellation modelling decision), demo-script, BACKLOG.

### Design note (ADR-0019)
ZGW allows only one eindstatus per zaaktype, so `Geannuleerd` is modelled as a **non-terminal** status (it records a cancellation status + resultaat but does not set `einddatum`). This follows the issue's explicit "distinct statustype + resultaat" outcome; the shared-eindstatus alternative is recorded in the ADR.

### Tests
Unit + acceptance all green locally (Acl 38, Big 134, Acceptance 17, Bff 33, EventSubscriber 19). Integration + verify-stack run in CI (need live OpenZaak + selectielijst egress).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #109
2026-07-21 13:58:15 +00:00
not 0904df8db0 feat(acl): diploma upload stored in the ZGW Documenten API (S-10b, closes #103) (#108)
CI / lint (push) Successful in 1m21s
CI / build (push) Successful in 1m4s
CI / unit (push) Successful in 1m12s
CI / frontend (push) Successful in 2m40s
CI / mutation (push) Successful in 5m31s
CI / verify-stack (push) Successful in 7m56s
## What & why

S-10b: the self-service **diploma upload** is now real. After submitting, the citizen picks a PDF and
uploads it; the portal base64-encodes it client-side → BFF → domain → **ACL**, which stores it in the
ZGW **Documenten (DRC) API** as an `enkelvoudiginformatieobject` and relates it to the zaak, then the
`WachtOpDocumenten` wait completes and the case advances to beoordeling. Per §8.1 only the ACL talks to
ZGW.

Closes #103

Mechanism in **ADR-0018** (proposal #107). Builds on S-10a (#102). The zaak-close-on-expiry item is
carved to **#106 (S-10c)**.

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer).
- [x] Conventional Commits referencing the issue (`refs #103`).
- [ ] CI green — all Gitea Actions jobs (pending on this PR).
- [x] `docker compose up` health unaffected (ACL boots on a placeholder informatieobjecttype URL; the real one is injected by verify-domain).
- [x] Docs updated (ADR-0018, demo-script, BACKLOG + S-10c).
- [x] ADR added (`docs/architecture/adr-0018-diploma-upload-via-acl-documenten.md`).
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **ACL** (`OpenZaakGateway.StoreDocumentAsync` + `AclService.StoreDiplomaAsync` + `POST /documenten`) reuses the existing gateway patterns (ZGW Bearer, buffered non-chunked body, **no CRS** — Documenten isn't geo). Unit-tested via the stub handler; an **integration test** stores a real document against live OpenZaak (verify-acl).
- **Transport:** base64 JSON on every hop (portal encodes client-side) — I deviated from proposal #107's multipart to keep one contract shape and avoid `IFormFile`/antiforgery/multipart-client plumbing; fine at diploma size (ADR-0018 §Alternatives).
- **Infra:** `seed_catalogus.py` seeds + publishes a "Diploma" `informatieobjecttype` and relates it to the zaaktype (while both concept); `verify-domain` injects its URL into the ACL. No new ZGW scopes (seed applicatie has `heeft_alle_autorisaties`).
- **e2e:** uploads a real PDF (`setInputFiles`) after the openbaar INGEDIEND row confirms the zaak is open (so storage doesn't race the OpenZaak worker).
- **Scope boundary:** the ZGW zaak is not set to a cancellation status on 30-day expiry — that's #106 (S-10c).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #108
2026-07-21 12:15:33 +00:00
not 4777ff2b1d feat(workflow): document-wait task + 30-day timeout cancellation (S-10a, closes #102) (#105)
CI / build (push) Successful in 1m1s
CI / unit (push) Successful in 1m11s
CI / frontend (push) Successful in 2m33s
CI / mutation (push) Successful in 5m14s
CI / verify-stack (push) Successful in 7m37s
CI / lint (push) Successful in 1m17s
## What & why

S-10a, the **workflow/timeout spine** of the (split) document-upload slice: the registratie process
now parks at a **`WachtOpDocumenten`** user task with an **interrupting `P30D` boundary timer**. When
the documents arrive the task completes and the process continues into the diploma routing (S-13) →
Beoordelen; if the 30 days lapse, the timer cancels the wait, runs a `RegistratieVerlopen`
external-worker task, and the domain expires the aggregate to a new terminal status **`Verlopen`**.
Backend only — the real upload trigger (portal → BFF → ACL → Documenten API) is S-10b (#103).

Closes #102

Mechanism recorded in **ADR-0017**; opened as proposal #104. Mirrors the S-14 escalation
(boundary-timer + external-worker) and S-11 withdrawal (interrupting cancel) patterns.

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green pairs per layer).
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issue (`refs #102`).
- [ ] CI green — all Gitea Actions jobs (pending on this PR).
- [x] `docker compose up` health unaffected (no new services; deploy path unchanged).
- [x] Docs updated (ADR-0017, demo-script, BACKLOG split).
- [x] ADR added (`docs/architecture/adr-0017-document-wait-timeout-cancellation.md`).
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Domain** (`Registration.Expire()` + `Verlopen`), **application** (`ExpireRegistrationWorker`),
  **infra** (`RegistratieVerlopenProcessor`/`Pump`, `IRegistratieVerlopenClient`, Flowable
  acquire/complete + `CompleteDocumentWaitAsync`) — the timeout counterpart to the OpenZaak/escalation
  worker trios; idempotent per §8.6.
- **BPMN** verified live against a `flowable-rest` probe: complete `WachtOpDocumenten` → routes to
  Beoordelen; fire the P30D timer → `RegistratieVerlopen` job (carrying `registrationId`) + the wait
  task cancelled. `verify-domain` exercises both branches in-stack (completes the wait in every existing
  block; fires the timer and asserts `Verlopen` in a new block).
- **Scope boundary:** on expiry the aggregate goes `Verlopen` and the process ends, but the ZGW *zaak*
  is not yet set to a cancellation status — that needs a new ACL method + statustype seeding and is
  folded into S-10b (noted in ADR-0017).
- `CompleteDocumentWaitAsync` is built and HTTP-tested here but not yet called from a domain endpoint;
  S-10b wires the upload trigger to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #105
2026-07-20 09:42:02 +00:00
not ccae27b3da feat(workflow): diploma-eligibility DMN routes foreign diplomas via CBGV-advies (S-13, closes #14) (#101)
CI / lint (push) Successful in 1m16s
CI / unit (push) Successful in 1m14s
CI / mutation (push) Successful in 5m14s
CI / build (push) Successful in 58s
CI / frontend (push) Successful in 2m29s
CI / verify-stack (push) Successful in 9m20s
## What & why

S-13: a diploma's origin decides its route. A **DMN** (`diploma-eligibility`) is evaluated inline by
the registratie process as a **`businessRuleTask`**; an exclusive gateway routes a **foreign**
(Buitenlands) diploma through a new **CBGVAdvies** user task before `Beoordelen`, a **domestic** one
straight there (PRD flow 4). The domain's only new job is carrying the diploma origin and passing it
as a process start variable.

Chose **Option B (DMN in the BPMN)** over the issue's literal "evaluated by the Domain Service via
Workflow Client" wording — keeps the decision a first-class workflow artefact and §8.2 clean.
Rationale in **ADR-0016** (proposal #100); noted on this issue.

Closes #14

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issue (`refs #14`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (additive; DMN deployed by flowable-init).
- [x] Docs updated (ADR-0016, demo note).
- [x] ADR added (`docs/architecture/adr-0016-diploma-eligibility-dmn.md`).
- [x] Demo note in `docs/demo-script.md`.

## How it was built (TDD)

- **Domain**: `DiplomaOrigin` on the aggregate + submit command; threaded through the process-start port so the Workflow Client emits a `diplomaOrigin` start variable. Red → green.
- **DMN + BPMN**: `workflows/diploma-eligibility.dmn` (origin → route); `businessRuleTask` + exclusive gateway + `CBGVAdvies` user task in `registratie.bpmn`; DMN deployed to Flowable's DMN engine by `flowable-init`.
- **Both paths**: `Een diploma op herkomst routeren` acceptance scenarios (origin carried into the process) + unit tests; verify-domain drives a foreign registration through CBGVAdvies→Beoordelen and the domestic one straight to Beoordelen — exercising both DMN branches live.

## Notes for reviewers

- Deviation from the issue's Option-A wording is deliberate and recorded (ADR-0016); the outcome is unchanged.
- The self-service eIDAS→foreign wiring is out of scope here (this slice is area:domain + area:workflow); the domain submit accepts an optional `diplomaOrigin` so the foreign path is drivable.
- Local green: domain unit 109, acceptance 15, `dotnet format`, Release build (0 errors), **domain mutation 95.39%** (break 90). The DMN/`businessRuleTask` REST wiring is CI-verified on verify-stack (no local full-stack run here).

Reviewed-on: #101
2026-07-20 07:26:52 +00:00
not 7bcbc726ce feat(workflow): beoordeling escalation to teamlead after 14 days (S-14, closes #15) (#99)
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 56s
CI / unit (push) Successful in 1m9s
CI / frontend (push) Successful in 2m27s
CI / mutation (push) Successful in 5m11s
CI / verify-stack (push) Successful in 7m30s
## What & why

S-14: a beoordeling a behandelaar does not pick up within **14 days** escalates to the **teamlead**.

A non-interrupting `P14D` boundary timer on the `Beoordelen` user task fires an external-worker task
(`BeoordelingEscaleren`); the domain's escalation worker reassigns the still-open task's candidate group
from `behandelaar` to `teamlead`. The task keeps its identity — only who may claim it changes. The
escalation-via-external-worker decision is recorded in **ADR-0015** (proposal #98); it upholds §8.2
(the Workflow Client stays the only code that talks to Flowable) and keeps Flowable a stock image.

Closes #15

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [x] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (no new services; escalation is additive to the domain worker).
- [x] Docs updated (ADR-0015, demo note).
- [x] ADR added (`docs/architecture/adr-0015-beoordeling-escalation.md`).
- [x] Demo note in `docs/demo-script.md`.

## How it was built (TDD)

- **Workflow Client** (`IBeoordelingEscalatieClient`): acquire `BeoordelingEscaleren` jobs → find the open `Beoordelen` task in the instance → add `teamlead`/remove `behandelaar` candidate group → complete the job. Red → green.
- **Escalation drain loop** (`BeoordelingEscalatieProcessor`) + hosted `BeoordelingEscalatiePump`, mirroring the OpenZaak worker. Red → green.
- **BPMN**: non-interrupting `P14D` boundary timer on `Beoordelen` → external task → escalation end.
- **Both branches** (escalate after timeout; no-op when completed in time) covered by the `Een beoordeling escaleren` acceptance scenarios + Workflow Client unit tests.
- **Live integration**: `verify-domain` fires the timer early via Flowable's management API and asserts the reassignment to teamlead.

## Notes for reviewers

- Interface segregation: escalation is on `IBeoordelingEscalatieClient`, separate from the OpenZaak worker's `IExternalWorkerClient`.
- Reassignment is two REST hops (add teamlead, remove behandelaar); idempotent on redelivery — see ADR-0015 consequences.
- Local checks green: domain unit tests (104), acceptance (13), `dotnet format --verify-no-changes`, Release build (0 errors), **domain mutation 96.69%** (break 90). The `run-domain-check.sh` escalation path is CI-verified on verify-stack (local full-stack run is constrained here).
- `BeoordelingEscalatiePump` excluded from mutation, mirroring the existing `OpenZaakJobPump` exclusion.

Reviewed-on: #99
2026-07-17 09:45:36 +00:00
not 8a537edd6c fix(infra): engine-portable portal nginx resolver (closes #96) (#97)
CI / lint (push) Successful in 1m28s
CI / build (push) Successful in 1m18s
CI / unit (push) Successful in 1m33s
CI / frontend (push) Successful in 3m7s
CI / mutation (push) Successful in 5m14s
CI / verify-stack (push) Successful in 7m7s
## What & why

Closes #96. The portal nginx configs hardcode `resolver 127.0.0.11` (Docker's embedded DNS) for their variable `proxy_pass` to the BFF, so on rootless **podman** (network-specific aardvark DNS) every proxied call 502'd — the portals loaded and login worked, but no in-app data flowed.

Add a shared `/docker-entrypoint.d` hook (`apps/portal-nginx-resolver.sh`, wired into all three portal Dockerfiles) that rewrites the resolver from the container's own `/etc/resolv.conf` at startup: a **no-op on Docker** (nameserver *is* 127.0.0.11) and **correct on podman** (rewrites to e.g. 10.89.0.1). nginx.conf is unchanged (the hardcoded value is the substitution anchor).

## How verified

Built the behandel image and ran it on the compose network under podman: the hook rewrote the config to `resolver 10.89.0.1`, and `GET /behandel/werkbak` proxied to the BFF returning **401** (auth), not 502. On Docker the nameserver is 127.0.0.11 so the substitution is a no-op and CI/e2e behaviour is unchanged.

Reviewed-on: #97
2026-07-16 14:23:40 +00:00
not e7bed37cda fix(infra): local event-subscriber Acl:BaseUrl parity (closes #94) (#95)
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / mutation (push) Has been cancelled
CI / verify-stack (push) Has been cancelled
## What & why

Closes #94. The local compose's `event-subscriber` lacked `Acl__BaseUrl` (and the `acl` dependency) that the canonical compose sets (#78) — so it threw `Missing configuration 'Acl:BaseUrl'` and exited on startup, which also knocked over podman-compose's bring-up of the rest of the stack (the frontends were left uncreated). Adds the env + dependency, matching `docker-compose.yml`.

## How verified

Recreated `event-subscriber` from the fixed compose locally — it now starts healthy, and the three portals come up (self-service :8140, openbaar :8141, behandel :8142). `docker compose config` valid.

## Note (separate, not fixed here)

On **rootless podman** the portal→BFF nginx proxy still 502s (`resolver 127.0.0.11` is Docker's embedded DNS; podman uses its own), and podman-compose orchestration of this dependency graph is flaky — both are pre-existing local-engine limitations, clean on Docker Desktop / CI. Tracking separately.

Reviewed-on: #95
2026-07-16 13:56:41 +00:00
not 94699f3603 feat(self-service): trek aanvraag in — withdrawal action (S-11c-2, closes #12) (#93)
CI / unit (push) Successful in 1m22s
CI / lint (push) Successful in 1m23s
CI / build (push) Successful in 1m15s
CI / frontend (push) Successful in 3m1s
CI / mutation (push) Successful in 6m21s
CI / verify-stack (push) Successful in 7m56s
## What & why

Final sub-slice of **S-11 · Withdrawal (Flow 3)** — the user-facing "trek aanvraag in" action, which **closes #12**.

- **self-service portal**: the submit confirmation gains a **"Trek aanvraag in"** button. It withdraws the just-submitted registration via `postSelfServiceRegistrationsIdWithdraw(reference)`; success shows an *ingetrokken* confirmation, a failure is surfaced (`role="alert"`) and the action stays available — same confirm-and-surface pattern as submit.
- **acceptance**: `Een registratie intrekken` — owner withdraws → INGETROKKEN + workflow cancelled; a different bsn is reported not-found.
- **e2e**: `withdrawal.spec.ts` — DigiD submit → trek aanvraag in → the portal confirms ingetrokken.
- **docs**: demo-script + frontend-decisions.

Together with S-11a (#88), S-11b (#89), S-11c-1 (#90), this completes the flow: citizen withdraws → domain INGETROKKEN → BPMN message event cancels the process → the case leaves the behandelaar's werkbak.

Closes #12

## Definition of Done

- [x] Linked Gitea issue (#12).
- [x] Failing tests committed before the implementation.
- [x] Implementation makes the tests pass.
- [x] Conventional Commits referencing the issue (`refs #12`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` unaffected.
- [x] Docs updated (demo-script + frontend-decisions).
- [x] ADR — ADR-0014 (from S-11b) covers the cancellation decision; nothing new here.

## Notes for reviewers

- Full local gate run before pushing: `dotnet format --verify-no-changes` clean; `make unit` green (Acceptance **11** incl. the 2 new withdrawal scenarios, Big 95, BFF 30, Acl 27, EventSubscriber 19); self-service lint/test/build green (9 tests, incl. the 2 new withdraw tests).
- `withdrawal.spec.ts` waits on the *ingetrokken* confirmation (which only renders after the withdraw POST returns), so it can't cancel the request early (the 499 lesson from #87). Live-validated by verify-stack.

Reviewed-on: #93
2026-07-16 13:06:55 +00:00
not 951bdd8364 fix(infra): local compose parity + host-browser OIDC (closes #91) (#92)
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / mutation (push) Has been cancelled
CI / verify-stack (push) Has been cancelled
CI / lint (push) Has been cancelled
## What & why

Closes #91. `infra/docker-compose.local.yml` (the no-make local stack) was missing the `domain` service and all three portals, and never wired host-browser OIDC — so browsing the behandel portal redirected to `http://keycloak:8080/…`, which a host browser can't resolve.

- **Parity**: add `domain`, `self-service`, `openbaar`, `behandel` (local now matches the CI-canonical `docker-compose.yml` service-for-service).
- **BFF**: give it the Keycloak + downstream env it was missing (it previously fell back to appsettings and couldn't reach Keycloak).
- **Host-browser OIDC**: pin Keycloak's frontend/issuer URL to `http://localhost:8180` (`KC_HOSTNAME`) with `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true`, so a host browser logs in on `localhost:8180` while the BFF still validates in-network via `keycloak:8080`.
- **Portals**: bind-mount a `localhost:8180` `config.json` over the image's baked `keycloak:8080` one (`infra/local-config/*`). openbaar is anonymous, no config.

## How verified

- `docker compose -f infra/docker-compose.local.yml config` valid; parity check shows nothing missing.
- Started Keycloak from the local compose and confirmed the discovery document:
  - **host view** (`localhost:8180`): `issuer` + all endpoints on `localhost:8180` (what the browser uses).
  - **in-network view** (`keycloak:8080`): `issuer` stays `http://localhost:8180/...` (matches browser tokens) while `jwks_uri`/`token_endpoint` resolve to `keycloak:8080` (reachable by the BFF).

## Notes for reviewers

- The full portal→BFF→Keycloak login round-trip should get a quick browser smoke test on a real engine (I validated the Keycloak issuer/backchannel split and compose validity, but can't drive a browser here). Ports: self-service :8140, openbaar :8141, behandel :8142; users in `docs/synthetic-data.md`.
- On rootless podman the portal→BFF nginx proxy (`resolver 127.0.0.11`) may 502 (a separate known podman-vs-docker DNS quirk); login is a browser redirect and is unaffected. Works on Docker Desktop.
- No app-code change; `docker-compose.yml` (CI-canonical) is untouched.

Reviewed-on: #92
2026-07-16 12:45:07 +00:00
not 2397d9196a feat(bff): owner-scoped self-service withdraw endpoint (S-11c-1, refs #12) (#90)
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / mutation (push) Has been cancelled
CI / verify-stack (push) Has been cancelled
CI / lint (push) Has been cancelled
## What & why

Third sub-slice of **S-11 · Withdrawal (Flow 3)** (#12) — the **owner-scoped BFF withdraw endpoint** (backend). S-11a/b made a withdrawal transition the aggregate and cancel the workflow; this adds the citizen-facing entry point through the BFF, gated to the registration's owner.

- **Domain**: `WithdrawRegistrationCommand` carries the caller's `bsn`; the handler returns a `WithdrawOutcome` and refuses a bsn that doesn't own the registration. Unknown and not-owned are **both 404** (indistinguishable — ownership isn't revealed). `POST /registrations/{id}/withdraw` takes `{bsn}` and maps the outcome (204/404).
- **BFF**: `POST /self-service/registrations/{id}/withdraw` (DigiD-authenticated) forwards the token's `bsn` to the domain and relays 204/404. The BFF authenticates; the domain owner-scopes (an aggregate invariant, not the domain doing auth).
- OpenAPI spec + Angular client regenerated for the new endpoint.
- `run-domain-check.sh` withdrawal step now sends the owner `bsn` (verify-stack).

Refs #12 — the self-service "trek aanvraag in" button + e2e (S-11c-2) closes it.

## Definition of Done

- [x] Linked Gitea issue (#12).
- [x] Failing tests committed before the implementation.
- [x] Implementation makes the tests pass.
- [x] Conventional Commits referencing the issue (`refs #12`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` unaffected.
- [x] No ADR needed (owner-scoping is an aggregate invariant; no boundary change).
- [x] Docs — the user-visible demo note lands with S-11c-2.

## Notes for reviewers

- **Full local gate run before pushing this time** (lessons from #89): `dotnet format --verify-no-changes` clean; `make unit` green — Acl 27, EventSubscriber 19, BFF 30, Acceptance 9, Big 95; `api-client` lint+test green.
- Owner mismatch returns 404 (not 403) so the portal can't be used to probe which references exist.

Reviewed-on: #90
2026-07-16 12:20:43 +00:00
not a34caba9ea feat(domain): withdrawal cancels the registratie process (S-11b, refs #12) (#89)
CI / build (push) Successful in 57s
CI / lint (push) Successful in 1m18s
CI / unit (push) Successful in 1m10s
CI / frontend (push) Successful in 2m38s
CI / mutation (push) Successful in 5m22s
CI / verify-stack (push) Successful in 7m18s
## What & why

Second sub-slice of **S-11 · Withdrawal (Flow 3)** (#12). S-11a (#88) made a withdrawal advance the aggregate to INGETROKKEN; this sub-slice **cancels the running Flowable process** so the withdrawn case leaves the behandelaar's werkbak.

- **BPMN** (`registratie.bpmn`): an interrupting message boundary event (`RegistratieIngetrokken`) on the `Beoordelen` task, routing to a dedicated "Registratie ingetrokken" end event.
- **Workflow Client**: `WithdrawBeoordelingAsync(executionId)` delivers `messageEventReceived` to the task's execution (PUT); `BeoordelingTask` now carries its `executionId`.
- **`WithdrawRegistration` handler**: after the domain transition, finds the open `Beoordelen` task for the registration and delivers the withdrawal message — best-effort, mirroring how the beoordeling completes its task.
- **Werkbak**: also filters out registrations that are no longer open, so a withdrawn case never surfaces even in the brief window before cancellation lands.
- **ADR-0014** records the decision (message event in BPMN vs. deleting the instance from code).
- **verify (`run-domain-check.sh`)**: a second registration parks at `Beoordelen`, is withdrawn via the domain, and the check asserts its `Beoordelen` task disappears — so verify-stack validates the live Flowable message correlation.

Refs #12 (S-11c — the BFF + self-service "trek aanvraag in" button + e2e — closes it).

## Definition of Done

- [x] Linked Gitea issue (#12).
- [x] Failing tests committed before the implementation (red → green per commit).
- [x] Implementation makes the tests pass.
- [x] Conventional Commits referencing the issue (`refs #12`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` unaffected (BPMN redeploys on a fresh CI DB via flowable-init).
- [x] ADR added (ADR-0014).
- [x] Docs — the user-visible demo note lands with S-11c.

## Notes for reviewers

- Verified locally: `Big.Tests` 94/94 pass; `Big.Api` builds; `registratie.bpmn` is well-formed.
- The Flowable message-correlation REST shape is validated **live** by verify-stack (the Workflow Client unit tests stub the exchange and assert only the request shape, per ADR-0009) — the new `run-domain-check.sh` withdrawal step is that live check.
- Known gap (ADR-0014): a withdrawal that races ahead of the process reaching `Beoordelen` finds no task to cancel; the aggregate is still INGETROKKEN and the werkbak filter hides it, but that instance parks unattended. A process-level event subprocess would close the gap — deferred.

Reviewed-on: #89
2026-07-16 11:09:28 +00:00
not 1f1c944a8b feat(domain): withdrawal — INGETROKKEN transition + endpoint (S-11a, refs #12) (#88)
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 56s
CI / unit (push) Successful in 1m5s
CI / frontend (push) Successful in 2m31s
CI / mutation (push) Successful in 4m57s
CI / verify-stack (push) Successful in 6m46s
## What & why

First sub-slice of **S-11 · Withdrawal (Flow 3)** (#12). A zorgprofessional can withdraw a still-open registration ("trek aanvraag in"); this sub-slice delivers the **domain transition + endpoint**, mirroring how S-12a shipped the beoordeling decision model on its own (#82).

- `RegistrationStatus.Ingetrokken` (terminal).
- `Registration.Withdraw()` — allowed from INGEDIEND or IN_BEHANDELING, needs no zaak, idempotent, and rejected once the registration has been decided (INGESCHREVEN/AFGEWEZEN).
- `WithdrawRegistration` application handler (load → withdraw → persist; repeated withdrawal is a no-op).
- `POST /registrations/{id}/withdraw` on the domain API.

Demoable: `POST /registrations/{id}/withdraw` → `GET /registrations/{id}` shows `INGETROKKEN`.

Refs #12 (not closing — see below).

## Scope / follow-ups

S-11 is bigger than one slice, so it is split (CLAUDE.md §13), like S-12 was:
- **S-11a (this PR)** — domain withdrawal transition + endpoint.
- **S-11b** — cancel the running Flowable process via a BPMN message event, so a withdrawn case leaves the behandelaar's werkbak.
- **S-11c** — owner-scoped BFF self-service withdraw endpoint + "trek aanvraag in" button + e2e.

Cancelling the Flowable process is deliberately deferred (documented in `WithdrawRegistration`), exactly as the beoordeling's rejection deferred its zaak propagation. #12 stays open until S-11c.

## Definition of Done

- [x] Linked Gitea issue (#12).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issue (`refs #12`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` unaffected (no infra/contract change).
- [x] Docs — none needed for this backend sub-slice; the user-visible demo note lands with S-11c.
- [x] No ADR needed — mirrors existing aggregate/handler/endpoint patterns; no boundary change.

## Notes for reviewers

- Verified locally: `Big.Tests` 89/89 pass; `Big.Api` builds clean.
- The domain trusts its callers (§8.3); owner-scoping by the caller's bsn is enforced at the BFF in S-11c.

Reviewed-on: #88
2026-07-16 09:15:12 +00:00
not 3abf8f7ccf feat(behandel): behandel-portal — werkbak + beoordeling (closes #13) (#87)
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m3s
CI / frontend (push) Successful in 2m30s
CI / mutation (push) Successful in 4m59s
CI / verify-stack (push) Successful in 7m5s
## What & why

Finishes **S-12 · Behandel-portal — werkbak + beoordeling**. The backend sub-slices (S-12a/b/c-1/c-2) were merged, but the slice's stated outcome — a behandel *portal* with medewerker login, a werkbak, and decide — had no frontend. This adds it.

- **`libs/auth`**: `MedewerkerAuthService` + `provideMedewerkerAuth` (Keycloak `medewerker` realm), a `roles`/`hasRole` surface on the shared `AuthService`, and a realm-roles protocol mapper so the SPA can read `behandelaar`/`teamlead` from the token. The BFF remains the security boundary (ADR-0013).
- **`apps/behandel`**: a new Nx Angular app mirroring self-service — medewerker OIDC login and a **werkbak** page listing registrations awaiting beoordeling (`GET /behandel/werkbak`) with per-row **Goedkeuren/Afwijzen** actions (`POST /behandel/registrations/{id}/decide`) that refresh the list. NL DS/Utrecht, standalone + signals.
- **e2e**: the walking-skeleton happy path now approves through the real portal (behandelaar logs in, finds the row by reference, clicks Goedkeuren) instead of the temporary admin endpoint.
- **infra/docs**: behandel service in compose (`:8142`, depends on Keycloak); added to the smoke `WAIT_SVCS` + CI log dump; `frontend-decisions.md` and `demo-script.md` updated.

Closes #13

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #13`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes. *(behandel image + container verified locally; full stack gated in CI.)*
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added — ADR-0013 (merged with the backend sub-slices) already covers the wiring; no new decision here.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- Verified locally: auth + behandel + all frontend projects pass lint & unit tests (incl. axe WCAG 2.1 AA); production build green; the behandel Docker image builds and serves with the correct baked `medewerker` config + SPA fallback.
- The full compose-up smoke, e2e, and mutation are CI-gated (known local full-stack verify limits).
- **Follow-ups (not in scope):** the `WerkbakItem` contract has no citizen name (werkbak shows the BSN) — adding one is a BFF+domain contract change; and the domain's temporary admin `approve` endpoint is now unused by the e2e and could be removed.

Reviewed-on: #87
2026-07-16 08:31:57 +00:00
not d226b6402d feat(#13): S-12c-2 — behandel decide → domain + complete workflow task (#86)
CI / lint (push) Successful in 1m17s
CI / build (push) Successful in 1m0s
CI / unit (push) Successful in 1m8s
CI / frontend (push) Successful in 2m20s
CI / mutation (push) Successful in 4m57s
CI / verify-stack (push) Successful in 7m3s
## What & why

Second half of **S-12c** (behandel-portal backend), completing the decision path per **ADR-0013**:

- **Domain:** `BeoordeelRegistratie` now, after applying the decision (aggregate + ACL for approval), **completes the open Flowable `Beoordelen` task** for that registration (found by registrationId) with the besluit, so the workflow advances. No open task → the decision still stands (completes nothing); idempotent.
- **BFF:** `POST /behandel/registrations/{id}/decide` behind the medewerker/`behandelaar` policy, forwarding `goedkeuren`/`afwijzen` to the domain. Validates the besluit vocabulary (400 on unknown) without troubling the domain.

Behavior: decide is **401** without a token, **403** without the role, **400** for an unknown besluit, **204** (forwarded) for a behandelaar.

This completes the behandel backend. **S-12d** (the Angular behandel-portal + Playwright e2e) closes umbrella #13 and retires the temporary `/approve`.

## Definition of Done

- [x] Linked issue: #13 (umbrella, `refs`)
- [x] Tests first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 79, bff 27, acceptance 9 (acl/event-subscriber unaffected)
- [x] Beoordeling acceptance scenario asserts task completion (goedkeuren + afwijzen)
- [x] openapi.json + api-client regenerated (drift guard passes)
- [x] Mutation ≥ break(90): **domain 100%, bff 100%**
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #86
2026-07-16 06:53:52 +00:00
not 9c3da48d8e feat(#13): S-12c-1 — behandel BFF auth + werkbak (ADR-0013) (#85)
CI / lint (push) Successful in 1m26s
CI / build (push) Successful in 1m19s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m37s
CI / mutation (push) Successful in 5m54s
CI / verify-stack (push) Successful in 7m18s
## What & why

First half of **S-12c** (behandel-portal backend), per **ADR-0013** (decisions recorded in #84):

- **BFF multi-realm auth.** A second JWT bearer scheme (`medewerker`) alongside the default `digid` scheme. On validation it lifts Keycloak's `realm_access.roles` onto the principal, and a `behandelaar` policy (medewerker scheme + `behandelaar` role) gates `/behandel/*`. Self-service keeps the digid scheme.
- **Werkbak = Flowable tasks.** The domain `Werkbak` query reads the open `Beoordelen` tasks (§8.2, S-12b's `IUserTaskClient`) and enriches each with its aggregate's bsn + status; `GET /behandel/werkbak` (domain) is proxied by the BFF `GET /behandel/werkbak` behind the behandelaar policy. The read projection stays the anonymous openbaar model (no premature `IN_BEHANDELING`/personal-data plumbing — deferred in ADR-0008).

Behavior: `/behandel/werkbak` is **401** without a token, **403** for a medewerker lacking the role, **200 + werkbak** for a behandelaar.

**S-12c-2** (next): `POST /behandel/registrations/{id}/decide` → domain decision + complete the Flowable task.

## Definition of Done

- [x] Linked issue: #13 (umbrella, `refs`); closes the adr-proposal #84
- [x] Tests first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 78, bff 23, acceptance 9 (+ acl/event-subscriber unaffected)
- [x] api-client `test` green; openapi.json regenerated (drift guard passes)
- [x] Mutation ≥ break(90): **domain 100%, bff 100%**
- [x] ADR-0013 added; `Keycloak__MedewerkerAuthority` wired into compose
- [ ] CI green (pending)

Part of #13. closes #84

Reviewed-on: #85
2026-07-15 09:54:01 +00:00
not 4085bdead7 feat(#13): S-12b — Workflow Client user-tasks + Beoordelen userTask (#83)
CI / lint (push) Successful in 1m23s
CI / build (push) Successful in 1m10s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m23s
CI / mutation (push) Successful in 5m45s
CI / verify-stack (push) Successful in 7m7s
## What & why

Second sub-slice of **S-12 (#13)** — the **Workflow Client gains behandelaar user-task operations**, and the process model gains the beoordeling step.

- **BPMN:** `registratie.bpmn` now parks at a `Beoordelen` **userTask** (candidate group `behandelaar`) after `OpenZaakAanmaken`; `registrationId` rides along as a process variable so the werkbak can correlate each task to its aggregate.
- **Workflow Client** (`IUserTaskClient`, the only code that talks to Flowable §8.2):
  - `GetOpenBeoordelingenAsync()` — the werkbak (open `Beoordelen` tasks + their `registrationId`)
  - `ClaimAsync(taskId, behandelaar)`
  - `CompleteBeoordelingAsync(taskId, besluit)` — carries the decision into the process as the `besluit` variable
- **Live integration:** `verify-domain` now drives the full user-task lifecycle against a real Flowable — after the worker opens the zaak, it polls for the task, claims it as `merel-behandelaar`, completes it (`goedkeuren`), and asserts the process finishes. This proves the exact REST contract (`service/runtime/tasks/query` + `…/{id}` claim/complete) the client depends on.

The walking skeleton is unaffected: the temporary `/approve` path still sets the zaak status directly; wiring the domain decision to *complete this task* (and driving the werkbak from the BFF) lands in **S-12c**.

## Definition of Done

- [x] Linked issue: #13 (umbrella; `refs`, does not close)
- [x] Tests first; red → green
- [x] Unit + acceptance green (`make unit`): domain 76, acceptance 9 (acl/event-subscriber/bff unaffected)
- [x] Mutation ≥ break(90): **domain 100%** (killed the new survivors *and* the pre-existing `FlowableWorkflowClient` baseline)
- [x] Live Flowable user-task lifecycle asserted in `verify-domain`
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #83
2026-07-15 08:53:33 +00:00
not d4ed0ffc22 feat(#13): S-12a — beoordeling decision model (domain) (#82)
CI / lint (push) Successful in 1m15s
CI / build (push) Successful in 58s
CI / unit (push) Successful in 1m9s
CI / frontend (push) Successful in 2m23s
CI / mutation (push) Successful in 5m3s
CI / verify-stack (push) Successful in 8m37s
## What & why

First sub-slice of **S-12 (#13)** — the **beoordeling decision model** in the Domain Service. Foundation for the behandel-portal: it gives the domain a proper decision lifecycle before any UI/Flowable/BFF work.

- **Statuses:** add `InBehandeling` and `Afgewezen` to `RegistrationStatus`.
- **Aggregate:** `TakeIntoBehandeling()` (`Ingediend → InBehandeling`, idempotent, guards terminal states); generalise the behandelaar decision — `Approve()` (requires a zaak) and new `Reject()` both act on an `Ingediend`/`InBehandeling` registration → `Ingeschreven`/`Afgewezen`.
- **Use-case:** `BeoordeelRegistratie` (`goedkeuren` sets the zaak's final status via the ACL §8.1 → `Ingeschreven`; `afwijzen` → `Afgewezen`, domain-only for now). Idempotent.
- **Endpoint:** `POST /registrations/{id}/decide` (`{ "besluit": "goedkeuren" | "afwijzen" }`), superseding the temporary `/approve` (retired when the portal lands, S-12d).
- **BDD:** `EenRegistratieBeoordelen.feature` — goedkeuren + afwijzen scenarios (feature-scoped bindings).

**Scoped out** to later S-12 sub-slices: Flowable user-task claim/complete + BPMN `userTask` (S-12b), BFF `/behandel/*` + medewerker authz (S-12c), the Angular behandel-portal + e2e (S-12d), and propagating a *rejection* to the zaak/projection via the ACL.

## Definition of Done

- [x] Linked issue: #13 (umbrella; this PR `refs`, does not close)
- [x] Tests first; red → green per behaviour
- [x] Unit + acceptance green (`make unit`): domain 65, acceptance 9
- [x] Mutation ≥ break(90): domain 98.77%, no survivors in new code (the one unkilled mutant is the pre-existing `FlowableWorkflowClient` baseline)
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #82
2026-07-15 07:12:19 +00:00
not 3023bb6fbe chore(release): 2026.07.0 (#81)
CI / lint (push) Successful in 1m28s
CI / build (push) Successful in 1m25s
CI / unit (push) Successful in 1m33s
CI / frontend (push) Successful in 2m58s
CI / mutation (push) Successful in 6m43s
CI / verify-stack (push) Successful in 7m42s
Cuts the first CalVer release **2026.07.0** (tag ), marking the end of **Iteration 1 — Walking Skeleton**.

 regenerated from Conventional Commits by git-cliff (covers Iterations 0 and 1, through #79).

After merge: tag  on main and publish the Gitea Release.

closes #80

Reviewed-on: #81
2026-07-14 14:46:55 +00:00
not 9997da8beb feat(#78): one citizen reference across self-service and the openbaar register (#79)
CI / lint (push) Successful in 1m25s
CI / build (push) Successful in 1m17s
CI / unit (push) Successful in 1m33s
CI / frontend (push) Successful in 2m54s
CI / mutation (push) Successful in 6m34s
CI / verify-stack (push) Successful in 7m39s
## What & why

Before this change the self-service confirmation and the openbaar register showed **different** identifiers, so a citizen could not look their registration back up (#78). Now both surface the same **reference**:

- **domain → ACL (write):** the domain `registrationId` is set as the zaak's `identificatie` on `POST /zaken`.
- **event-subscriber → ACL (read):** the subscriber reads the zaak's `identificatie` back through the ACL (§8.1 — only the ACL talks to ZGW) via a new `POST /zaken/reference`, and stores it on the projection row **and** the `processed_notifications` replay log.
- **BFF + openbaar:** the public view exposes `id/status/reference` (never bsn/naam) and searches by id or reference; the register's "Referentie" column shows the reference.

Storing the reference in the replay log keeps ADR-0008's **rebuild-is-log-only** invariant intact — `/admin/rebuild` reproduces the reference without re-reading the ACL.

Decision recorded in **ADR-0012**.

## Definition of Done

- [x] Linked issue: #78
- [x] Tests written first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 49, acl 27, bff 20, event-subscriber 19, acceptance 7
- [x] Frontend lint + test green (`nx run-many -t lint test`)
- [x] Mutation ≥ break(90): acl 100%, event-subscriber 100%, bff 100%, domain 98.41% (pre-existing FlowableWorkflowClient baseline, untouched)
- [x] e2e extended: confirmation reference == register reference
- [x] openapi.json + api-client regenerated (drift guard green)
- [x] ADR-0012 added; demo-script note appended
- [x] `Acl__BaseUrl` wired for the subscriber in compose

closes #78

Reviewed-on: #79
2026-07-14 14:01:49 +00:00
not 1c185e6686 S-09b: Approval flow — temp admin endpoint + status transition to projection (#77)
CI / lint (push) Successful in 1m25s
CI / build (push) Successful in 1m13s
CI / unit (push) Successful in 1m26s
CI / mutation (push) Successful in 6m6s
CI / verify-stack (push) Successful in 7m34s
CI / frontend (push) Successful in 2m35s
## What & why

S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar.

## Changes (bottom-up, each red→green TDD)

- **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`.
- **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test.
- **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status.
- **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN.
- **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note.

## Key decisions (see ADR-0011)

- **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant.
- **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+).

## Verification

- All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean.
- No new compose config (the eindstatus-discovery approach avoided it).
- The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally).

closes #75

Reviewed-on: #77
2026-07-14 09:04:57 +00:00
not bc9831c113 S-09: Openbaar Register portal — public lookup (#76)
CI / lint (push) Successful in 1m9s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m57s
CI / mutation (push) Successful in 5m19s
CI / verify-stack (push) Successful in 6m31s
Anonymous openbaar portal completing the walking skeleton (submit → projection → public visibility).

closes #10
2026-07-13 14:35:34 +00:00
not 7e8c5d7b51 Merge pull request 'ci: speed up pipeline — NuGet cache + prebuilt Playwright image' (#74) from chore/73-ci-speedups into main
CI / lint (push) Successful in 1m13s
CI / build (push) Successful in 57s
CI / unit (push) Successful in 1m4s
CI / mutation (push) Successful in 4m5s
CI / verify-stack (push) Successful in 6m22s
CI / frontend (push) Successful in 1m46s
Reviewed-on: #74
2026-07-13 13:59:15 +00:00
notandClaude Opus 4.8 2b9eb5eb41 ci(e2e): run Playwright from the prebuilt image instead of downloading browsers (refs #73)
CI / build (pull_request) Successful in 56s
CI / mutation (pull_request) Successful in 4m6s
CI / verify-stack (pull_request) Successful in 7m3s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 5m43s
The verify-e2e lane downloaded ~150 MB of Chromium (npx playwright install) on
every verify-stack run. Use the official mcr.microsoft.com/playwright image with
browsers pre-baked; npm install still pins @playwright/test from tests/e2e, and
the image tag is kept in lockstep with that version. Verified the exact
create + docker cp + start flow launches the baked browser with no download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
notandClaude Opus 4.8 60df0845aa ci: cache the NuGet package store across the .NET jobs (refs #73)
lint, build, unit and mutation each restored packages from the network on every
run. There are no lock files (so setup-dotnet's built-in cache doesn't apply), so
cache ~/.nuget/packages keyed on the project files via actions/cache. Pinned @v3
to avoid the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); the
cache is best-effort, so a miss simply restores from the network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
not 2a746736dc Merge pull request 'test(e2e): serve the portal + walking-skeleton Playwright e2e (closes #68)' (#72) from feat/68-e2e into main
CI / lint (push) Successful in 1m10s
CI / build (push) Successful in 54s
CI / unit (push) Successful in 1m0s
CI / frontend (push) Successful in 1m52s
CI / mutation (push) Successful in 4m11s
CI / verify-stack (push) Successful in 7m19s
Reviewed-on: #72
2026-07-13 13:20:57 +00:00
notandClaude Opus 4.8 986e36bc7d test(portal-self-service): guard that the DigiD token attaches to relative BFF calls (refs #68)
CI / lint (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 58s
CI / frontend (pull_request) Successful in 2m10s
CI / mutation (pull_request) Successful in 3m58s
CI / verify-stack (pull_request) Successful in 7m48s
The token-attachment bug (secureRoutes set to the app origin, which a relative
api-client URL never matches) was only caught by the full-stack e2e. Add a fast
unit guard: drive the REAL angular-auth-oidc-client interceptor and the REAL
api-client against the production route value, faking only the config source and
the token storage. Asserts the bearer token rides the relative /self-service/
call and is withheld from the anonymous /openbaar/ call.

Extract the value to a shared SECURE_API_ROUTES constant so the test binds to
exactly what the app configures. Verified the guard fails (Authorization null)
if the value regresses to an origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:00:32 +02:00
notandClaude Opus 4.8 7e152e4432 feat(portal-self-service): surface submit failures with a retryable alert (refs #68)
CI / build (pull_request) Successful in 54s
CI / lint (pull_request) Successful in 1m11s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m49s
CI / mutation (pull_request) Successful in 4m3s
CI / verify-stack (pull_request) Successful in 7m42s
Add an error branch to submit(): on a failed BFF call, set a `failed` signal,
re-enable the button, and render a role="alert" message so the user knows the
submit did not go through and can retry — instead of the click silently doing
nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:33:09 +02:00
notandClaude Opus 4.8 5bf25f094d test(portal-self-service): submit surfaces BFF failures instead of swallowing them (refs #68)
Failing test: when postSelfServiceRegistrations errors, the page should show an
alert, not the confirmation, and keep the submit button available for retry.
Currently submit() has no error handler, so the rejection is swallowed and the
page silently stays put — exactly the failure mode that hid the missing-token
bug behind a 90s e2e timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:32:30 +02:00
notandClaude Opus 4.8 0e6c7d2066 fix(portal-self-service): attach the DigiD token to relative BFF calls (refs #68)
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 1m3s
CI / verify-stack (pull_request) Successful in 7m59s
CI / frontend (pull_request) Successful in 1m48s
CI / mutation (pull_request) Successful in 3m57s
After login the submit silently did nothing: the confirmation ("...is
ontvangen...") never rendered because the POST to the BFF went out with no
Authorization header, so the BFF rejected it and the no-error-handler
subscribe left the page unchanged.

Root cause: angular-auth-oidc-client's interceptor attaches the token when
`req.url.startsWith(secureRoute)`. The api-client calls the BFF with RELATIVE
URLs (same-origin via the nginx proxy), so `req.url` is `/self-service/...` —
but secureRoutes was configured as the app ORIGIN (`http://self-service`),
which a relative URL never starts with. No match → no token.

Configure secureRoutes with the relative `/self-service/` prefix instead. The
unit test mocked the api-client, so only the walking-skeleton e2e exercises the
real token attachment — now green.

Verified against a focused stack (keycloak + self-service + real BFF + stub
domain): the submit now carries the bearer token, the BFF forwards to the
domain, and the portal shows the confirmation with the returned reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:09:15 +02:00
notandClaude Opus 4.8 39923e0e68 fix(e2e): treat the http portal origin as secure so DigiD PKCE login works (refs #68)
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m47s
CI / mutation (pull_request) Successful in 4m2s
CI / verify-stack (pull_request) Failing after 7m51s
The walking-skeleton e2e timed out waiting for the Keycloak login form
(`#username`). Root cause: in the compose network the portal is served over
plain HTTP on a non-localhost origin (http://self-service), which is not a
secure context, so Web Crypto (`crypto.subtle`) is undefined. angular-auth-
oidc-client needs SubtleCrypto to build the PKCE code challenge, so
`authorize()` threw ("Cannot read properties of undefined (reading 'digest')")
and the login redirect never fired.

Production serves the portal over HTTPS, where this works. Instead of
terminating TLS in the throwaway e2e stack, tell Chromium to treat the origin
as secure via --unsafely-treat-insecure-origin-as-secure. The flag is only
honoured by the full Chromium build (new headless), not Playwright's default
headless-shell, so pin channel: 'chromium'.

Verified against a minimal in-network stack (keycloak + self-service): login
redirect now reaches the Keycloak form, and the full login → token exchange →
authenticated portal renders with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:37:31 +02:00
notandClaude Opus 4.8 2e00ad38ba ci(portal-self-service): run Vitest ahead of the production build to stop worker-start timeout (refs #68)
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 2m0s
CI / mutation (pull_request) Successful in 4m5s
CI / verify-stack (pull_request) Failing after 10m20s
The frontend lane ran `nx run-many -t lint test build`, so the ~5min
self-service production build shared nx's task pool with the Vitest test
worker. @angular/build:unit-test's Vitest worker has hard-coded 60s/90s
startup timeouts (not configurable); on a CPU-constrained CI runner the
concurrent build starved the worker and it failed with "Timeout waiting
for worker to respond" — flaky, since it passed on the prior commit.

Split the target into a light lint+test phase and a separate build phase
so tests get CPU and the worker starts well inside its window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:51:56 +02:00
notandClaude Opus 4.8 be016f920c fix(portal-self-service): health-check nginx over IPv4 (127.0.0.1) (refs #68)
CI / lint (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 1m6s
CI / frontend (pull_request) Failing after 7m11s
CI / mutation (pull_request) Successful in 3m59s
CI / verify-stack (pull_request) Failing after 7m47s
nginx listens on IPv4 only (listen 80), but 'localhost' inside the container resolves
to ::1 first, so the wget healthcheck got connection-refused and self-service never
went healthy — timing out the CI stack bring-up. Probe 127.0.0.1 instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:37:56 +02:00
notandClaude Opus 4.8 d3f23a4da3 docs(portal-self-service): serving/e2e decisions + walking-skeleton demo note (refs #68)
CI / lint (pull_request) Successful in 1m5s
CI / unit (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 1m31s
CI / mutation (pull_request) Successful in 3m55s
CI / build (pull_request) Successful in 52s
CI / verify-stack (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:09:04 +02:00
notandClaude Opus 4.8 490e7347b0 test(e2e): walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
tests/e2e Playwright spec drives DigiD login (jan-burger/test123) → submit →
confirmation against the compose-served portal. run-e2e-check.sh runs it inside the
compose network (node container, browser installed at runtime) so the token issuer
(keycloak:8080) matches the BFF authority (ADR-0010). Wired as verify-e2e (Makefile +
verify chain + a verify-stack CI step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:08:10 +02:00
notandClaude Opus 4.8 4f311c9b5a ci(portal-self-service): serve the self-service app in compose (refs #68)
Add the self-service nginx service (build the app image, depends_on bff healthy +
keycloak started, health-checked, host port 8140). Add it to WAIT_SVCS and the CI
log dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:05:10 +02:00
notandClaude Opus 4.8 a55ba1160d feat(portal-self-service): runtime config + nginx serve/proxy image (refs #68)
The app loads /config.json at startup (main.ts) so the OIDC authority is set per
environment from one build; appConfig becomes a factory and derives redirectUrl +
secureApiOrigin from the app origin (same-origin as the BFF). A multi-stage
Dockerfile builds the app and serves it via nginx, reverse-proxying /self-service
+ /openbaar to the bff (relative URLs → no CORS); nginx resolves the BFF at request
time. The compose image bakes config.json with the keycloak:8080 authority so the
browser's token issuer matches the BFF (ADR-0010).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:03:59 +02:00
not 4416d1f4ed Merge pull request 'feat(portal-self-service): NL DS + DigiD self-service submit form (closes #67)' (#71) from feat/67-self-service-form into main
CI / lint (push) Successful in 1m4s
CI / build (push) Successful in 51s
CI / unit (push) Successful in 1m1s
CI / frontend (push) Successful in 1m31s
CI / mutation (push) Successful in 3m56s
CI / verify-stack (push) Successful in 5m29s
Reviewed-on: #71
2026-07-01 11:52:32 +00:00
notandClaude Opus 4.8 074101e836 fix(portal-self-service): run checkAuth() at startup to end the login redirect loop (refs #67)
CI / lint (pull_request) Successful in 1m6s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m56s
CI / mutation (pull_request) Successful in 3m55s
CI / verify-stack (pull_request) Successful in 4m34s
Without an app-init auth check, the DigiD callback (?code=…) was never processed, so
the guard kept seeing 'not authenticated' and re-triggered login — an infinite
redirect loop. Add withAppInitializerAuthCheck() so checkAuth() runs before the router
and guard, establishing the session on the callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:26:42 +02:00
notandClaude Opus 4.8 5089c2aea6 fix(portal-self-service): re-export the full Utrecht package from libs/ui (refs #67)
CI / frontend (pull_request) Successful in 1m30s
CI / lint (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 59s
CI / mutation (pull_request) Successful in 3m54s
CI / verify-stack (pull_request) Has been cancelled
Importing UtrechtComponentsModule pulls every component it exports into the AOT
compiler scope, so all must be resolvable through the ui barrel; a partial
re-export failed a fresh build with NG3004 (masked locally by the Nx build cache,
surfaced by nx serve / a --skip-nx-cache build). Re-export the whole package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:23:17 +02:00
notandClaude Opus 4.8 29f3dcc6cf docs(portal-self-service): record NL DS + DigiD decisions and demo note (refs #67)
CI / lint (pull_request) Successful in 1m6s
CI / build (pull_request) Successful in 50s
CI / unit (pull_request) Successful in 1m0s
CI / verify-stack (pull_request) Successful in 5m46s
CI / frontend (pull_request) Successful in 1m27s
CI / mutation (pull_request) Successful in 3m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:13:04 +02:00
notandClaude Opus 4.8 2c196245c2 feat(portal-self-service): implement the DigiD registration submit page (refs #67)
RegistrationPage shows the signed-in BSN and submits to the BFF via the generated
api-client, confirming with the returned reference; built from NL Design System
(Utrecht) components. Wire the guarded route + app providers (DigiD OIDC + token
interceptor + HttpClient), the NL DS theme, and lang=nl. Component tests
(Testing Library) + axe (WCAG 2.1 AA) pass; a guard test covers libs/auth. Replace
the demo eslint depConstraints (scope:shop/shared) with a permissive default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:12:09 +02:00
notandClaude Opus 4.8 72c2bdfae7 test(portal-self-service): DigiD-guarded registration submit page (refs #67)
Scaffold libs/ui (NL Design System via Utrecht components) and libs/auth (DigiD OIDC
over angular-auth-oidc-client: mockable AuthService, provider, token interceptor,
authenticated guard). Failing component + axe tests for the RegistrationPage: it must
show the signed-in BSN, submit to the BFF (mocked api-client) and confirm, with no
WCAG 2.1 AA violations. The page is a stub, so the behaviour tests fail; green follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:02:29 +02:00
not 311aab0aba Merge pull request 'feat(api-client): generated BFF client library (closes #66)' (#70) from feat/66-api-client into main
CI / lint (push) Successful in 1m4s
CI / build (push) Successful in 48s
CI / unit (push) Successful in 54s
CI / frontend (push) Successful in 1m16s
CI / mutation (push) Successful in 3m48s
CI / verify-stack (push) Successful in 5m46s
Reviewed-on: #70
2026-07-01 10:51:01 +00:00
notandClaude Opus 4.8 fcdb117768 docs(api-client): record the orval generator choice (refs #66)
CI / lint (pull_request) Successful in 1m4s
CI / build (pull_request) Successful in 49s
CI / unit (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 1m17s
CI / mutation (pull_request) Successful in 3m48s
CI / verify-stack (pull_request) Successful in 5m49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:32:42 +02:00
notandClaude Opus 4.8 c3f0710a18 feat(api-client): expose the generated BFF client + repeatable generate target (refs #66)
The lib barrel exports the generated BffApiV1Service + models (SubmitAccepted,
OpenbaarEntry), so the app can inject a typed client for the BFF. Add an
'api-client:generate' target (orval) to regenerate from services/bff/openapi.json;
generation is idempotent. Tests (HttpClientTesting) now pass: POST /self-service/
registrations and GET /openbaar/register with the query, mapping typed responses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:32:07 +02:00
notandClaude Opus 4.8 7c363099ff test(api-client): generated BFF client is exposed and calls the endpoints (refs #66)
Scaffold libs/api-client (Nx Angular lib) and generate a typed HttpClient client
from services/bff/openapi.json with orval (node-based; Angular target integrates
with HttpClient interceptors for the S-08c auth token). A failing test drives the
public API: it expects an injectable BffApiV1Service to POST /self-service/registrations
and GET /openbaar/register (via HttpClientTesting), but the lib barrel doesn't export
the client yet, so it fails. Normalise the vitest target to 'test'. Green exposes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:29:57 +02:00
not a069ab07a2 Merge pull request 'feat(portal-self-service): Nx workspace + self-service app scaffold (closes #65)' (#69) from feat/65-nx-workspace into main
CI / lint (push) Successful in 1m7s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m0s
CI / frontend (push) Successful in 1m10s
CI / mutation (push) Successful in 3m52s
CI / verify-stack (push) Successful in 5m37s
Reviewed-on: #69
2026-07-01 10:22:53 +00:00
notandClaude Opus 4.8 34969659f7 fix(portal-self-service): keep dotnet format green under the shared .editorconfig (refs #65)
CI / lint (pull_request) Successful in 1m6s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 59s
CI / frontend (pull_request) Successful in 2m27s
CI / verify-stack (pull_request) Successful in 5m43s
CI / mutation (pull_request) Successful in 3m55s
The imported Nx .editorconfig applied a global 2-space indent + charset=utf-8 to
all files, so dotnet format flagged every 4-space C# line and the BOM'd EF migration.
Scope it: [*.cs] keeps 4-space, and the global charset rule is dropped (utf-8 is the
default; the BOM'd generated migration is left alone). Frontend files stay 2-space.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:05:45 +02:00
notandClaude Opus 4.8 fd90c4abe2 docs(portal-self-service): frontend-decisions + demo note for S-08a (refs #65)
CI / lint (pull_request) Failing after 1m8s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m5s
CI / mutation (pull_request) Has been cancelled
CI / verify-stack (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Record the workspace/tooling decisions (pnpm, Nx scoped to apps/+libs/, Vitest,
no @nx/docker, no Nx Cloud, Gitea-only) and a demo note for running the placeholder
app. NL DS deferred to S-08c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:58:02 +02:00
notandClaude Opus 4.8 3824f85af6 ci(portal-self-service): Nx frontend lane (lint/test/build) (refs #65)
Add a make frontend target (pnpm install --frozen-lockfile + nx run-many -t lint
test build) and a CI 'frontend' job (pnpm + Node 24, pinned action URLs). Wire
frontend into make ci. The .NET lanes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:56:59 +02:00
notandClaude Opus 4.8 ef877ebc80 feat(portal-self-service): self-service portal placeholder page (refs #65)
Replace the generated Nx welcome page with a minimal self-service placeholder
(Dutch 'Zelfservice — BIG-registratie' heading + router-outlet); drop nx-welcome.
The login + submit form arrive in S-08c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:55:46 +02:00
notandClaude Opus 4.8 9c961f9a13 test(portal-self-service): self-service portal placeholder renders (refs #65)
Bootstrap the Nx (pnpm) workspace at the repo root with the self-service Angular
app (standalone + signals, Vitest via @angular/build, ESLint) — the frontend
foundation. Nx is scoped to apps/+libs/ only; the .NET services stay on
dotnet/Makefile (no @nx/docker inference). A failing test asserts the app renders
a 'Zelfservice' heading; it still shows the generated Nx welcome page, so it fails.
Green commit implements the placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:54:48 +02:00
notandClaude Opus 4.8 0b82841b14 docs(backlog): split S-08 into S-08a-d (refs #65)
S-08 (#9) bundled the Nx bootstrap, generated client, NL DS + DigiD form and a
full-stack Playwright e2e — past the 1-2 day line (CLAUDE.md §13). Closed #9 in
favour of #65 (Nx workspace + CI lane), #66 (api-client), #67 (submit form + a11y),
#68 (Playwright e2e + compose serving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:37:47 +02:00
not 5a4331a416 Merge pull request 'feat(bff): BFF with one endpoint per portal + OIDC validation (closes #8)' (#64) from feat/8-bff into main
CI / lint (push) Successful in 1m7s
CI / build (push) Successful in 54s
CI / unit (push) Successful in 56s
CI / mutation (push) Successful in 3m52s
CI / verify-stack (push) Successful in 5m46s
Reviewed-on: #64
2026-07-01 09:32:44 +00:00
notandClaude Opus 4.8 96d447832f docs(bff): demo note for the BFF front door (S-07) (refs #8)
CI / build (pull_request) Successful in 58s
CI / lint (pull_request) Successful in 1m7s
CI / unit (pull_request) Successful in 1m2s
CI / mutation (pull_request) Successful in 3m47s
CI / verify-stack (pull_request) Successful in 5m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:15:39 +02:00
notandClaude Opus 4.8 a07d8277d6 ci(bff): compose wiring, verify-bff live check, mutation baseline (refs #8)
Wire the bff service in compose (Keycloak authority + downstream domain/projection
URLs, depends_on domain/projection healthy + keycloak started). run-bff-check.sh
verifies the BFF end-to-end against the up stack: 401 without a token, 202 with a
real digid token minted via direct grant against keycloak:8080 (host-consistent
issuer, ADR-0010), and an anonymous public-safe openbaar register (never a bsn).
Wired as verify-bff (Makefile + verify chain + CI step). Stryker baseline for the
BFF's pure logic (OpenbaarProjection) at 100% (break 90); Program/HTTP adapters are
covered by the endpoint tests + verify-bff. CI uploads the bff mutation report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:15:05 +02:00
notandClaude Opus 4.8 69d6e80378 feat(bff): committed OpenAPI contract + drift guard (refs #8)
Document typed responses (202 SubmitAccepted / 400 / 401 on self-service; 200
OpenbaarEntry[] on openbaar) so the generated spec carries real schemas for S-08's
client. A document transformer clears the auto-populated servers block so the spec
is host-independent and deterministic. Commit services/bff/openapi.json and add a
test asserting it matches the served /openapi/v1.json (fails on drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:07:55 +02:00
notandClaude Opus 4.8 5d32d4f15e test(bff): acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
Use-case-level BDD (Reqnroll) driving the real BFF over HTTP with fake downstreams
and locally-minted tokens: a valid DigiD token is accepted and the bsn forwarded
to the domain; a tokenless submit is 401; the openbaar register is anonymous and
never exposes the bsn (ADR-0010). Real Keycloak validation is the verify-bff check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:03:37 +02:00
notandClaude Opus 4.8 d767430ad7 feat(bff): implement self-service submit and openbaar lookup (refs #8)
POST /self-service/registrations requires a valid digid JWT, reads the bsn claim
and forwards it to the domain, returning 202. GET /openbaar/register is anonymous
and returns OpenbaarProjection.PublicView — rows filtered by q and mapped to the
public-safe id+status only (bsn/naam never exposed). JwtBearer validates
signature/issuer/expiry against the Keycloak digid authority (§8.3, ADR-0010).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:00:56 +02:00
notandClaude Opus 4.8 751ca006a7 test(bff): endpoints, JWT auth and public-safe projection (refs #8)
Failing tests for the BFF walking-skeleton endpoints:
- POST /self-service/registrations rejects missing/malformed/wrong-key/expired
  tokens (401) and, with a valid digid token, forwards the bsn to the domain and
  returns 202 (WebApplicationFactory + a local test signing key, ADR-0010).
- GET /openbaar/register serves public-safe rows anonymously (never the bsn) and
  filters by q.
- OpenbaarProjection.PublicView (pure) filters by id and maps to id+status only.

Endpoints and PublicView are stubs so the tests compile and fail on their
assertions; the green commit implements them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:59:55 +02:00
notandClaude Opus 4.8 fea806848b arch(bff): ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
The BFF is the portals' only backend (§8.3): it validates Keycloak digid-realm
JWTs on POST /self-service/registrations (extracting bsn → domain), leaves
GET /openbaar/register anonymous (public lookup, S-09), and fans out to the
domain and projection over typed HTTP clients. Tests mint tokens with a test
signing key; real Keycloak validation is a live-stack verify-bff check. Records
the container OIDC issuer-mismatch wrinkle. OpenAPI is generated + committed for
the S-08 client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:51:59 +02:00
not 2f5d656b54 Merge pull request 'feat(domain): BIG Domain Service skeleton with the Registration aggregate (closes #6)' (#61) from feat/6-domain-service into main
CI / lint (push) Successful in 1m4s
CI / build (push) Successful in 50s
CI / unit (push) Successful in 52s
CI / mutation (push) Successful in 3m14s
CI / verify-stack (push) Successful in 5m42s
Reviewed-on: #61
2026-07-01 08:46:21 +00:00
notandClaude Opus 4.8 72efab3ae0 ci: retrigger CI after gitea restart (refs #6)
CI / lint (pull_request) Successful in 1m36s
CI / build (pull_request) Successful in 50s
CI / unit (pull_request) Successful in 57s
CI / mutation (pull_request) Successful in 3m36s
CI / verify-stack (pull_request) Successful in 5m47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:09:02 +02:00
notandClaude Opus 4.8 1edd34e2db ci: retrigger CI (refs #6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:04:55 +02:00
notandClaude Opus 4.8 f885e0a3be ci: retrigger after runner cleanup (refs #6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:03:14 +02:00
notandClaude Opus 4.8 ac874bf746 ci(mutation): make Stryker report upload best-effort (refs #62)
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 55s
CI / mutation (pull_request) Successful in 3m16s
CI / lint (pull_request) Failing after 14m2s
CI / verify-stack (pull_request) Successful in 6m12s
The Gitea artifact backend returns 500 to actions/upload-artifact@v3 (server-side,
distinct from the @v4 GHES guard). With if: always() that 500 failed the whole
mutation job even though the ratchet passed — red on main and on every PR. Mark the
three report uploads continue-on-error: true so the mutation *gate* stays the Stryker
ratchet (make mutation's exit code), not the report upload. Documented in
gitea-actions-gotchas.md §4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:32:16 +02:00
notandClaude Opus 4.8 67f0ffb88d docs(domain): demo note for submitting a registration (S-05) (refs #6)
CI / lint (pull_request) Successful in 1m4s
CI / build (pull_request) Successful in 48s
CI / unit (pull_request) Successful in 58s
CI / mutation (pull_request) Failing after 17m2s
CI / verify-stack (pull_request) Successful in 6m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:32:29 +02:00
notandClaude Opus 4.8 5a3f28ac6d ci(domain): containerize, wire into compose, and verify end-to-end (refs #6)
Dockerfile (multi-stage, .NET 10) + .dockerignore for the BIG Domain Service; a
'domain' service in infra/docker-compose.yml (health-checked, depends on acl healthy
and flowable-init completed). run-domain-check.sh drives the full path against the up
stack — seed a published zaaktype, recreate the acl pointed at it (host-consistent),
POST /registrations, and assert the worker opens a zaak and records it. Wired as the
verify-domain Makefile target + a verify-stack CI step; domain added to WAIT_SVCS and
the log dump. seed_catalogus.py now emits a machine-readable ZAAKTYPE_URL line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:31:45 +02:00
notandClaude Opus 4.8 e9a873c152 test(domain): mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
Stryker.NET config for the domain service (break 90, the repo's ratchet floor),
excluding the OpenZaakJobPump hosted-shell from mutation. Hardened the unit tests
to kill survivors — Basic-credential value, variable types, null/failure response
paths, option defaults, guard clauses, save counts and log output — leaving only
two documented equivalent mutants (Stryker-disabled). make mutation runs the domain
ratchet and CI uploads its report alongside the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:24:56 +02:00
notandClaude Opus 4.8 79dcd8f14b test(domain): acceptance scenario for submitting a registration (refs #6)
Use-case-level BDD (Reqnroll) for S-05: a zorgprofessional submits a registration;
the Domain Service starts the registratie process and the OpenZaakAanmaken external
task opens a zaak via the ACL, recorded on the aggregate (ADR-0009). Driven against
in-memory Workflow Client and ACL stand-ins; real Flowable+ACL+OpenZaak delivery is
the live-stack verify-domain check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:15:23 +02:00
notandClaude Opus 4.8 22ab38f328 feat(domain): expose POST /registrations and the read endpoint (refs #6)
The BIG Domain Service Api wires the use cases and the hosted job worker:
POST /registrations creates the aggregate and starts the registratie process,
returning 202 with a location; GET /registrations/{id} reads the aggregate so
the eventually-opened zaak URL can be observed (ADR-0009). The Workflow Client
is registered once behind both Flowable ports; the ACL client and in-memory
store complete the wiring. Verified against a live flowable-rest: submit starts
a parked process and the worker polls it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:13:27 +02:00
notandClaude Opus 4.8 0d34d60797 feat(domain): implement the Flowable Workflow Client and ACL client (refs #6)
FlowableWorkflowClient speaks flowable-rest's REST API (Basic auth): start a
registratie process with the registrationId variable, acquire OpenZaakAanmaken
external-worker jobs and parse their registrationId, complete a job with the
zaakUrl variable — the contract verified against a live engine. AclHttpClient
POSTs the bsn to the ACL and returns the zaak URL. InMemoryRegistrationStore is
a concurrent-dictionary upsert. OpenZaakJobProcessor drains parked jobs, opening
a zaak per job and completing it, leaving failures for redelivery; OpenZaakJobPump
is the hosted polling shell that drives it on an interval (ADR-0009).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:10:21 +02:00
notandClaude Opus 4.8 6d4adaf957 test(domain): Workflow Client, ACL client, store and job processor (refs #6)
Failing infrastructure unit tests (stub HttpMessageHandler, fakes):
- FlowableWorkflowClient starts a process with the registrationId variable and
  returns the instance id; acquires OpenZaakAanmaken jobs (topic/workerId/lock)
  and parses their registrationId; completes a job with the zaakUrl variable —
  request URIs match flowable-rest's service/ and external-job-api/ paths.
- AclHttpClient POSTs the bsn to the ACL and returns the zaak URL.
- InMemoryRegistrationStore saves/reads/upserts by id.
- OpenZaakJobProcessor acquires, opens a zaak, completes the job; leaves a failing
  job uncompleted for redelivery; polls harmlessly when idle.

Adapters are stubs so the tests compile and fail on their assertions; the green
commit implements them against the REST contract verified on a live Flowable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:08:56 +02:00
notandClaude Opus 4.8 39b2388a9d feat(domain): implement SubmitRegistration and OpenZaakWorker (refs #6)
SubmitRegistration creates the aggregate, persists it, starts the registratie
process via the Workflow Client, records the instance id and upserts. OpenZaakWorker
loads the correlated registration, opens a zaak via the ACL, attaches it and saves;
an unknown registration throws (job redelivered), and an already-opened zaak short-
circuits without opening a second one (§8.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:00:05 +02:00
notandClaude Opus 4.8 8d176c2603 test(domain): SubmitRegistration + OpenZaakWorker use cases (refs #6)
Failing application-layer tests over fake ports (IWorkflowClient, IAclClient,
IRegistrationStore):
- Submit persists an INGEDIEND registration and starts the registratie process,
  recording the instance id — and persists *before* starting, so the worker can
  correlate the OpenZaakAanmaken job back to its aggregate (ADR-0009).
- The worker opens a zaak via the ACL and attaches it; an unknown registration
  throws (job left for redelivery); a redelivered job is idempotent and opens no
  second zaak (§8.6).

Handlers are stubs (no persistence / no ACL call) so the tests compile and fail
on their assertions; the green commit implements them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:59:27 +02:00
notandClaude Opus 4.8 53751fd1bc feat(domain): implement the Registration aggregate invariants (refs #6)
Submit requires a bsn and starts the aggregate in INGEDIEND; the started
process-instance id is recorded; AttachZaak stores the ACL's zaak URL,
tolerating a duplicate (at-least-once worker delivery) and rejecting a
conflicting URL, without advancing the status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:56:31 +02:00
notandClaude Opus 4.8 cc9e7852e1 test(domain): Registration aggregate invariants (refs #6)
Failing unit tests for the Registration aggregate root: a submission starts
in INGEDIEND carrying its bsn, an empty/whitespace/null bsn is rejected, the
started process-instance id is remembered, and attaching the zaak the ACL
opened records its URL idempotently (a conflicting URL is rejected) while the
status stays INGEDIEND.

The aggregate is a stub (no-op mutators, empty bsn) so the tests compile and
fail on their assertions; the green commit implements the invariants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:54:59 +02:00
notandClaude Opus 4.8 c9edf27a48 arch(domain): ADR-0009 external-task job-worker pattern (refs #6, #60)
The Domain Service drives the OpenZaakAanmaken external-worker task as a
hosted job worker (PRD §36): POST /registrations starts the registratie
process and returns; a polling worker acquires the job, opens a zaak via
the ACL (§8.1), attaches the zaak URL to the aggregate, and completes the
job. The Workflow Client is the only Flowable client (§8.2); the worker
logic is an Application service over ports. Registration state is in-memory
for the minimal slice (the read path is the projection, S-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:51:40 +02:00
not c3ccffe417 Merge pull request 'feat(event-subscriber): NRC event subscriber + rebuildable read projection (closes #7)' (#59) from feat/7-event-subscriber-projection into main
CI / build (push) Successful in 46s
CI / mutation (push) Failing after 11m57s
CI / lint (push) Successful in 1m0s
CI / unit (push) Successful in 53s
CI / verify-stack (push) Successful in 4m57s
Reviewed-on: #59
2026-06-30 13:56:59 +00:00
notandClaude Opus 4.8 0d0778036e docs(arch): ADR-0008 read projection store + demo note for the event path (refs #7)
CI / lint (pull_request) Successful in 1m0s
CI / build (pull_request) Successful in 45s
CI / unit (pull_request) Successful in 54s
CI / mutation (pull_request) Successful in 2m16s
CI / verify-stack (pull_request) Successful in 5m34s
ADR-0008 records the read-projection design: one rebuildable store shared by the Event
Subscriber (writer) and projection-api (reader) as one CQRS bounded context (reconciled
with §8.5), idempotency + rebuild from the notification log (no OpenZaak access, §8.1),
the deferred bsn/naam, and the new EF Core + Npgsql dependency. Add a demo-script entry
walking the OZ→NRC→subscriber→projection-api path and wire both into the MkDocs nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:36 +02:00
notandClaude Opus 4.8 fa8382fc02 ci(infra): run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
Add projection-db + the two services to both compose files (host ports 8110/8120), their
Dockerfiles (repo-root context — they share Projection.ReadModel), and a runner-safe
verify-projection check (infra/run-projection-check.sh) that registers the abonnement at the
real subscriber, creates a zaak and asserts projection-api serves an INGEDIEND row. Wire it
into make (verify-projection, verify, WAIT_SVCS) and the CI verify-stack job, and run the
event-subscriber Stryker ratchet in `make mutation` + upload its report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:24 +02:00
notandClaude Opus 4.8 06d8d13e19 feat(event-subscriber): enforce the callback bearer before reading the body (refs #7)
Check the Authorization header before deserializing the notification, and read/parse the
body manually. NRC probes a new abonnement's callback with a request that has neither the
configured auth nor a valid notification body, and refuses to register unless it gets a 401
(not a 400) — ADR-0007. Mirrors the verify harness's sink contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:14 +02:00
notandClaude Opus 4.8 a111e5cc20 test(event-subscriber): ratchet projector mutation baseline to 100% (refs #7)
Sharpen the projector tests so Stryker has no survivors (was 75%): assert a replayed
delivery never reaches the store (upsert count, not just row count), that two distinct
zaken get distinct rows (pins the idempotency key), that rebuild clears stale rows, and
a Theory over wrong kanaal/resource/actie combinations (pins the zaken/zaak/create guard).
Add the per-service Stryker config + solution; break threshold 90 (CLAUDE.md §5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:06 +02:00
notandClaude Opus 4.8 7ef63c7ae9 feat(projection): persist the read projection and expose webhook + read APIs (refs #7)
Add the projection persistence and the two services around it:

- Projection.ReadModel: a shared EF Core (Npgsql) read model owning the projection
  schema — register_projection + the subscriber's processed_notifications log — plus
  EfProjectionStore / EfNotificationLog (atomic record-or-skip on the PK for idempotency)
  and the initial migration. One rebuildable store, written by the subscriber and read
  by projection-api (ADR-0008).
- EventSubscriber.Api: POST /notifications NRC callback (enforces the abonnement bearer,
  401 without it per ADR-0007), POST /admin/rebuild, /health. Migrates on start.
- ProjectionApi.Api: GET /register, GET /register/{id}, /health — the read side.

dotnet-ef pinned as a local tool for migrations; NuGetAuditMode=direct so EF's
design-time-only tooling transitive doesn't flag the shipped build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:55:04 +02:00
notandClaude Opus 4.8 017cd5e66b feat(event-subscriber): project zaak-created notifications into the read projection (refs #7)
Implement NotificationProjector: a zaken/zaak/create notification records the delivery
in the notification log (atomic record-or-skip for idempotency, §8.6) and upserts an
INGEDIEND projection row keyed by zaak id; other channels/actions are ignored. Rebuild
clears the projection and replays the log — no OpenZaak access needed (§8.1). bsn/naam
are deferred (ADR-0008).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:48:08 +02:00
notandClaude Opus 4.8 c70840e5b7 test(event-subscriber): project zaak-created notifications into the read projection (refs #7)
Failing unit + acceptance tests for the Event Subscriber's NotificationProjector:
a zaken/zaak/create notification yields one INGEDIEND projection row, duplicate
deliveries collapse to one row, non-zaak/non-create notifications are ignored, and
a rebuild repopulates the projection from the durable notification log (PRD §8.4).

The projector is a no-op stub so the tests compile and fail on the assertions; the
implementation follows in the green commit. The notification log doubles as the
idempotency guard and rebuild source so a rebuild needs no OpenZaak access (§8.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:47:16 +02:00
not 32c98f00db Merge pull request 'feat(infra): wire OpenZaak → Open Notificaties notifications (closes #56)' (#57) from feat/56-nrc-notification-wiring into main
CI / lint (push) Successful in 52s
CI / build (push) Successful in 40s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m35s
CI / verify-stack (push) Successful in 4m44s
Reviewed-on: #57
2026-06-30 12:29:43 +00:00
notandClaude Opus 4.8 d49443353e refactor(ci): one verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 48s
CI / mutation (pull_request) Successful in 1m36s
CI / verify-stack (pull_request) Successful in 4m37s
On the single self-hosted runner CI jobs run sequentially, so booting OpenZaak once
beats once-per-job. Replace the integration + notifications + compose-smoke jobs with
one verify-stack job that brings the full stack up once and runs, as clearly-named
steps: health (make verify-up, the DoD smoke) → ACL ↔ OpenZaak (verify-acl) →
OpenZaak → NRC delivery (verify-nrc) → teardown (always) + log dump on failure.

The check logic moves into stack-agnostic runners (run-acl-integration.sh,
run-notification-check.sh) that operate on whatever stack is already up, reaching
services by container IP. The local single-concern wrappers (make integration oz-only,
make verify-notifications oz+nrc) keep working by delegating to the same runners, so
nothing is duplicated. make ci now runs the consolidated 'verify' stage.

Verified locally: make verify boots the full stack once, ACL integration passes and
the NRC notification is delivered, then tears down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:48:38 +02:00
notandClaude Opus 4.8 a256db1a23 arch(infra): ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 49s
CI / mutation (pull_request) Successful in 1m35s
CI / integration (pull_request) Successful in 3m24s
CI / notifications (pull_request) Successful in 3m14s
CI / compose-smoke (pull_request) Successful in 4m2s
Records the wiring decision (AC-delegated auth, required celery-beat) and the two
non-obvious gotchas: single-label hosts aren't URL-valid (reach services by IP) and
abonnement callbacks must enforce auth. Documents the new notifications CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
notandClaude Opus 4.8 4d07285dcd test(infra): verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
make verify-notifications brings the stack up, seeds a published BIG zaaktype, and
asserts a zaak-create notification is delivered to a webhook-sink abonnement. The
sink + driver run as containers inside the compose network and reach OpenZaak/NRC by
container IP (the runner can't reach published ports, and a single-label host isn't
URL-valid). The sink enforces a bearer token because NRC refuses an unauthenticated
callback. New 'notifications' Gitea Actions job runs it (Docker-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
notandClaude Opus 4.8 f3e9db7147 feat(infra): wire OpenZaak → Open Notificaties notifications (refs #56)
Completes the S-01-c wiring so a zaak created in OpenZaak is published to NRC:

- OpenZaak: a zgw_consumers 'nrc' service + notifications_config (setup_configuration),
  publishing as big-reference-seed. NOTIFICATIONS_DISABLED stays true for OpenZaak-only
  bring-ups (OZ_NOTIFICATIONS_DISABLED) so the ACL integration test doesn't 500; the
  full/local stacks and stack-up set it false.
- NRC: the JWT credential, an 'ac' service + autorisaties_api delegation to OpenZaak's
  Autorisaties API, and the 'zaken' kanaal. nrc-init now runs setup_configuration; its
  data.yaml is delivered via the rr-nrc-config volume (seed-config.sh nrc), mirroring oz.
- nrc-beat added to every stack: NRC accepts a notification then drains it via a
  scheduled execute_notifications task — without beat, nothing is delivered. Interval 5s.

Applied across the standalone, full, and local-bind-mount composes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
not 86cc65f4d9 Merge pull request 'test(acl): ACL integration test against real OpenZaak (closes #46)' (#54) from test/46-acl-openzaak-integration into main
CI / build (push) Successful in 40s
CI / lint (push) Successful in 50s
CI / mutation (push) Successful in 1m37s
CI / integration (push) Successful in 3m28s
CI / compose-smoke (push) Successful in 3m53s
CI / unit (push) Successful in 48s
Reviewed-on: #54
2026-06-29 10:48:00 +00:00
notandClaude Opus 4.8 4474585606 ci(acl): run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 47s
CI / mutation (pull_request) Successful in 1m31s
CI / integration (pull_request) Successful in 3m43s
CI / compose-smoke (pull_request) Successful in 4m1s
The hosted runner can't reach the stack's published ports (sibling containers),
so run the seed and the test as containers joined to the OpenZaak network,
reaching it by container IP — a single-label host like 'openzaak' isn't URL-valid
for OpenZaak's own URLValidator, but an IPv4 literal is. Code is delivered via
image build / docker cp (bind mounts don't reach the daemon either).

- infra/run-integration.sh: up -> wait healthy (docker inspect) -> seed published
  zaaktype (python container on the net) -> build + run the test image on the net
  -> always tear down. Plain docker primitives only (portable docker/podman).
- services/acl/Dockerfile.integration: builds + runs Acl.IntegrationTests; dotnet
  lives in the image, so the CI job needs only Docker (no setup-dotnet).
- make integration now delegates to the script; re-added the Gitea Actions job.

Supersedes the local-only gap documented earlier; #55 is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:28:43 +02:00
notandClaude Opus 4.8 3829cb0b68 ci(acl): keep the integration lane local-only; document the runner gap (refs #46)
CI / lint (pull_request) Successful in 49s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 50s
CI / mutation (pull_request) Successful in 1m31s
CI / compose-smoke (pull_request) Successful in 3m54s
The hosted Gitea runner starts the OpenZaak stack as sibling containers via the
host daemon, so a process on the runner can't reach the published ports — the seed
and dotnet test get Connection refused on localhost:8000. Drop the (non-working)
integration CI job; make integration stays the local / host-runner gate. Document
the limitation in gitea-actions-gotchas.md §5 and the CI runbook, and track running
it inside the compose network in #55. ADR-0006 updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:04:44 +02:00
notandClaude Opus 4.8 855a5565fe ci(acl): run the ACL integration test as a Gitea Actions job (refs #46)
CI / integration (pull_request) Failing after 5m16s
CI / lint (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 46s
CI / mutation (pull_request) Successful in 1m37s
CI / build (pull_request) Successful in 41s
CI / compose-smoke (pull_request) Successful in 4m11s
New integration job: setup-dotnet + make integration (stack up, OZ_PUBLISH=1 seed,
Integration-category tests, tear down), with on-failure log dump + teardown like
compose-smoke. Documents the job and the new make target in the CI runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:38 +02:00
notandClaude Opus 4.8 09de500fb8 arch(acl): ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
Records why the integration test targets the running compose stack rather than a
Testcontainers graph (no .NET compose support; not hermetic anyway due to the
Selectielijst dependency), the opt-in publish seed, and the chunked-body bug the
test caught. Proposed in #53.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:38 +02:00
notandClaude Opus 4.8 4322c607cb fix(acl): buffer the zaak POST body so OpenZaak accepts it (refs #46)
OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
JsonContent streams without a Content-Length (Transfer-Encoding: chunked), so
buffer it first. Only a real OpenZaak surfaces this — the integration test from
the previous commit now passes. A unit test asserts a Content-Length is sent
(captured before the stub reads/buffers the body), guarding the fix in the fast
lane and killing the Stryker mutant that would otherwise survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:27 +02:00
notandClaude Opus 4.8 d0582cef65 test(acl): integration test opens a real zaak against OpenZaak (refs #46)
S-04a: the deferred S-04 acceptance criterion. A gated Acl.IntegrationTests
project (Category=Integration) drives the real OpenZaakGateway against the
running compose stack — real ZGW JWT auth and the real POST /zaken contract a
stubbed HttpMessageHandler cannot exercise. The lane is kept out of the fast
checks: make unit filters Category!=Integration, Stryker is pinned to Acl.Tests,
and a new make integration target brings the stack up, seeds a published zaaktype
and tears down.

Red: against real OpenZaak the gateway POST fails 400 — JsonContent streams the
body chunked and OpenZaak's uwsgi rejects it. Fixed in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:17 +02:00
notandClaude Opus 4.8 f2e575b427 feat(infra): publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
OpenZaak rejects a zaak against a concept zaaktype (not-published). Add an
opt-in OZ_PUBLISH path that creates the relations publish requires — two
statustypen, a roltype, and a resultaattype whose Selectielijst procestype is
matched onto the zaaktype — then publishes. Default stays concept (ADR-0002);
only the ACL integration test flips it on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:05 +02:00
not fd5fa5ac3c Merge pull request 'test(acl): ACL mutation-score baseline with Stryker.NET (closes #47)' (#52) from feat/47-acl-mutation-baseline into main
CI / lint (push) Successful in 50s
CI / build (push) Successful in 41s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m25s
CI / compose-smoke (push) Successful in 4m1s
Reviewed-on: #52
2026-06-29 09:00:29 +00:00
notandClaude Opus 4.8 5f3dd31925 fix(ci): pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
CI / unit (pull_request) Successful in 43s
CI / mutation (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 46s
CI / compose-smoke (pull_request) Successful in 4m2s
The artifact step failed the mutation job: upload-artifact@v4 bundles
@actions/artifact v2, which hard-aborts on any non-github.com server ("not
supported on GHES"), even though Gitea 1.25 stores artifacts fine. @v3 uses the
older protocol Gitea speaks and has no GHES guard — a drop-in swap (same inputs).
Document it as gotcha §4 and correct the CI runbook note.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:28:07 +02:00
notandClaude Opus 4.8 347713766e ci(acl): publish the Stryker HTML report as a CI artifact (refs #47)
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 41s
CI / mutation (pull_request) Failing after 1m50s
CI / unit (pull_request) Successful in 47s
CI / compose-smoke (pull_request) Successful in 3m55s
Add an upload-artifact step to the mutation job so the ACL mutation report is
downloadable from the run summary. `if: always()` uploads it even when the
ratchet fails — exactly when the survivors matter. A glob handles Stryker's
timestamped output directory. First use of actions/upload-artifact (@v4, pinned);
Gitea 1.25.x supports it. Document it in the CI runbook.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:21:26 +02:00
notandClaude Opus 4.8 7ecc184111 arch(acl): ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 45s
CI / mutation (pull_request) Successful in 1m24s
CI / compose-smoke (pull_request) Successful in 4m1s
Record the decision to adopt Stryker.NET (pinned local tool, solution mode on
Acl.slnx) and to set the first repo-wide mutation baseline on the ACL: observed
95%, enforced break threshold 90%. Document the ratchet, local run, and report
location in the CI runbook; add the ADR to the docs nav.

Proposed in #51 (adr-proposal). Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:59:41 +02:00
notandClaude Opus 4.8 e8510bf9c3 ci(acl): run the mutation ratchet as a parallel CI job (refs #47)
Add a `mutation` job mirroring the unit job (checkout + pinned setup-dotnet,
then `make mutation`). It runs in parallel with lint/build/unit/compose-smoke
and gates merges on the ACL mutation baseline (CLAUDE.md §5/§15). The job calls
the same make target developers run, so the pipeline stays a mirror of `make ci`.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:22 +02:00
notandClaude Opus 4.8 6ac2fca384 test(acl): add Stryker config + mutation make target recording the 95% baseline (refs #47)
Configure Stryker.NET for the ACL in solution mode (Acl.slnx), so both
Acl.Application and Acl.Infrastructure — the two projects under test — are
mutated while Acl.Api (untested) is skipped. Record the repo-wide mutation
baseline as the ratchet (CLAUDE.md §5): observed score 95%, enforced break
threshold 90% (one-mutant headroom over the ~20-mutant surface). The ACL is the
first service with branching logic, so it sets the baseline; later slices
ratchet it up deliberately, never down.

Add a `mutation` make target (`dotnet tool restore` + `dotnet stryker`) and wire
it into the `make ci` aggregate, keeping `make ci` an exact mirror of the
pipeline.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:00 +02:00
notandClaude Opus 4.8 10816f5303 test(acl): kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
Stryker exposed thin ACL tests (35% mutation score): the suite never asserted
the geo CRS headers, the ArgumentNullException guards, the non-success and
empty-body error paths, or the structure of the minted ZGW JWT — so mutating
any of those survived.

Strengthen the unit tests to kill those mutants:
- assert Accept-Crs / Content-Crs are EPSG:4326,
- assert OpenZaakAsync rejects a null request/registration without calling out,
- assert a non-2xx response throws and an empty body throws InvalidOperationException,
- decode the Bearer token and assert the HS256 header + acl identity claims.

Raises the ACL mutation score to 95%. The one remaining survivor mutates only
the exception *message* text (an equivalent mutant — message strings are not
worth a brittle assertion).

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:51 +02:00
notandClaude Opus 4.8 89b097d015 build(acl): pin Stryker.NET as a local dotnet tool (refs #47)
Add a tool manifest pinning dotnet-stryker 4.15.0 so `make mutation` runs
the same mutation tester locally and in CI from a fresh clone (`dotnet tool
restore`), with no global install. Ignore the generated StrykerOutput/ report
directory.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:38 +02:00
not 5a83216395 Merge pull request 'ci(infra): Gitea Actions CI pipeline + full-stack compose smoke (closes #30)' (#50) from ci/30-gitea-actions-ci into main
CI / lint (push) Successful in 49s
CI / build (push) Successful in 43s
CI / unit (push) Successful in 46s
CI / compose-smoke (push) Successful in 3m54s
Reviewed-on: #50
2026-06-25 12:34:43 +00:00
notandClaude Opus 4.8 f9e123dfcb docs(infra): tighten gitea-actions-gotchas, add local compose (refs #30)
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 41s
CI / unit (pull_request) Successful in 49s
CI / compose-smoke (pull_request) Successful in 4m0s
Restructure for scannability: a shared root-cause intro, a quick-reference
table (gotcha → fix → where), and consistent Symptom/Why/Fix sections with
tighter prose. Documents infra/docker-compose.local.yml as the no-make/Windows
path and drops the now-stale "no bind mounts remain" line (the local compose
uses them, which is fine locally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:00:51 +02:00
notandClaude Opus 4.8 e87113da24 feat(infra): add bind-mount local compose for no-make/Windows dev (refs #30)
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 42s
CI / compose-smoke (pull_request) Successful in 3m59s
CI / unit (pull_request) Successful in 51s
Adds infra/docker-compose.local.yml: the same full stack as the canonical
infra/docker-compose.yml, but the three config inputs (OpenZaak data.yaml,
Keycloak realms, Flowable BPMN) are bind-mounted from the repo instead of
streamed into external volumes by seed-config.sh.

Bind mounts are valid here because a local daemon (Docker Desktop on Windows/
macOS, or rootless Podman on Linux) can see the working directory — the seed
dance only exists for the containerized CI runner, where it can't. So this file
runs with a plain `docker compose up`: no make, no seed step, no bash.

  docker compose -f infra/docker-compose.local.yml up -d --build
  docker compose -f infra/docker-compose.local.yml up -d --build --wait  # Docker Desktop

Linux/macOS convenience wrappers `make local` / `make local-down` added too.
Verified on podman: Keycloak boots from this file and imports the bind-mounted
realms (digid realm returns 200). docs/runbooks/ci.md documents the Windows path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:00:32 +02:00
notandClaude Opus 4.8 dda4c58e1c fix(infra): portable health poll instead of compose --wait (refs #30)
CI / lint (pull_request) Successful in 48s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 44s
CI / compose-smoke (pull_request) Successful in 3m56s
`make smoke` errored locally because podman-compose doesn't implement
`docker compose up --wait` (`unrecognized arguments: --wait`).

Replace the `--wait` step with infra/wait-healthy.sh, which polls each durable
health-checked service ($(WAIT_SVCS)) via `docker ps` + `docker inspect
'{{.State.Health.Status}}'`. This:

- works on both docker compose (CI) and podman-compose (local) — only plain
  docker primitives, no `--wait`;
- reads the in-container healthcheck, so it needs no host port access (the CI
  runner can't reach published ports);
- ignores the one-shot init jobs, sidestepping the "--wait fails when a
  consumer-less one-shot exits 0" issue (flowable-init).

Verified on podman-compose: wait-healthy.sh reports bff healthy (rc=0); podman
exposes .State.Health.Status (starting -> healthy) and the name filter matches
both `_` and `-` container naming.

Docs: gitea-actions-gotchas.md updated (the two `--wait` sections folded into one
"portable health poll" section).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:57:52 +02:00
notandClaude Opus 4.8 b349dff496 refactor(infra): use upstream images verbatim, seed config via docker cp (refs #30)
CI / lint (pull_request) Successful in 49s
CI / build (pull_request) Successful in 44s
CI / unit (pull_request) Successful in 44s
CI / compose-smoke (pull_request) Successful in 4m15s
Drops the inline-build images for the upstream services. The compose now
references the published images directly (openzaak/open-zaak,
openzaak/open-notificaties, keycloak, curl, flowable-rest) with no build for
them, and the config they need is streamed into external named volumes by
infra/seed-config.sh:

  rr-oz-config  -> oz-init     /app/setup_configuration   (data.yaml)
  rr-kc-realms  -> keycloak    /opt/keycloak/data/import   (realm exports)
  rr-fl-bpmn    -> flowable-init /work                     (registratie.bpmn)

How: the seeder creates each volume, `docker create`s a throwaway helper that
mounts it, `docker cp`s the files in, and removes it. docker cp streams over the
Docker API, so it works in Docker-in-Docker (the CI runner) where bind mounts
mount empty. It uses plain `docker create`/`cp` — NOT `docker compose create`,
which podman-compose (local dev) lacks. `external: true` fixed names keep the
volumes identical across docker compose and podman-compose.

Consequence: bare `docker compose up` no longer self-seeds, so use `make up`
(seeds then starts). Every `*-up` target seeds first; `*-down` removes the
external volume. acl/bff are still built (they're our apps, not upstream images).

Verified end-to-end on podman-compose: `make keycloak-up` seeds rr-kc-realms,
the upstream Keycloak mounts it, and --import-realm imports all four realms
(digid realm returns 200). Seeder runs in ~2s.

Docs updated: gitea-actions-gotchas.md, ci.md, openzaak.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:22:14 +02:00
notandClaude Opus 4.8 6d8e1d0830 refactor(infra): bake config via dockerfile_inline, drop Dockerfile files (refs #30)
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 46s
CI / compose-smoke (pull_request) Successful in 4m7s
Replaces the three standalone Dockerfiles (openzaak, opennotificaties,
keycloak) with `build.dockerfile_inline` recipes in the compose files, so the
config bake has no separate Dockerfile artifacts to maintain. Behaviour is
identical: each derived image still COPYies its config in.

- oz-init / keycloak / flowable-init: 2-line inline Dockerfiles.
- Open Notificaties needs no bake at all now — nrc-init runs migrations only,
  so all NRC services use the plain base image (removes a whole derived image).

Why dockerfile_inline and not `docker cp` into named volumes: docker cp avoids
images entirely but needs `docker compose create`, which podman-compose (the
local dev runtime) does not implement — it would break `make openzaak-up` etc.
locally. dockerfile_inline works on both podman-compose and the CI runner
(verified both: oz-init + keycloak inline builds locally; flowable-init inline
has been green on CI since run 27).

Docs updated: gitea-actions-gotchas.md and openzaak.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:32:35 +02:00
notandClaude Opus 4.8 a0aa22c80b fix(infra): smoke waits on durable services, not the whole project (refs #30)
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 50s
CI / compose-smoke (pull_request) Successful in 4m52s
Run 28 got the full stack healthy but `compose-smoke` still failed. The last
compose line before the error was:

  container infra-flowable-init-1 exited (0)

`docker compose up --wait` treats a service that exits as a failure of the
"stay running" condition unless something depends on it via
`service_completed_successfully`. oz-init/nrc-init are fine (openzaak/nrc-web
depend on them), but flowable-init deploys the BPMN and exits 0 with no
dependant, so whole-project `--wait` failed the instant it finished — even
though everything else was healthy and nrc-init now exits 0.

Smoke now:
  1. `up -d` starts the full stack (one-shots run + deploy as before), then
  2. `up -d --wait <WAIT_SVCS>` waits only for the durable health-checked
     services (openzaak nrc-web acl bff).

Also drops the external `curl localhost:8080/health`: the containerized CI
runner can't reach published host ports at localhost, and each service's
healthcheck already runs inside its container — so `--wait` succeeding IS the
smoke. Documented in docs/runbooks/gitea-actions-gotchas.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:03:37 +02:00
notandClaude Opus 4.8 12049a0f35 fix(infra): nrc-init runs migrations only, not setup_configuration (refs #30)
CI / lint (pull_request) Successful in 49s
CI / build (pull_request) Successful in 43s
CI / unit (pull_request) Successful in 47s
CI / compose-smoke (pull_request) Failing after 3m34s
With OpenZaak now coming up, nrc-init ran for the first time and failed:

  nrc-init-1 | CommandError: No steps enabled, aborting.

NRC's setup_configuration/data.yaml is intentionally empty ({}) — the
OZ<->NRC wiring is deferred to S-06 — but /setup_configuration.sh runs
`manage.py setup_configuration` regardless, and NRC 1.16.1 aborts when no
steps are enabled. (This was masked until now: oz-init failed first, so
openzaak never became healthy and nrc-init, which waits on it, never ran.)

The documented intent is "init runs migrations only", so nrc-init now runs
`manage.py migrate` directly instead of /setup_configuration.sh, and the
dead RUN_SETUP_CONFIG env is dropped from the NRC services. nrc-web still
migrates + creates the superuser itself via /start.sh.

Also:
- Makefile: bump compose `--wait-timeout` 300 -> 420. The serial
  oz-db -> oz-init -> openzaak(healthy) -> nrc-init -> nrc-web(healthy)
  chain runs ~260 s on the runner; 420 s gives comfortable headroom.
- ci.yaml: widen the on-failure log dump to oz-init, openzaak, nrc-init,
  nrc-web, flowable-init, keycloak, acl, bff for full diagnosability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:18:32 +02:00
notandClaude Opus 4.8 9ff7937055 fix(infra): bake config into images so compose-smoke passes on CI (refs #30)
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 45s
CI / compose-smoke (pull_request) Failing after 3m31s
Root cause of the compose-smoke failure (found in the runner logs):

  oz-init-1 | CommandError: Yaml file
              `/app/setup_configuration/data.yaml` does not exist.

The ubuntu-latest runner runs the job inside a container, so
`docker compose up` starts the stack as SIBLING containers via the host
daemon. A relative bind mount (./openzaak/setup_configuration) resolves to
a path inside the job container that the daemon can't see, so Docker mounts
an empty dir and the init container can't find data.yaml. The same trap hit
nrc-init (data.yaml), flowable-init (the BPMN) and keycloak (realm import).

Fix: bake the assets into small derived images instead of bind-mounting:
  - infra/openzaak/Dockerfile        -> register-referentie/openzaak:dev
  - infra/opennotificaties/Dockerfile-> register-referentie/opennotificaties:dev
  - infra/keycloak/Dockerfile        -> register-referentie/keycloak:dev
  - flowable-init: build.dockerfile_inline bakes workflows/registratie.bpmn

Base versions stay build args (OPENZAAK_TAG / OPENNOTIFICATIES_TAG), so the
pinning is unchanged. Applied to both the consolidated compose and the
per-service composes, so local Podman and CI use one mechanism — no bind
mounts, no SELinux `:z`, no world-readable requirement.

Verified locally: `podman build` of the OpenZaak and BPMN images produces
the file at the expected in-container path.

Docs: docs/runbooks/gitea-actions-gotchas.md explains the DinD bind-mount
trap and the bake fix; openzaak.md and ci.md point at it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:06:56 +02:00
notandClaude Sonnet 4.6 88de47d1bb fix(infra): harden oz-db healthcheck and raise compose-up timeout (refs #30)
CI / build (pull_request) Successful in 44s
CI / lint (pull_request) Successful in 52s
CI / unit (pull_request) Successful in 45s
CI / compose-smoke (pull_request) Failing after 1m53s
Three root-cause fixes for the oz-init CI failure:

1. Smoke timeout: add --wait-timeout 300 to `docker compose up --wait`
   so CI has 5 minutes instead of the 60-second default in older Compose
   v2 releases (migrations alone take 50 s locally).

2. PostGIS race: the old healthcheck used pg_isready which only checks
   TCP connectivity — it passes before the postgis/postgis init scripts
   have run SELECT PostGIS_Version(). The new check adds a psql probe so
   oz-init does not start until PostGIS is actually available.

3. Remove :z from volume mounts: the SELinux re-label flag is
   Podman/Fedora-specific and a no-op (or unexpected) under Docker on
   ubuntu-latest; plain :ro is correct for both runtimes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 11:55:15 +02:00
notandClaude Sonnet 4.6 8528664660 fix(infra): pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
CI / lint (pull_request) Successful in 55s
CI / build (pull_request) Successful in 44s
CI / unit (pull_request) Successful in 46s
CI / compose-smoke (pull_request) Failing after 1m27s
latest bumped to OpenZaak 1.29.0 (2026-06-18) and open-notificaties
updated (2026-06-22), breaking oz-init in compose-smoke.  Pin all four
compose files to stable patch releases:

  open-zaak:            1.28.2  (was :latest -> 1.29.0)
  open-notificaties:    1.16.1  (was :latest)

Tags are still overridable via OPENZAAK_TAG / OPENNOTIFICATIES_TAG env vars.

Also adds two if: failure() steps to the compose-smoke CI job: one that
dumps the last 100 lines of oz-init / nrc-init / acl / bff logs, and one
that tears the stack down cleanly, so future failures are self-diagnosing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:46:33 +02:00
notandClaude Sonnet 4.6 f32fc4e8c0 ci(infra): switch runner label to ubuntu-latest (refs #30)
CI / lint (pull_request) Successful in 1m27s
CI / build (pull_request) Successful in 48s
CI / compose-smoke (pull_request) Failing after 3m47s
CI / unit (pull_request) Successful in 47s
Self-hosted respellion-linux runner not required — Gitea's hosted
ubuntu-latest runner has Docker + Compose v2 out of the box, so
make smoke works without any manual registration step.

Updates docs/runbooks/ci.md to reflect the new runner label and
removes the act_runner self-hosted setup as the primary path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:32:38 +02:00
notandClaude Sonnet 4.6 eaca611842 ci(infra): ACL Dockerfile + full compose stack for smoke test (refs #30)
CI / unit (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / compose-smoke (pull_request) Has been cancelled
Adds the ACL multi-stage Dockerfile and .dockerignore, and expands
infra/docker-compose.yml from the BFF-only stub to the full development
stack (OpenZaak, NRC, Keycloak, Flowable, ACL, BFF).  Without these
files a fresh checkout cannot satisfy `make smoke`'s `docker compose
up --build --wait` step, so `make ci` could never go green.

`make lint && make build && make unit` verified green locally.
`make smoke` requires Docker Compose v2 (`--wait` flag); on this dev box
only podman-compose is available — smoke will be verified on the
respellion-linux CI runner once it is registered (see docs/runbooks/ci.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:25:53 +02:00
352 changed files with 39470 additions and 805 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.15.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
# .NET sources use 4-space indent (dotnet format enforces this). The 2-space default
# above is for the frontend (TS/HTML/CSS/JSON); C# keeps the .NET convention.
[*.cs]
indent_size = 4
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+123 -6
View File
@@ -17,34 +17,151 @@ permissions:
jobs:
lint:
runs-on: respellion-linux
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
# — a miss just restores from the network. See issue #73.
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make lint
build:
runs-on: respellion-linux
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make build
unit:
runs-on: respellion-linux
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit
compose-smoke:
runs-on: respellion-linux
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- run: make smoke
- uses: https://github.com/pnpm/action-setup@v4
with:
version: 11
- uses: https://github.com/actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- run: make frontend
mutation:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
# ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend
# 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's
# non-deterministic StrykerOutput/<timestamp>/ dir. Pinned @v3: @v4's bundled
# @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook.
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: acl-mutation-report
path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: event-subscriber-mutation-report
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: domain-mutation-report
path: services/domain/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: bff-mutation-report
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
# image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
verify-stack:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# Bring the full stack up + wait for health — this also is the DoD "compose up
# reaches green health" smoke (it replaces the old compose-smoke job).
- name: Bring up the full stack & wait for health
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
run: OBS_TIMEOUT=180 make verify-observability
- name: ACL ↔ OpenZaak integration tests
run: make verify-acl
- name: OpenZaak → NRC notification delivery
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
run: make verify-projection
- name: Domain → Flowable → ACL → OpenZaak
run: make verify-domain
- name: BFF → Keycloak + domain + projection
run: make verify-bff
- name: Distributed traces reach Tempo (one connected trace across services)
run: TRACING_TIMEOUT=120 make verify-tracing
- name: Self-service e2e (Playwright, login → submit → success)
run: make verify-e2e
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
+25
View File
@@ -15,6 +15,9 @@ coverage*.json
coverage*.xml
*.coverage
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
StrykerOutput/
# Rider / VS / VS Code
.idea/
.vs/
@@ -32,3 +35,25 @@ site/
# OS
.DS_Store
Thumbs.db
# ── Frontend (Nx / Angular / pnpm) ──
node_modules/
dist/
tmp/
out-tsc/
/coverage
.angular/
.nx/cache
.nx/workspace-data
.nx/self-healing
.nx/migrate-runs
.nx/polygraph
vite.config.*.timestamp*
vitest.config.*.timestamp*
.angular
# Playwright e2e (installed/generated in-container or on local runs)
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
+8
View File
@@ -0,0 +1,8 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.angular
.nx/self-healing
+3
View File
@@ -0,0 +1,3 @@
{
"singleQuote": true
}
+61 -18
View File
@@ -151,32 +151,47 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
### S-08 · Self-Service portal (Angular, NL DS) — submit a registration
**Outcome:** The self-service Angular app, in the Nx monorepo, lets a zorgprofessional log in via mock DigiD and submit a registration. NL Design System styling. Generated API client.
> **S-08 was split** (CLAUDE.md §13; issue #9 closed) into the sub-slices below — it bundled the
> Nx bootstrap, the generated client, the NL DS + DigiD form, and a full-stack Playwright e2e, well
> past 12 days. Each sub-slice is independently demoable and CI-green.
- **S-08a (#65)** · Nx monorepo + Angular tooling + CI Node lane. Placeholder `self-service` app; `nx lint/test/build` green in a new CI Node lane.
- **S-08b (#66)** · Generated api-client lib from `services/bff/openapi.json` (never hand-written, §10) + a mocked-BFF unit test.
- **S-08c (#67)** · Self-service submit form — NL Design System `libs/ui`, DigiD OIDC `libs/auth`, component tests (Angular Testing Library), axe WCAG 2.1 AA on the submit page.
- **S-08d (#68)** · Playwright happy-path e2e (login → submit → success) against the full stack + compose serving + CI e2e lane.
**Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup *(#10)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
**Acceptance:**
- E2E test (Playwright): full happy path, login → submit → success page.
- Component tests (Testing Library) for the form.
- Accessibility audit (axe-core) passes WCAG 2.1 AA on the submit page.
- E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
- Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
**Touches:** `apps/self-service/`, `libs/ui/`, `libs/auth/`, `libs/api-client/`, tests.
**Touches:** `apps/openbaar/`, compose serving, e2e, docs.
**Out of scope:** document upload, status tracking page.
**Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
### S-09 · Openbaar Register portal — public lookup
### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end.
**Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
**Acceptance:**
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry.
- Public-safe field whitelist enforced and tested.
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
- Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
**Touches:** `apps/openbaar/`, projection-api hardening, tests.
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
**Out of scope:** advanced search filters, sorting.
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
**End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
---
@@ -184,9 +199,25 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
### S-11 · Withdrawal (Flow 3)
@@ -208,6 +239,12 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
### S-26 · Self-service — resume an existing registration after refresh — #111
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
---
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
@@ -216,13 +253,19 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
### S-16 · OpenTelemetry traces + Grafana dashboard
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
Split into independently deployable sub-slices (CLAUDE.md §13):
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a.
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
---
+112 -1
View File
@@ -2,19 +2,130 @@
All notable changes to this project. Generated from Conventional Commits by git-cliff.
## Unreleased
## v2026.07.0 — 2026-07-14
### Architecture
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
- ADR-0009 external-task job-worker pattern (refs #6, #60)
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
### Bug Fixes
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
- Bake config into images so compose-smoke passes on CI (refs #30)
- Nrc-init runs migrations only, not setup_configuration (refs #30)
- Smoke waits on durable services, not the whole project (refs #30)
- Portable health poll instead of compose --wait (refs #30)
- Pin upload-artifact to @v3@v4 refuses to run on Gitea (refs #47)
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
- Keep dotnet format green under the shared .editorconfig (refs #65)
- Re-export the full Utrecht package from libs/ui (refs #67)
- Run checkAuth() at startup to end the login redirect loop (refs #67)
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
- Attach the DigiD token to relative BFF calls (refs #68)
### Build
- Pin Stryker.NET as a local dotnet tool (refs #47)
### CI
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
- ACL Dockerfile + full compose stack for smoke test (refs #30)
- Switch runner label to ubuntu-latest (refs #30)
- Run the mutation ratchet as a parallel CI job (refs #47)
- Publish the Stryker HTML report as a CI artifact (refs #47)
- Run the ACL integration test as a Gitea Actions job (refs #46)
- Keep the integration lane local-only; document the runner gap (refs #46)
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
- Containerize, wire into compose, and verify end-to-end (refs #6)
- Make Stryker report upload best-effort (refs #62)
- Retrigger after runner cleanup (refs #6)
- Retrigger CI (refs #6)
- Retrigger CI after gitea restart (refs #6)
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
- Nx frontend lane (lint/test/build) (refs #65)
- Serve the self-service app in compose (refs #68)
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
- Cache the NuGet package store across the .NET jobs (refs #73)
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
### Chores
- Add idempotent Gitea backlog seeder
- Remove bootstrap scripts from main (#35)
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
### Documentation
- Split S-00 into sub-slices (refs #1) (#33)
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
- Tighten gitea-actions-gotchas, add local compose (refs #30)
- ADR-0008 read projection store + demo note for the event path (refs #7)
- Demo note for submitting a registration (S-05) (refs #6)
- Demo note for the BFF front door (S-07) (refs #8)
- Split S-08 into S-08a-d (refs #65)
- Frontend-decisions + demo note for S-08a (refs #65)
- Record the orval generator choice (refs #66)
- Record NL DS + DigiD decisions and demo note (refs #67)
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
### Features
- Placeholder BFF + /health endpoint (closes #28) (#34)
- Containerize BFF + compose-up smoke (closes #29) (#36)
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
- Open Notificaties up + shared network (closes #2) (#42)
- Keycloak with four mock realms (closes #3) (#43)
- Flowable + registratie.bpmn external task (closes #4) (#44)
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
- Add bind-mount local compose for no-make/Windows dev (refs #30)
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
- Wire OpenZaak → Open Notificaties notifications (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Persist the read projection and expose webhook + read APIs (refs #7)
- Enforce the callback bearer before reading the body (refs #7)
- Implement the Registration aggregate invariants (refs #6)
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
- Implement the Flowable Workflow Client and ACL client (refs #6)
- Expose POST /registrations and the read endpoint (refs #6)
- Implement self-service submit and openbaar lookup (refs #8)
- Committed OpenAPI contract + drift guard (refs #8)
- Self-service portal placeholder page (refs #65)
- Expose the generated BFF client + repeatable generate target (refs #66)
- Implement the DigiD registration submit page (refs #67)
- Runtime config + nginx serve/proxy image (refs #68)
- Surface submit failures with a retryable alert (refs #68)
- One citizen reference across self-service and the openbaar register (#79)
### Other
- Openbaar Register portal — public lookup (#76)
- Approval flow — temp admin endpoint + status transition to projection (#77)
### Refactor
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
- Use upstream images verbatim, seed config via docker cp (refs #30)
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
### Tests
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
- Integration test opens a real zaak against OpenZaak (refs #46)
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Ratchet projector mutation baseline to 100% (refs #7)
- Registration aggregate invariants (refs #6)
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
- Workflow Client, ACL client, store and job processor (refs #6)
- Acceptance scenario for submitting a registration (refs #6)
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
- Endpoints, JWT auth and public-safe projection (refs #8)
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
- Self-service portal placeholder renders (refs #65)
- Generated BFF client is exposed and calls the endpoints (refs #66)
- DigiD-guarded registration submit page (refs #67)
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
- Submit surfaces BFF failures instead of swallowing them (refs #68)
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
+172 -14
View File
@@ -7,7 +7,22 @@
SLN := register-referentie.slnx
COMPOSE := infra/docker-compose.yml
HEALTH_URL := http://localhost:8080/health
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
# containerized CI runner. SEED populates them; run it before every `up`. The
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
LOCAL_COMPOSE := infra/docker-compose.local.yml
OZ_COMPOSE := infra/openzaak/docker-compose.yml
OZ_BASE := http://localhost:8000
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
@@ -28,10 +43,23 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
ci: lint build unit smoke
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
ci: lint build unit mutation frontend verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
# not configurable. When the ~5min production build shares the run-many pool, it
# starves that worker of CPU on constrained CI runners and Vitest fails with
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
# heavy build's back so the worker starts well inside its window.
frontend:
pnpm install --frozen-lockfile
pnpm nx run-many -t lint test
pnpm nx run-many -t build
## lint: verify formatting (no changes)
lint:
@@ -41,30 +69,147 @@ lint:
build:
dotnet build $(SLN) -c Release
## unit: run unit tests
## unit: run unit tests (excludes the container-backed Integration lane)
unit:
dotnet test $(SLN) -c Release
dotnet test $(SLN) -c Release --filter "Category!=Integration"
## smoke: compose up (wait for healthy), curl /health, then tear down
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Each service owns its config + break
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
# Scores never regress below baseline.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
cd services/event-subscriber && dotnet stryker
cd services/domain && dotnet stryker
cd services/bff && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim;
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is
# checked by infra/wait-healthy.sh polling the durable, health-checked services
# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
# (oz-init, flowable-init) aren't polled; they just need to have run.
smoke:
docker compose -f $(COMPOSE) up -d --build --wait
bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc'
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## down: stop and remove the local stack
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
down:
docker compose -f $(COMPOSE) down --volumes
-docker volume rm -f $(CFG_VOLS)
## local: bring up the bind-mount stack (no seed step) and wait for health
## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly)
local:
docker compose -f $(LOCAL_COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
verify-local:
bash infra/run-local-flow-check.sh
## local-down: stop and remove the bind-mount stack
local-down:
docker compose -f $(LOCAL_COMPOSE) down --volumes
## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff)
changelog:
git-cliff --output CHANGELOG.md
# ── ZGW verification ───────────────────────────────────────────────────────
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
# check logic lives in stack-agnostic runners that reach services by container IP
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
# convenience wrappers that bring up a lighter stack and call the same runners.
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
verify-acl:
bash infra/run-acl-integration.sh
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
verify-nrc:
bash infra/run-notification-check.sh
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
## against the already-running stack.
verify-projection:
bash infra/run-projection-check.sh
## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the
## already-running stack. Recreates the acl service to inject the seeded zaaktype URL.
verify-domain:
bash infra/run-domain-check.sh
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
## + anonymous public-safe openbaar register (ADR-0010).
verify-bff:
bash infra/run-bff-check.sh
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
## submit → confirmation, driven inside the compose network.
verify-e2e:
bash infra/run-e2e-check.sh
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
## Prometheus datasources) is live, against the already-running stack (S-16a).
verify-observability:
bash infra/run-observability-check.sh
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
## (S-16b), against the already-running stack.
verify-tracing:
bash infra/run-tracing-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
verify:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc'
## integration: local convenience — ACL integration test against a throwaway
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
integration:
bash infra/run-integration.sh
## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up:
$(SEED) oz
docker compose -f $(OZ_COMPOSE) up -d
## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced
openzaak-smoke:
docker compose -f $(OZ_COMPOSE) up -d
openzaak-smoke: openzaak-up
@bash -c 'set -e; \
echo "waiting for OpenZaak to respond..."; \
for i in $$(seq 1 60); do \
@@ -88,10 +233,18 @@ openzaak-seed: openzaak-up
## openzaak-down: stop and remove the OpenZaak stack (wipes data)
openzaak-down:
docker compose -f $(OZ_COMPOSE) down --volumes
-docker volume rm -f rr-oz-config
## stack-up: start OpenZaak + Open Notificaties together (shared network)
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
verify-notifications:
bash infra/verify-notifications.sh
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
## OpenZaak publishing notifications to NRC (S-01-c).
stack-up:
docker compose $(STACK_FILES) up -d
$(SEED) oz nrc
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
stack-smoke: stack-up
@@ -110,9 +263,11 @@ stack-smoke: stack-up
## stack-down: stop and remove both stacks (wipes data)
stack-down:
docker compose $(STACK_FILES) down --volumes
-docker volume rm -f rr-oz-config rr-nrc-config
## keycloak-up: start Keycloak with the four imported realms
keycloak-up:
$(SEED) kc
docker compose -f $(KC_COMPOSE) up -d
## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim
@@ -125,9 +280,11 @@ keycloak-smoke: keycloak-up
## keycloak-down: stop and remove Keycloak
keycloak-down:
docker compose -f $(KC_COMPOSE) down --volumes
-docker volume rm -f rr-kc-realms
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
flowable-up:
$(SEED) fl
docker compose -f $(FL_COMPOSE) up -d
## flowable-smoke: start Flowable, then verify a started instance waits on the external task
@@ -140,6 +297,7 @@ flowable-smoke: flowable-up
## flowable-down: stop and remove Flowable
flowable-down:
docker compose -f $(FL_COMPOSE) down --volumes
-docker volume rm -f rr-fl-bpmn
## help: list available targets
help:
+27
View File
@@ -0,0 +1,27 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+24
View File
@@ -0,0 +1,24 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /behandel/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "behandel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/behandel/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/behandel",
"browser": "apps/behandel/src/main.ts",
"tsConfig": "apps/behandel/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/behandel/public"
}
],
"styles": ["apps/behandel/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "behandel:build:production"
},
"development": {
"buildTarget": "behandel:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "behandel:build",
"staticFilePath": "dist/apps/behandel/browser",
"spa": true
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+73
View File
@@ -0,0 +1,73 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('behandel medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative werkbak call', () => {
bff.getBehandelWerkbak().subscribe();
const req = http.expectOne('/behandel/werkbak');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('attaches the bearer token to the relative decide call', () => {
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
const req = http.expectOne('/behandel/registrations/reg-1/decide');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush(null);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/behandel/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet></router-outlet>
+7
View File
@@ -0,0 +1,7 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { WerkbakPage } from './werkbak/werkbak-page';
export const appRoutes: Route[] = [
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
];
+15
View File
@@ -0,0 +1,15 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'behandel';
}
@@ -0,0 +1,64 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Werkbak</utrecht-heading-1>
<p utrecht-paragraph>
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Registraties in behandeling
</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">BSN</th>
<th scope="col">Status</th>
<th scope="col">Actie</th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.registrationId) {
<tr>
<td>{{ item.registrationId }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.status }}</td>
<td>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'goedkeuren')"
>
Goedkeuren
</button>
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[attr.aria-label]="'Afwijzen ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'afwijzen')"
>
Afwijzen
</button>
</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -0,0 +1,110 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['behandelaar']);
login(): void {
/* not exercised here */
}
logout(): void {
/* spied in tests */
}
}
function setup(
overrides: {
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBehandelWerkbak =
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
const postBehandelRegistrationsIdDecide =
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
return {
getBehandelWerkbak,
postBehandelRegistrationsIdDecide,
providers: [
{
provide: BffApiV1Service,
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
},
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('WerkbakPage', () => {
it('lists the registrations awaiting beoordeling on open', async () => {
const { getBehandelWerkbak, providers } = setup();
await render(WerkbakPage, { providers });
expect(getBehandelWerkbak).toHaveBeenCalled();
expect(await screen.findByText('reg-1')).toBeTruthy();
expect(screen.getByText('123456782')).toBeTruthy();
expect(screen.getByText('reg-2')).toBeTruthy();
});
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'goedkeuren',
});
// Reloaded after the decision: once on open, once after deciding.
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
});
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
const { postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'afwijzen',
});
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(WerkbakPage, { providers });
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(WerkbakPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,65 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
/**
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*/
@Component({
selector: 'app-werkbak-page',
imports: [UtrechtComponentsModule],
templateUrl: './werkbak-page.html',
})
export class WerkbakPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<WerkbakItem[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
protected readonly deciding = signal<string | undefined>(undefined);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
decide(registrationId: string, besluit: Besluit): void {
this.deciding.set(registrationId);
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
// Refresh so the decided registration drops off the werkbak (its task is now completed).
next: () => {
this.deciding.set(undefined);
this.load();
},
error: () => {
this.deciding.set(undefined);
this.failed.set(true);
},
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
+2
View File
@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
# Multi-stage build for the openbaar portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "openbaar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/openbaar/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/openbaar",
"browser": "apps/openbaar/src/main.ts",
"tsConfig": "apps/openbaar/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/openbaar/public"
}
],
"styles": ["apps/openbaar/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "openbaar:build:production"
},
"development": {
"buildTarget": "openbaar:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "openbaar:build",
"staticFilePath": "dist/apps/openbaar/browser",
"spa": true
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+19
View File
@@ -0,0 +1,19 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
],
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet></router-outlet>
+4
View File
@@ -0,0 +1,4 @@
import { Route } from '@angular/router';
import { RegisterPage } from './register/register-page';
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];
+14
View File
@@ -0,0 +1,14 @@
import { provideRouter } from '@angular/router';
import { render } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegisterPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'openbaar';
}
@@ -0,0 +1,54 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
<p utrecht-paragraph>
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
</p>
<div role="search">
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
<input
id="register-search"
type="search"
utrecht-textbox
[ngModel]="query()"
(ngModelChange)="query.set($event)"
[ngModelOptions]="{ standalone: true }"
(keyup.enter)="search()"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="loading()"
(click)="search()"
>
Zoeken
</button>
</div>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (searched() && entries().length === 0) {
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
} @else if (entries().length > 0) {
<table utrecht-table>
<caption>Inschrijvingen in het openbaar register</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.reference }}</td>
<td>{{ entry.status }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
return {
get,
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
};
}
describe('RegisterPage', () => {
it('lists the public register entries from the BFF on open', async () => {
const { get } = providers();
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
// not the internal zaak id.
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/REG-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {
const get = vi.fn().mockReturnValue(of(sample));
await render(RegisterPage, { providers: providers(get).providers });
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
});
it('shows an empty-state message when the register has no matches', async () => {
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
await render(RegisterPage, { providers: providers(get).providers });
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(RegisterPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,45 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
*/
@Component({
selector: 'app-register-page',
imports: [FormsModule, UtrechtComponentsModule],
templateUrl: './register-page.html',
})
export class RegisterPage {
private readonly bff = inject(BffApiV1Service);
protected readonly query = signal('');
protected readonly entries = signal<OpenbaarEntry[]>([]);
protected readonly loading = signal(false);
protected readonly searched = signal(false);
constructor() {
// Show the full register on open; the search box narrows it.
this.search();
}
search(): void {
const q = this.query().trim();
this.loading.set(true);
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
next: (rows: OpenbaarEntry[]) => {
this.entries.set(rows);
this.loading.set(false);
this.searched.set(true);
},
error: () => {
this.entries.set([]);
this.loading.set(false);
this.searched.set(true);
},
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Openbaar BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+2
View File
@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
#
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
#
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
# entrypoint executes or sources it.
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
echo "portal-nginx-resolver: set resolver to $ns"
fi
+27
View File
@@ -0,0 +1,27 @@
# Multi-stage build for the self-service portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/self-service apps/self-service
COPY libs libs
RUN pnpm nx build self-service
FROM nginx:1.27-alpine AS runtime
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
location /self-service/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "self-service",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/self-service/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/self-service",
"browser": "apps/self-service/src/main.ts",
"tsConfig": "apps/self-service/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/self-service/public"
}
],
"styles": ["apps/self-service/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "self-service:build:production"
},
"development": {
"buildTarget": "self-service:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "self-service:build",
"staticFilePath": "dist/apps/self-service/browser",
"spa": true
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"authority": "http://localhost:8180/realms/digid"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,65 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
// and the token storage are faked, so the assertion turns on the actual route-matching.
describe('self-service DigiD token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'digid-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative self-service BFF call', () => {
bff.postSelfServiceRegistrations().subscribe();
const req = http.expectOne('/self-service/registrations');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
* api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
* which stays relative, so an absolute origin would never match and the token would go unattached.
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
*/
export const SECURE_API_ROUTES = ['/self-service/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideDigiadAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet></router-outlet>
+7
View File
@@ -0,0 +1,7 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { RegistrationPage } from './registration/registration-page';
export const appRoutes: Route[] = [
{ path: '', component: RegistrationPage, canActivate: [authenticatedGuard] },
];
+15
View File
@@ -0,0 +1,15 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegistrationPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'self-service';
}
@@ -0,0 +1,74 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) {
@if (withdrawn()) {
<p utrecht-paragraph role="status">
Uw registratie met referentie {{ reference() }} is ingetrokken.
</p>
} @else {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
<label utrecht-form-label for="diploma">Diploma</label>
<input
id="diploma"
type="file"
accept="application/pdf"
[disabled]="providingDocuments()"
(change)="onFileSelected($event)"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="providingDocuments() || !selectedFile()"
(click)="provideDocuments()"
>
Documenten aanleveren
</button>
}
@if (withdrawFailed()) {
<p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[disabled]="withdrawing()"
(click)="withdraw()"
>
Trek aanvraag in
</button>
}
} @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {
<p utrecht-paragraph role="alert">
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="submitting()"
(click)="submit()"
>
Registratie indienen
</button>
}
</utrecht-article>
</main>
@@ -0,0 +1,173 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client';
import { axe } from 'vitest-axe';
import { RegistrationPage } from './registration-page';
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>('123456782');
login(): void {
/* noop */
}
logout(): void {
/* noop */
}
}
function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
getCurrent = vi.fn().mockReturnValue(of(undefined)),
) {
return {
post,
withdraw,
provideDocuments,
getCurrent,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{
provide: BffApiV1Service,
useValue: {
getSelfServiceRegistrations: getCurrent,
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
},
},
],
};
}
describe('RegistrationPage', () => {
it('shows the signed-in BSN', async () => {
await render(RegistrationPage, { providers: providers().providers });
expect(screen.getByText(/123456782/)).toBeTruthy();
});
it('submits the registration and confirms', async () => {
const { post, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
expect(post).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
});
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
const { post, providers: p } = providers(
undefined,
undefined,
undefined,
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
);
await render(RegistrationPage, { providers: p });
// The confirmation view is restored from the in-flight registration — no submit click.
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
expect(screen.getByText(/reg-77/)).toBeTruthy();
expect(post).not.toHaveBeenCalled();
});
it('shows an error and keeps the submit available when the BFF call fails', async () => {
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
expect(post).toHaveBeenCalledTimes(1);
// The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/ontvangen/i)).toBeNull();
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
});
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
const { withdraw, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
expect(withdraw).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
const { provideDocuments, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
// Choose the file, then upload it.
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
expect(provideDocuments).toHaveBeenCalledWith(
'reg-9',
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
);
});
it('surfaces a diploma-upload failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
});
it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document.
document.documentElement.lang = 'nl';
const { container } = await render(RegistrationPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,140 @@
import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
/**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*
* On load it asks the BFF for the caller's current open registration and restores the submitted view
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
*/
@Component({
selector: 'app-registration-page',
imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html',
})
export class RegistrationPage implements OnInit {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
protected readonly bsn = this.auth.bsn;
protected readonly submitting = signal(false);
protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
* current open registration, or 204 (empty body) when there is none — in which case we show the
* submit form as before. Failures are non-fatal for the same reason. */
ngOnInit(): void {
this.bff.getSelfServiceRegistrations().subscribe({
next: (current: CurrentRegistration | void) => {
if (current && current.registrationId) {
this.reference.set(current.registrationId);
this.submitted.set(true);
}
},
error: () => {
// No resumable registration (or the lookup failed) — fall back to the submit form.
},
});
}
submit(): void {
this.submitting.set(true);
this.failed.set(false);
this.bff.postSelfServiceRegistrations().subscribe({
next: (accepted: SubmitAccepted) => {
this.reference.set(accepted.registrationId);
this.submitted.set(true);
this.submitting.set(false);
},
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
error: () => {
this.failed.set(true);
this.submitting.set(false);
},
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.selectedFile.set(input.files?.[0] ?? undefined);
}
async provideDocuments(): Promise<void> {
const reference = this.reference();
const file = this.selectedFile();
if (!reference || !file) {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
let contentBase64: string;
try {
contentBase64 = await readAsBase64(file);
} catch {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
return;
}
this.bff
.postSelfServiceRegistrationsIdDocuments(reference, {
contentBase64,
fileName: file.name,
contentType: file.type || 'application/pdf',
})
.subscribe({
next: () => {
this.documentsProvided.set(true);
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
}
withdraw(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.withdrawing.set(true);
this.withdrawFailed.set(false);
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
next: () => {
this.withdrawn.set(true);
this.withdrawing.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.withdrawFailed.set(true);
this.withdrawing.set(false);
},
});
}
}
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
function readAsBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
reader.readAsDataURL(file);
});
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>self-service</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
+2
View File
@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
@@ -0,0 +1,68 @@
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
- **Status:** Accepted
- **Date:** 2026-06-25
- **Deciders:** Respellion engineering
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
## Context
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
strictly, no slice could satisfy that gate. S-04b establishes it.
The ACL is the natural place to set the first baseline: it is the first service with real
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
that:
- mutates C# and runs the existing xUnit suite per mutant,
- is reproducible (same version locally and in CI, no global install),
- understands this repo's `.slnx` solution format (used repo-wide),
- emits a break threshold CI can gate on.
## Decision
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
- **Solution mode** (`stryker-config.json``solution: Acl.slnx`) mutates the two projects
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%**
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
## Consequences
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
the small ACL). Pinned + tool-restored, so reproducible.
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
Asserting exception message text is brittle and the behaviour (type + control flow) is
unchanged — treated as an equivalent mutant, not a test gap.
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
services add their own mutation run as they gain branching logic (BFF, Domain, …).
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
names; the fallback is no mutation testing, which §5 forbids.
## Alternatives considered
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
local manifest gives every checkout and the CI runner the same version.
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
opts in as it gains logic.
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
the riskiest code — unguarded by the ratchet.
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
tests would *catch* a regression; that is the whole point of §5.
@@ -0,0 +1,92 @@
# ADR-0006: Provision the ACL integration test against the compose stack
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
## Context
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
> Integration test using Testcontainers against real OpenZaak passes.
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
Investigation reversed the initially-favoured Testcontainers option:
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
mean re-implementing that five-service stack — init ordering, the config volume, health
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
which OpenZaak validates by fetching the external **Selectielijst** reference API
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
regardless of how the containers are started.
## Decision
**The ACL integration test targets the running compose stack; it does not start containers
itself. No new test dependency is added.**
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
`OpenZaakGateway` against it.
- **The lane is kept out of the fast checks.** `make unit` runs with
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
neither the unit nor the mutation lane needs a live stack. A `make integration` target
(`infra/run-integration.sh`) brings up a throwaway OpenZaak and runs the lane locally.
In CI the check runs as the `verify-acl` step of the consolidated `verify-stack` job
(issue #58) — one shared full-stack bring-up. This matches `make` being the single
source of truth (ADR-0005).
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
a concept; only `make integration` flips the switch.
## Consequences
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
test asserting a `Content-Length` is set. This is the concrete justification for §11's
integration tier.
- **External dependency:** the integration job needs the OpenZaak container to reach
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
in production) but it is a network touchpoint, and a CI environment without egress would need
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes.
- **Runs on the hosted runner.** A process *on* the runner can't reach the stack's published
ports (Compose starts sibling containers via the host daemon — gitea-actions-gotchas.md §5,
same split as §1), so `infra/run-integration.sh` runs both the seed and the test as containers
*joined to the OpenZaak network*, reaching it by **container IP** (a single-label host like
`openzaak` isn't URL-valid for OpenZaak's own `URLValidator`; an IPv4 literal is). Code is
delivered by image build / `docker cp`, never bind mounts. The CI job therefore needs only
Docker — no `setup-dotnet`. (This closed the follow-up that was originally split out as #55.)
## Alternatives considered
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
undermine the contract the test exists to verify.
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
tests already do; it cannot exercise the real contract, and would not have caught the chunked
body bug.
@@ -0,0 +1,77 @@
# ADR-0007: Wiring OpenZaak → Open Notificaties (NRC) for notifications
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-01-c (#56); completes S-01 (#2); unblocks the Event Subscriber (#7); builds on ADR-0002 (catalogus/seed) and ADR-0006 (runner-safe container harnesses)
## Context
S-01 brought OpenZaak + Open Notificaties (NRC) up in compose but **deferred the
notification wiring**: OpenZaak ran with `NOTIFICATIONS_DISABLED=true` and NRC's
`setup_configuration` was empty. The walking skeleton (PRD §12) needs the upstream
event path — a zaak created in OpenZaak must publish a notification NRC fans out to
subscribers — before the Event Subscriber (#7) can consume it.
The OpenZaak↔NRC handshake is intricate and several details are non-obvious; they
were nailed down by iterating `setup_configuration` against the running stack.
## Decision
**Provision both sides declaratively via `setup_configuration`, authenticate with the
existing `big-reference-seed` client, and run NRC's celery-beat so deliveries happen.**
- **OpenZaak** (`infra/openzaak/setup_configuration/data.yaml`): a `zgw_consumers`
service `nrc` (api_type `nrc`, the NRC API root) plus `notifications_config` naming
it. `NOTIFICATIONS_DISABLED` is flipped to `false` **only when NRC is present**
the full stack and the local twin set it; OpenZaak-only bring-ups (`openzaak-up`,
the ACL integration test) default it back to `true` via `OZ_NOTIFICATIONS_DISABLED`
so they don't 500 publishing to an absent NRC.
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): the
`big-reference-seed` JWT credential (to verify OpenZaak's token), a `zgw_consumers`
`ac` service pointing at **OpenZaak's Autorisaties API**, the `autorisaties_api`
step delegating authorization to that AC, and the `zaken` kanaal. NRC's init
container switches from `migrate` to `/setup_configuration.sh`; its data.yaml is
delivered through the `rr-nrc-config` external volume by `infra/seed-config.sh`
(the same `docker cp` pattern as OpenZaak — bind mounts don't reach the CI runner's
daemon).
- **celery-beat is required.** NRC accepts a notification and writes a
`ScheduledNotification`; a periodic `execute_notifications` task (celery-beat,
every `NOTIFICATION_SEC_INTERVAL`s) drains it to the worker for delivery. The lean
S-01 stack dropped beat — so notifications were accepted but never delivered. An
`nrc-beat` service is added to every compose; the interval is lowered to 5s.
Verification is a runner-safe smoke (`infra/run-notification-check.sh`): it seeds a
published BIG zaaktype, registers an abonnement to a webhook sink, creates a zaak, and
asserts the sink receives the `zaken`/`create` notification — all from containers
**inside** the compose network (ADR-0006). Locally it runs via `make verify-notifications`
(a throwaway oz+nrc stack); in CI it runs as the `verify-nrc` step of the consolidated
`verify-stack` job (one shared full-stack bring-up — issue #58).
## Consequences
- **Positive:** the walking-skeleton event path works end to end; #7 can consume real
notifications; the wiring is declarative and reproducible from a fresh `make`.
- **Gotchas captured (see gitea-actions-gotchas.md):**
- **Single-label hosts aren't URL-valid.** OpenZaak/NRC reject `http://openzaak…`
/`http://nrc-web…` in URLs they validate (Django `URLValidator`); the verify
harness reaches services and registers the sink callback **by container IP**.
- **Abonnement callbacks must enforce auth.** NRC probes the callback during
registration and refuses it (`no-auth-on-callback-url`) unless it returns 401
without the configured `Authorization`; the sink enforces a bearer token.
- **Cost:** an extra long-running service (`nrc-beat`) per stack, and the verify job
needs egress (base images + `selectielijst.openzaak.nl`, since the published
zaaktype the check creates a zaak against depends on it — ADR-0006).
- **Dev-only credentials** reused (`big-reference-seed` / its secret) across publish,
AC lookup, and seeding — acceptable for the reference app, not production.
## Alternatives considered
- **NRC with its own (non-AC) authorization** — rejected: delegating to OpenZaak's
Autorisaties API is the upstream-intended model and reuses the applicatie that
already grants `heeft_alle_autorisaties`.
- **Keep beat out, deliver synchronously** — not an option: Open Notificaties 1.16
delivers via scheduled notifications drained by beat; there is no sync path.
- **A persistent abonnement in `setup_configuration`** instead of registering one in
the verify harness — deferred: the real subscriber is #7; the harness's sink
abonnement is throwaway and IP-specific.
@@ -0,0 +1,89 @@
# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader
- **Status:** Accepted
- **Date:** 2026-06-30
- **Deciders:** Respellion engineering
- **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo
## Context
S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes
NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton
projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status`
and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable**
(a derived artefact, never a write-only source of truth).
Two design questions had no obvious answer:
1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the
zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`).
It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may
do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`,
not the ACL.
2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api
reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its
schema." Two deployables on one table looks like a violation.
## Decision
**One Postgres database is the read projection. The Event Subscriber writes it (projector) and
the projection-api reads it (query); both are processes of the single "Read Projection" bounded
context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is
deferred.**
- **Schema ownership.** The read model — `register_projection` plus the subscriber's
`processed_notifications` log — lives in `services/projection-api/Projection.ReadModel`
(EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split
(one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5
forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still
holds for every domain database.
- **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key
derived from the immutable notification content). A duplicate insert raises a unique violation,
caught and reported as "already recorded", so the duplicate never reaches the projection. The
projection upsert is itself idempotent on the zaak id, a second line of defence.
- **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection`
and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access
(§8.1) and no ACL dependency — keeping S-06 within its stated scope.
- **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice
populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data
requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape
is in place so that change is additive.
- **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a
migrated relational schema, LINQ queries, and a clean port implementation. What we'd write
instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency
graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for
migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the
audited, shipped graph.
The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`,
the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real
Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all
in-network, reaching services by container IP (ADR-0006/0007).
## Consequences
- **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable
without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is
ready for S-09 to tighten public-safe field filtering.
- **Negative / deferred:**
- `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL.
- The abonnement is registered by the verify harness (by container IP), not provisioned
persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service
host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked
as a follow-up; a plain `make up` therefore needs the abonnement registered before the event
path flows.
- Two services share one database. Acceptable for a derived read model; revisit if the read and
write sides ever need independent scaling or storage.
## Alternatives considered
- **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks
to ZGW) and would need its own ADR to bend the rule.
- **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but
it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward;
deferred to a follow-up.
- **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** —
rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no
current benefit over a shared, rebuildable read model.
- **Separate databases for the log and the projection** — rejected as premature: both are the read
side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.
@@ -0,0 +1,88 @@
# ADR-0009: The Domain Service drives Flowable as an external-task job worker
- **Status:** Accepted
- **Date:** 2026-06-30
- **Deciders:** Respellion engineering
- **Relates to:** S-05 (#6); proposal #60; builds on ADR-0001 (loose coupling, §8.1/§8.2), S-03 (#4, the `registratie` BPMN), S-04 (#5, the ACL `OpenZaak` operation)
## Context
S-05 (#6) adds the **BIG Domain Service**. Submitting a registration must: create a
`Registration` aggregate, **start the Flowable `registratie` process** (S-03), have the
`OpenZaakAanmaken` task **open a zaak via the ACL** (S-04), and store the resulting zaak URL
back on the aggregate.
`OpenZaakAanmaken` is a Flowable **external-worker** service task (`flowable:type="external-worker"`,
topic `OpenZaakAanmaken`). Flowable does not push it anywhere — it parks the job and waits for a
worker to **acquire and lock** it, do the work, and **complete** it. Two coupling rules constrain
who may do what:
- **§8.2 — the Workflow Client is the only code that talks to Flowable.** BPMN models never embed
OpenZaak knowledge; they ask the Workflow Client to execute external tasks.
- **§8.1 — the ACL is the only code that talks to ZGW.** The worker opens the zaak *through the ACL*,
never by constructing ZGW URLs itself.
This is an ADR-worthy moment (§14): a service boundary is defined and both coupling rules are
exercised. The open question is *how* the external task is driven.
## Decision
**The Domain Service drives the `OpenZaakAanmaken` task as a hosted external-task job worker
(PRD §36). Orchestration is eventually consistent, not request-synchronous.**
- **`POST /registrations` is fast and side-effecting only on the domain side.** It creates the
`Registration` aggregate in state `INGEDIEND`, persists it, and asks the Workflow Client to start
one `registratie` process instance, recording the process-instance id on the aggregate. It returns
immediately; it does **not** wait for the zaak to be opened.
- **A hosted worker polls Flowable for `OpenZaakAanmaken` jobs.** It acquires and locks a job, calls
the ACL `OpenZaak` operation (§8.1), attaches the returned zaak URL to the matching aggregate
(`Registration.AttachZaak`), and completes the job in Flowable. The process then runs to its end
event.
- **The Workflow Client is the only Flowable client (§8.2).** It lives in the Domain Service's
`Infrastructure` layer and speaks Flowable's REST API (start process-instance; acquire/lock/complete
external-worker jobs). No other code — not the Application layer, not the BPMN — knows Flowable
exists.
- **The worker *logic* is an Application service over ports**, not Flowable-aware code. `OpenZaakWorker`
takes an acquired job (topic + the registration id it carries), calls `IAclClient` and
`IRegistrationStore`, and returns the zaak URL to complete with. The **polling loop** is a thin
`BackgroundService` in `Infrastructure` that fetches jobs via the Workflow Client and feeds them to
the worker. So the orchestration is covered by fast unit tests against fakes; only the REST framing
needs a container integration test.
## Scope decisions for the minimal slice
- **Registration persistence is in-memory.** The walking skeleton's *read* path is fed by
NRC → Event Subscriber → projection (S-06, #7), not by the domain database. An EF-backed domain
store buys nothing the demo needs yet, so it is a documented follow-up; the `IRegistrationStore`
port keeps that change additive. (PRD §88 envisions EF Core for the domain DB eventually.)
- **The aggregate's state machine is minimal:** `INGEDIEND` on submission. Later flows (withdrawal,
beoordeling, herregistratie) add states in their own slices — they are out of scope here.
- **No bsn flows to ZGW yet.** The ACL `OpenZaak` operation already default-fills the ZGW-mandatory
fields (ADR-0003) and takes the bsn as its domain payload; the domain hands it through unchanged.
## Consequences
- **Positive:** the submit request is decoupled from ACL/OpenZaak latency; the documented Common
Ground pattern (external-task worker) is realised; both coupling rules (§8.1, §8.2) hold with the
Flowable knowledge isolated to one Infrastructure class; the orchestration is unit-testable.
- **Negative / deferred:**
- Eventual consistency: immediately after `POST /registrations` the aggregate has no zaak URL yet.
Acceptable — the read side is the projection, not the domain store.
- In-memory registration state is lost on restart; fine for the skeleton, replaced by an EF store
in a follow-up.
- The worker polls (no push); poll interval is a tuning knob, not a correctness concern, since
Flowable holds the job until completed.
## Alternatives considered
- **Synchronous acquire+complete inside the `POST /registrations` request** — rejected: simpler and
deterministic, but couples the submit request to ACL/OpenZaak latency and failure, and is not the
external-task worker pattern PRD §36 mandates. It would also make the request fail if OpenZaak is
briefly down, instead of the job simply staying parked for the worker to retry.
- **A standalone Workflow Client service, separate from the Domain Service** — rejected for this
slice: the worker needs the domain's aggregate store and the ACL client anyway, and PRD §9 places
the Workflow Client inside the Domain Service deployment. A separate process adds a hop and a
shared store for no current benefit.
- **Flowable pushes to a webhook instead of being polled** — rejected: Flowable's external-worker
model is pull-based (acquire/lock/complete); a push shim would re-implement it with weaker
delivery guarantees.
+74
View File
@@ -0,0 +1,74 @@
# ADR-0010: The BFF validates Keycloak tokens and is the portals' only backend
- **Status:** Accepted
- **Date:** 2026-07-01
- **Deciders:** Respellion engineering
- **Relates to:** S-07 (#8); proposal #63; builds on ADR-0001 (loose coupling, §8.3), S-02 (#3, Keycloak realms), S-05 (#6, Domain Service), S-06 (#7, read projection)
## Context
S-07 (#8) adds the **BFF (Backend-for-Frontend)** — the single backend the Angular portals talk
to (CLAUDE.md §8.3). For the walking skeleton it exposes two endpoints and fans out to services
already built:
- `POST /self-service/registrations` → Domain Service `POST /registrations` (S-05).
- `GET /openbaar/register?q=…` → projection-api `GET /register` (S-06).
It must validate tokens issued by Keycloak (S-02). This is an ADR-worthy moment (§14): a new
dependency (JWT bearer authentication) and two new service boundaries (BFF→domain, BFF→projection).
## Decision
**The BFF is the portals' only backend; it validates Keycloak `digid`-realm JWTs on the
self-service endpoint, leaves the openbaar lookup anonymous, and fans out to the domain and
projection over typed HTTP clients.**
- **Auth model.** `POST /self-service/registrations` requires a valid `digid`-realm bearer token;
the BFF reads the `bsn` claim and forwards it to the domain. Missing / invalid / expired token →
**401**. `GET /openbaar/register` is **anonymous** — the openbaar register is a public lookup
(S-09), so no token is required.
- **Portals talk only to the BFF (§8.3).** They never call the Domain Service, ACL, projection, or
OpenZaak directly. The BFF orchestrates via typed `HttpClient`s whose base URLs come from config.
Downstream calls are unauthenticated on the internal network for the walking skeleton; a
service-to-service auth story (e.g. client-credentials) is a later slice, not this one.
- **Validation is `Microsoft.AspNetCore.Authentication.JwtBearer`** pointed at the Keycloak `digid`
realm authority. **New dependency justification:** it gives us standards-based OIDC/JWT validation
(signature, issuer, expiry, audience) maintained by the framework; rolling our own JWT validation
would be error-prone security code; the risk is a first-party ASP.NET Core package — minimal.
- **Tests mint their own tokens.** `WebApplicationFactory` tests override the bearer options with a
**test signing key**, so valid / invalid / expired tokens are minted in-process without a live
Keycloak. Real Keycloak validation is exercised by a live-stack `verify-bff` check.
- **OpenAPI is generated and committed** (`services/bff/openapi.json`) from .NET's built-in OpenAPI,
so S-08's Angular client is generated from the spec, never hand-written (§10).
## Known wrinkle — container OIDC issuer mismatch
Keycloak stamps tokens with an `iss` equal to its **browser-facing** URL (what the portal used to
log in), which differs from the BFF's **in-container** authority (`http://keycloak:8080/realms/digid`).
Strict issuer validation then rejects otherwise-valid tokens. Unit tests avoid this (test key).
`verify-bff` handles it by aligning the configured authority/issuer with the token's `iss` (and, if
needed, disabling metadata address rewriting). Recorded so it is not rediscovered each time.
## Consequences
- **Positive:** the walking skeleton gains its front door; §8.3 holds with all portal traffic going
through one backend; token validation is standard and testable without infra; the committed
OpenAPI unblocks S-08.
- **Negative / deferred:**
- Downstream service-to-service auth is deferred (internal-network trust for now).
- The openbaar endpoint is anonymous; when public-safe field filtering tightens (S-09) it stays
anonymous but the projection query narrows.
- The issuer-mismatch handling is dev-oriented; a production reverse-proxy setup would align the
browser and internal issuer URLs instead.
## Alternatives considered
- **Token-gate the openbaar endpoint too** — rejected: the openbaar register is public by design
(S-09); requiring a login would contradict the slice's intent.
- **Validate tokens by calling Keycloak's introspection endpoint per request** — rejected: adds a
network hop per call and a Keycloak dependency on the hot path; local JWT signature validation via
the realm's JWKS is the standard, faster choice.
- **Hand-written JWT parsing** — rejected: security-sensitive code we shouldn't own when a
first-party validator exists.
- **Generate the OpenAPI client by hand / keep the spec uncommitted** — rejected: §10 requires a
generated client from a committed spec.
@@ -0,0 +1,64 @@
# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone
- **Status:** Accepted
- **Date:** 2026-07-13
- **Deciders:** Respellion engineering
- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker)
## Context
The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register,
but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly
visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a
**temporary admin endpoint** on the Domain Service.
Two decisions are non-obvious (§14) and cross service boundaries:
1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the
domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs.
2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over
NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an
NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does
not carry the statustype.
## Decision
**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the
notification's own fields on the read side.**
- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a
repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then
advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it.
- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's
**eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and
POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW
translation (§8.1, ADR-0003).
- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on
it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created,
flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton
the only status ever set after creation is the approval — so it never has to read OpenZaak to learn
the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild
reproduces the right status.
## Consequences
- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW
statustype knowledge lives only in the ACL.
- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW
resource, which is all a rebuild needs to reproject the status.
- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval.
- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the
sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+),
the subscriber must distinguish statustypen. The honest options then are to carry the statustype
omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded
here so future-me revisits this rather than assuming it generalises.
## Alternatives considered
- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected:
couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus
discovery keeps the ACL self-contained for one extra ZGW GET per approval.
- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected:
violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up.
- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log
should retain notification *facts*, not projection semantics; the mapping stays in the projector.
@@ -0,0 +1,104 @@
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
- **Status:** Accepted
- **Date:** 2026-07-14
- **Deciders:** Respellion engineering
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
## Context
A citizen submits through the self-service portal and is shown a confirmation with a
**reference** so they can find their registration back in the public register. But the two
sides showed **different identifiers**:
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
exists.
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
assigned by OpenZaak when the ACL opens the zaak.
These never match, so the reference on the confirmation was useless for looking the entry up.
The two identifiers live on opposite sides of the ACL boundary and are generated by different
systems at different times, so there is no way to reconcile them after the fact without a
correlating value carried across the boundary.
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
This is that follow-up, scoped to the one field the citizen actually needs.
## Decision
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
with the reference by reading the zaak through the ACL, and stores it in the replay log so
rebuild stays log-only.**
Concretely, following the request path:
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
3. **Projection + replay log.** The reference is written both to the `register_projection` row
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
without re-reading the ACL.
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
from the submit confirmation must appear as a cell in the public register.
### Why HTTP to the ACL, not the ACL as a library
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
## Consequences
**Positive**
- One reference, end to end: the citizen's confirmation value is exactly what the public
register shows and searches by.
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
OpenZaak.
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
so `/admin/rebuild` needs no ACL/ZGW access.
- The column additions are nullable and additive; older rows without a reference are tolerated.
**Negative / costs**
- A new coupling: the Event Subscriber now depends on the ACL being reachable
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
need the notification redelivered (NRC already redelivers; the projection upsert is
idempotent).
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
one small GET per zaak, off the citizen's request path.
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
setting it explicitly is now load-bearing.
## Alternatives considered
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
the notification content is not ours to extend; it would also couple the projection to a
bespoke notification shape.
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
no zaak id to show at submit time.
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
source of truth for a value that can simply be the same on both sides.
@@ -0,0 +1,76 @@
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
- **Status:** Accepted
- **Date:** 2026-07-15
- **Deciders:** Respellion engineering
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
## Context
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
the whole slice.
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
`digid`-realm JWT validation rejects a medewerker token outright.
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
task; the decision must advance it, and also apply the domain transition (ADR-0011).
## Decision
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
completes the Flowable task.**
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
decision acts on a real task with no separate correlation store. The read projection stays the
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
data (both deferred in ADR-0008) just to render a staff view.
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
here so the boundary is decided up front.
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
endpoint + task completion.
## Consequences
**Positive**
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
(§8.3).
**Negative / costs**
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
## Alternatives considered
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
enough at this size and keeps §8.3 simple.
@@ -0,0 +1,72 @@
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Respellion engineering
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
(behandel-portal wiring, the Beoordelen user task)
## Context
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
Two questions shape this sub-slice.
1. **How does the case get cancelled — in code, or in the BPMN model?**
2. **How does a withdrawal correlate to the right running process instance?**
## Decision
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
Targeting the subscribed execution (not the user task's execution — a message boundary event's
subscription lives on its own execution) is what makes the correlation land.
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
completes its task best-effort.
## Consequences
**Positive**
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
that talks to Flowable and does not delete instances behind the model's back.
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
**Negative / costs**
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
reference at these volumes; a process-level interrupting event subprocess would close the gap and
is an additive follow-up if it matters.
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
(consistent with ADR-0009).
## Alternatives considered
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
not the process. The message event keeps the cancellation a first-class part of the workflow.
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
change if the race proves to matter.
@@ -0,0 +1,77 @@
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
pattern on `Beoordelen`).
## Context
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
Two forces shape this.
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
escalation happens alongside it.
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
(§8.2)" posture since ADR-0009.
## Decision
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
behandelaar group to teamlead.**
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
belongs to the teamlead; its history and variables are untouched.
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
`OpenZaakAanmaken` worker.
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
## Consequences
**Positive**
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
timer.
**Negative / costs**
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
on redelivery.
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
## Alternatives considered
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
surface to the engine for no capability the external-worker route lacks.
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
@@ -0,0 +1,77 @@
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
## Context
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
governing the registration workflow" (Flowable as a peer orchestration module).
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
back. That is one option; it is not the only one, and it is not the cleanest.
## Decision
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
only new job is to carry the diploma origin and pass it into the process as a start variable.**
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
`route` (`Buitenlands``CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
never evaluates the DMN and never learns the route — that is the process's concern.
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
— the resource is stored but no decision is created, so the service task fails at runtime with
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
With the decision present in the DMN repository, the process's DMN service task resolves it across
deployments by key (verified live), so no shared parent deployment id is needed.
## Consequences
**Positive**
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
framing); business users can read/adjust the decision table without touching domain code.
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
- The domain change is minimal and additive: one value on the aggregate, one start variable.
**Negative / costs**
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
cover only that the origin is carried into the process.
## Alternatives considered
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
the domain to Flowable for a decision and splits the routing across two places (domain computes,
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
@@ -0,0 +1,90 @@
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
## Context
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
aggregate stays in sync.
## Decision
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
between the gateway and Beoordelen.
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
without persisting again; an unknown registration throws so the job is redelivered.
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
behind the same action.
## Consequences
**Positive**
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
worker trio (worker + processor + pump), one Workflow Client method.
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
introduced in S-10a.
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
**Negative / costs**
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
through the process.
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
## Alternatives considered
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
status — the case would still look open.
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
@@ -0,0 +1,74 @@
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
expiry to #106 (S-10c).
## Context
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
## Decision
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
zaaktype.**
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers**
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
ZGW `inhoud`.
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
(a zeros-uuid placeholder otherwise, so the ACL still boots).
## Consequences
**Positive**
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
default, one seed block).
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
Playwright journey uploads a real PDF).
**Negative / costs**
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
diploma, but not a pattern to reuse for large documents without streaming/multipart.
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
## Alternatives considered
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
client, for no benefit at diploma size.
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
talks to ZGW).
@@ -0,0 +1,81 @@
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
- **Status:** Accepted
- **Date:** 2026-07-21
- **Deciders:** Respellion engineering
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
## Context
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
(§8.1, the only code that talks to ZGW).
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
## Decision
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
(name) rather than by position or the eindstatus flag alone.**
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
zaaktype's single `selectielijstProcestype` so they validate on publish.
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
registration that lapsed before its zaak was opened has nothing to cancel.
## Consequences
**Positive**
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
the change is additive and §8 stays clean (only the ACL talks to ZGW).
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
cancelled end-to-end.
**Negative / costs**
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
`Vervallen` resultaat) is a possible follow-up.
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
is documented in the gateway.
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
## Alternatives considered
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
the approval eindstatus resolution.
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
@@ -0,0 +1,92 @@
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
- **Status:** Accepted
- **Date:** 2026-07-22
- **Deciders:** Respellion engineering
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
## Context
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
could not complete the flow:
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
and the openbaar register stayed empty (S-06).
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
the compose file is written and cannot be a static value.
## Decision
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
identical to the CI `flowable-init`. Idempotent.
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
compose placeholders before the app reads config. The ACL `depends_on: local-seed
(service_completed_successfully)`.
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
cycle with the ACL bootstrap.
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
in the openbaar register — the red-to-green test for all three gaps.
## Consequences
**Positive**
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
the slice's stated outcome.
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
image and the CI stack are untouched.
**Negative / costs**
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
Mitigated by reusing the same underlying scripts and cross-referencing them.
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
running with a placeholder).
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
idempotent re-seed reports current-IP URLs.
## Alternatives considered
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
browser. Rejected.
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
@@ -0,0 +1,67 @@
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
- **Status:** Accepted
- **Date:** 2026-07-22
- **Deciders:** Respellion engineering
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
## Context
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
the compose file is written — every stack had to seed the catalogus and then capture + inject the
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed``acl.env` bootstrap
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
## Decision
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl``ZaaktypeIdentificatie`
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…`
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
re-resolves.
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
400.
## Consequences
**Positive**
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
self-configures from the catalogus it already talks to.
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
**Negative / costs**
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
(`http://openzaak:8000/…``zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
covers it (§5).
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
URLValidator) — unchanged from before.
## Alternatives considered
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
if the catalogus isn't published yet). Rejected in favour of lazy.
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
exactly what S-27 was carved out to fix.
@@ -0,0 +1,79 @@
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-17 (#18) · **Proposal issue:** #120
## Context
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
scan the register for inscriptions whose deadline is within the reminder window and
remind each one.
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
work as soon as it is parked, on a short poll interval. The reminder sweep is a
different shape of work: **time-triggered**, once a day, over our own store — there
is no queue to drain and no "as soon as possible" requirement.
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
## Decision
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
many reminders went out.
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
which the store query and the sweep both build on. The sweep marks each reminded
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
(§8.6).
Two options were rejected:
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
drifts to process-start time, has no cron/misfire semantics, and contradicts the
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
for.
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
continuously" work is churn and a boundary change for negative benefit. The
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
sweeps.**
## Consequences
**Positive**
- Cron scheduling with restart-stable timing and misfire handling, for free.
- The reminder rule is one domain method, reused by the store query and the sweep;
the scheduler owns none of the policy.
- The reference app now demonstrates the intended Scheduler component.
**Negative / costs**
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
two genuinely different concerns, documented here.
**Follow-up**
- The validity term (5 years) and reminder lead time (16 weeks) are domain
calibration knobs; promote them to beheer config (S-15) if a demo needs them
per-catalogus.
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
store is a later concern if the Domain Service is scaled out.
## Coupling rules touched (CLAUDE.md §8)
None. Quartz is internal to the Domain Service and drives an application use case
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
peer module.
@@ -0,0 +1,74 @@
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-16a (#122), first of the S-16 (#17) split
## Context
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
backplane must stand up first: a local, CI-friendly place for traces and metrics to
land, viewable in one UI, reaching green health within the 3-minute compose budget.
Two shape decisions are non-obvious enough to record.
## Decision
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
and ship the config baked into small built images.**
### Trace backend: Tempo (not Jaeger)
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
add a second UI and a second mental model for no benefit at this scale.
### No OTLP collector
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
If we later need fan-out, tail sampling, or log processing, a collector is an
additive change — the services already speak OTLP.
### Config baked into built images, not config volumes
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
**verbatim** images and get their config streamed into external named volumes by
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
are **not** peer modules we must run verbatim, so we take the simpler path: a
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
and no Makefile sprawl.
### Verified, not assumed
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
method) — so the check proves the datasources are actually wired, not merely that
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
itself, so no in-image healthcheck tool is required.
## Consequences
**Positive**
- One UI for traces + metrics + (future) logs. Config is versioned in
`infra/observability/` and self-contained in the images.
- Backplane is independent of app instrumentation — #123 and #124 build on it.
**Negative / costs**
- Three more images built each CI run (kept small; not on the health-gate list).
- Storage is ephemeral container fs — a demo backplane, not a retention target.
Object storage for Tempo / remote-write for Prometheus is a later concern.
## Coupling rules touched (CLAUDE.md §8)
None. The stack is passive infrastructure: services *push* OTLP and *expose*
`/metrics`; nothing in the stack calls into a service or a peer module.
-46
View File
@@ -1,46 +0,0 @@
# FDS-architectuur — Open Register
Deze map bevat de architectuurbesluiten en de engineer-documentatie voor de FDS-kant van deze
referentie-applicatie: deelnemen aan het Federatief Datastelsel als **afnemer**.
De strategische inzet, de slices en de portfoliostatus staan in het Innovation Lab-repo,
`Respellion/innovation-lab`, onder `projects/open-register-fd/`. Daar staan ook de
architectuurblauwdruk, de FDS gap-analyse en de privacy-views.
## Documenten
| Document | Waarvoor |
|---|---|
| [`c4-component-view.md`](c4-component-view.md) | Componentview op niveau 3: ports en adapters, en welke views nog waarde toevoegen |
| [`slice-1-proposal.md`](slice-1-proposal.md) | Het bouwbare eerste increment; plak dit in een `poc-voorstel`-issue |
| `adr/` | De geaccepteerde architectuurbesluiten, ADR-0001 tot en met ADR-0006. Zie de tabel hieronder. |
## Architecture Decision Records
Een ADR legt een besluit vast dat **vaststaat**, met de context en de gevolgen, zodat het niet stil
opnieuw wordt uitgevochten. Statuswaarden: `proposed``accepted` → (`vervangen door ADR-NNNN` |
`deprecated`).
Een geaccepteerde ADR wijzigen betekent een nieuwe ADR schrijven die de oude vervangt. Wij
herschrijven de historie nooit.
ADRs liggen naast governance. Acceptatie volgt de asynchrone bezwaarronde uit
`Respellion/innovation-lab`, `operating-model/operating-model.md`, sectie *Besluitvorming*.
| ADR | Besluit | Status |
|---|---|---|
| [0001](adr/0001-acl-at-every-register-boundary.md) | Anti-Corruption Layer op elke registergrens | accepted |
| [0002](adr/0002-fsc-for-connectivity.md) | FSC voor connectiviteit tussen organisaties, geen ruwe REST | accepted |
| [0003](adr/0003-pbac-via-opa.md) | Policy-based access control via OPA, FTV-klaar | accepted |
| [0004](adr/0004-bounded-cache.md) | Begrensde cache; registers blijven systeem van registratie | accepted |
| [0005](adr/0005-ldv-verwerkingenlog.md) | Verwerkingenlog via event-emissie, in lijn met LDV | accepted |
| [0006](adr/0006-module-boundary-and-reuse.md) | Modulegrens en hergebruikstrategie: in-process → .NET-module → OpenMetadata-feed → gateway op verzoek | accepted |
## Nummering
Deze reeks staat los van de ADR-reeks over de referentie-applicatie zelf, die begint bij
[`adr-0001-loose-coupling.md`](../adr-0001-loose-coupling.md). Vandaar de eigen map `fds/`: beide
reeksen beginnen bij 0001, en de nummers zouden anders botsen.
Nieuwe FDS-ADR: kopieer [`adr/template.md`](adr/template.md), neem het volgende nummer, en open een
pull request.
@@ -1,42 +0,0 @@
# ADR-0001: Anti-Corruption Layer op elke registergrens
- **Status:** accepted
- **Datum:** 2026-06-13
- **Deciders:** Lab Circle (Build, Lead Link)
- **Vervangt / vervangen door:**
## Context
De applicatie bevraagt meerdere registers: BRP, NHR/KVK, en ZGW via OpenZaak. Hun vocabulaires en
schema's verschillen van elkaar en van ons domein. Zij veranderen ook zelf mee met de FDS-standaarden.
Lekt registervocabulaire het domeinmodel in, dan werkt elke wijziging aan de registerzijde door in de
bedrijfslogica. Het domein wordt dan een lappendeken van vreemde begrippen in plaats van ubiquitous
language.
## Besluit
Elk register is bereikbaar via een Anti-Corruption Layer: **één adapter per register**, die een
**port** vervult die het domein definieert.
Adapters doen alleen vertalen en velden versmallen. Zij bevatten geen bedrijfslogica. Het domein
spreekt `Persoon` en `Organisatie`, en nooit veldnamen uit BRP of NHR.
## Gevolgen
**Positief:** verloop in registers en FDS-standaarden blijft bij de adapter. Het domein blijft stabiel
en testbaar. Adapters zijn onafhankelijk vervangbaar, en dat is precies wat de FSC-wissel uit
ADR-0002 goedkoop maakt. Het patroon generaliseert naar een herbruikbare ACL-template per register,
een Foundations-kandidaat.
**Negatief en kosten:** één vertaalmap per register om te schrijven en te onderhouden, plus een extra
indirectie die engineers moeten respecteren in plaats van omzeilen.
**Vervolgwerk:** extraheer de ACL-template zodra de tweede adapter bestaat (slice 3).
## Overwogen alternatieven
- **Registers direct aanroepen uit de applicatieservices** — afgewezen: dit koppelt bedrijfscode aan
registerschema's en aan versies van FDS-standaarden.
- **Eén generieke registeradapter** — afgewezen: registers verschillen genoeg dat een generieke
abstractie zou gaan lekken of opzwellen. Adapters per register zijn duidelijker.
@@ -1,44 +0,0 @@
# ADR-0002: FSC voor connectiviteit tussen organisaties, geen ruwe REST
- **Status:** accepted
- **Datum:** 2026-06-13
- **Deciders:** Lab Circle, Upstream Liaison
- **Vervangt / vervangen door:**
## Context
Registerbevragingen kruisen een organisatiegrens naar systemen van bronhouders met
persoonsgegevens. Het FDS noemt Federatieve Service Connectiviteit (FSC, de opvolger van NLX) als de
richting voor connectiviteit: wederzijdse authenticatie op organisatieniveau, autorisatie
gecontroleerd tegen een contract en gehandhaafd bij de bron, en symmetrische transactielogging.
Een ruwe REST-client met mTLS geeft ons geen van de contractadministratie, delegatie of onafhankelijke
tweezijdige verantwoording die een FG of auditor nodig heeft.
## Besluit
Het FSC Client-component stuurt alle registerbevragingen via een **FSC outway**, de
EUPL-referentie-implementatie. De ACL-adapter hangt af van de FSC Client, en niet van een HTTP-client.
FSC-zaken — contracten, identiteiten, delegatie — leven in dit component, achter de Register Port.
## Gevolgen
**Positief:** de autorisatie wordt bij de bron gehandhaafd, en niet op gezag van de aanroeper
vertrouwd. Onweerlegbaar loggen aan beide uiteinden maakt onafhankelijke afstemming tegen ons LDV-log
mogelijk. Delegatie wordt expliciet meegedragen. Wij lopen in lijn met de FDS-richting, vóór er een
verplichting is.
**Negatief en kosten:** FSC is operationeel zwaarder dan een REST-aanroep — beheer van certificaten en
identiteiten, plus een outway die op De Werf moet draaien. De vergelijking FSC tegenover DSP loopt
binnen het FDS nog, dus sommige details kunnen schuiven.
**Vervolgwerk:** valideer het contract- en logginggedrag van de huidige fsc-nlx-implementatie
(slice 2). Herzie dit als het FDS voor DSP kiest; ADR-0001 houdt die wissel beperkt tot één component.
## Overwogen alternatieven
- **Ruwe REST met mTLS** — afgewezen: geen contractlaag, geen tweezijdig log, en het wijkt af van het
FDS.
- **Wachten tot het FDS FSC tegenover DSP heeft beslist** — afgewezen: de naad uit ADR-0001 laat ons nu
adopteren en later aanpassen. Wachten geeft het voordeel van vroege expertise weg.
@@ -1,44 +0,0 @@
# ADR-0003: Policy-based access control via OPA, FTV-klaar
- **Status:** accepted
- **Datum:** 2026-06-13
- **Deciders:** Lab Circle, FG (geconsulteerd)
- **Vervangt / vervangen door:**
## Context
Elke bevraging van persoonsgegevens uit BRP of NHR is een verwerking die een grondslag en een
begrensde doelbinding nodig heeft. Toegangsregels moeten handhaafbaar en auditeerbaar zijn, en
wijzigbaar zonder de bedrijfscode opnieuw uit te rollen.
De Federatieve Toegangsverlening (FTV) van het FDS beweegt naar policy-based access control, maar is
nog geen afgeronde standaard.
## Besluit
Introduceer een Policy Decision Point met Open Policy Agent (OPA). De applicatieservices roepen de
PDP aan — via een Authorisation Port en een PDP Client — **vóór elke registerbevraging**, en geven
rol, doel en grondslag mee.
Policies schrijven wij als code, **geversioneerd in Gitea**, en zij gaan via review naar productie. De
PDP staat zo gepositioneerd dat wij bij de komst van FTV alleen het policy-dialect opnieuw uitdrukken,
zonder de architectuurgrens te verplaatsen.
## Gevolgen
**Positief:** doelbinding en grondslag worden gehandhaafd, en niet alleen gedocumenteerd. De FG kan de
werkelijke regels in versiebeheer lezen, waardoor het verwerkingenregister en de gehandhaafde policy
naar elkaar toe groeien. Toegangswijzigingen zijn reviewbaar en gedateerd.
**Negatief en kosten:** BRP-autorisatiebesluiten correct modelleren is juridisch werk, geen
engineering. De PDP maakt de handhaving betrouwbaar, niet de policy juist. Daarnaast komt er een
component bij om te exploiteren.
**Vervolgwerk:** een promotiepijplijn voor policies in Gitea Actions. Policies opnieuw uitdrukken zodra
FTV stabiliseert. Een FG-review van de policy-set vóórdat er echte persoonsgegevens in komen.
## Overwogen alternatieven
- **Rolcontroles in de applicatiecode** — afgewezen: niet auditeerbaar, niet wijzigbaar zonder deploy,
en het verspreidt toegangslogica over de codebase.
- **Wachten op FTV** — afgewezen: de PBAC-vorm is al duidelijk. Nu OPA, later het FTV-dialect.
@@ -1,48 +0,0 @@
# ADR-0004: Begrensde cache; registers blijven systeem van registratie
- **Status:** accepted
- **Datum:** 2026-06-13
- **Deciders:** Lab Circle, FG (geconsulteerd)
- **Vervangt / vervangen door:**
## Context
*Data bij de bron* verbiedt het behandelen van registerdata als lokale bron van waarheid. Maar BRP of
NHR bij elke interactie bevragen is onpraktisch en vergroot de blootstelling.
Persoonsgegevens zijn de data die wij het minst willen opbouwen. Een onbegrensde cache wordt stil een
schaduwregister, met een onbeheerde bewaarverplichting als gevolg.
## Besluit
Een **begrensde cache** staat achter een Cache Port, beheerd door een Cache Manager. Vier grenzen
gelden.
| Grens | Wat die betekent |
|---|---|
| **Tijd** | Een TTL die aan het doel hangt |
| **Omvang** | Alleen de werkset van een actieve zaak |
| **Gezag** | Antwoordt nooit wat de bron niet zou antwoorden; geen systeem van registratie |
| **Adresseerbaarheid** | Gesleuteld op subject, zodat verwijderen op verzoek kan |
Purge-triggers: het verstrijken van de TTL, het sluiten van de zaak, en een verwijderingsverzoek.
## Gevolgen
**Positief:** de prestaties van een lokale kopie, zonder een onbevoegd register te worden. Bewaartermijn
en het recht op verwijdering zijn echte operaties, geen hoop. Dit is consistent met zowel
AVG-dataminimalisatie als FDS-data-bij-de-bron.
**Negatief en kosten:** de mapping van doel naar TTL is een beleidsbesluit, samen met de FG en de
autorisatievoorwaarden, en geen engineeringconstante. Die is dus makkelijk fout te krijgen. Daarnaast
komt de complexiteit van cache-invalidatie erbij.
**Vervolgwerk:** definieer het beleid voor doel naar TTL met de FG. Maak een toestandsdiagram voor de
levensloop van een cache-entry. Documenteer de aanvaardbare veroudering per register.
## Overwogen alternatieven
- **Geen cache; altijd de bron bevragen** — afgewezen: onpraktische latency en belasting, en meer
blootstelling per aanroep.
- **Een onbegrensde of algemene cache** — afgewezen: die wordt een schaduwregister, precies de
faalvorm waar de AVG en het FDS beide tegen duwen.
@@ -1,42 +0,0 @@
# ADR-0005: Verwerkingenlog via event-emissie, in lijn met LDV
- **Status:** accepted
- **Datum:** 2026-06-13
- **Deciders:** Lab Circle, FG (geconsulteerd)
- **Vervangt / vervangen door:**
## Context
AVG art. 30 vereist een register van verwerkingsactiviteiten. De FDS-bouwsteen Logboek
Dataverwerkingen (LDV) wijst naar een gestandaardiseerd verwerkingslog dat de burger kan bevragen.
Database-CDC met Debezium legt *datawijzigingen* vast, en niet *verwerkingsgebeurtenissen met
doelbinding*. Het is dus geen verwerkingenlog.
## Besluit
Elke registeradapter stuurt een **verwerkingsactiviteit-event** naar een eigen Redpanda-topic, via een
Verwerking Port en een LDV Emitter. Het event bevat: subjectcategorie, register, velden, doel en
doelbinding, grondslag, bevragende rol, en tijdstempel. **Nooit de opgehaalde waarden.**
Een projectie maakt het log bevraagbaar. De emissie is asynchroon, maar niet over te slaan: de adapter
die de Register Port vervult, is dezelfde code die het event uitstuurt.
## Gevolgen
**Positief:** het spoor voor art. 30 en LDV ontstaat als neveneffect van de bevraging, dus het kan niet
uit de pas lopen met de werkelijkheid. Het is af te stemmen tegen de tweezijdige logs van FSC
(ADR-0002). Het is onderscheidend in een tender.
**Negatief en kosten:** een topic en een projectie om te exploiteren. Het ontsluiten van het log naar
de burger valt buiten de huidige scope; wij produceren het log. Het eventschema vraagt governance.
**Vervolgwerk:** definieer het schema van het verwerkingsevent. Bouw de bevraagbare projectie. Sluit
aan op de LDV-standaard zodra die volwassen wordt; dit is een upstream-kandidaat.
## Overwogen alternatieven
- **Debezium-CDC hergebruiken als log** — afgewezen: dat legt datawijzigingen vast, en geen verwerking
met doelbinding. Verkeerde semantiek.
- **Synchroon loggen in het aanroeppad** — afgewezen: dat koppelt de latency van de bevraging aan het
log. Asynchroon maar niet over te slaan geeft zowel snelheid als garantie.
@@ -1,68 +0,0 @@
# ADR-0006: Modulegrens en hergebruikstrategie voor de governed-access spine
- **Status:** accepted
- **Datum:** 2026-06-13
- **Deciders:** Lab Circle (Lead Link, Build, Upstream Liaison)
- **Vervangt / vervangen door:**
## Context
De compliance-spine uit slice 1 bestaat uit de PDP-controle (ADR-0003), gegoverneerd uitgaand verkeer
via FSC (ADR-0002), emissie van het verwerkingenlog (ADR-0005), en de begrensde cache (ADR-0004),
allemaal achter ports (ADR-0001). Die spine is mogelijk breder herbruikbaar dan alleen in de
referentie-applicatie.
Er spelen twee hergebruikvragen: welke verpakkingsvorm kiezen wij, en hoe verhoudt de spine zich tot
andere omgevingen zoals het OpenMetadata-datagovernanceproject?
Twee verduidelijkingen bepalen het besluit.
1. **OpenMetadata is geen afnemer.** In het datagovernanceproject is het de catalogus- en
lineage-laag over (synthetische) data. Het bevraagt geen BRP of NHR. FSC of de begrensde cache
daarin inbouwen zou zinloos zijn. De juiste aansluiting is **integratie van de output van de
spine**, en niet het inbouwen van de spine.
2. **FSC en de begrensde cache zijn zaken die alleen een afnemer aangaan.** "Maak het herbruikbaar"
mag deze niet uitsmeren over componenten die geen registerdata bevragen.
Nu al een taalonafhankelijke gateway bouwen — vóórdat er een tweede, niet-.NET afnemer bestaat — zou
de valkuil van speculatieve architectuur herhalen, die wij voor de capability-laag al hebben
afgewezen.
## Besluit
Wij nemen een **vraaggestuurde reeks van vier stappen** aan. Elke stap hangt af van echte behoefte, en
niet van verwachte behoefte.
| Stap | Wat | Wanneer |
|---|---|---|
| 1 | **In-process bewijzen.** Bouw de spine als gewone componenten achter ports, binnen de .NET register-applicatie. Nog geen extractie. Doel: de compliance-invarianten één keer echt valideren. | Slice 1 |
| 2 | **Extraheren als .NET-module.** Zodra een tweede .NET-afnemer in zicht is, haal de spine eruit als een geversioneerde .NET-library of SDK. Dit is de ACL-template-extractie die het charter al plant. Herbruikbaar voor .NET-afnemers, en dat is genoeg voor register-reference en zijn broertjes. | Slice 3 |
| 3 | **De feed LDV naar OpenMetadata aansluiten.** Route verwerkingsevents uit de LDV-emitter naar OpenMetadata als access- en usage-metadata bij het geclassificeerde asset: wie las welk persoonsgegevensveld, met welk doel, hoe vaak. Optioneel laten classificatietags uit OpenMetadata terugstromen om veldminimalisatie in de ACL aan te sturen. Dit is de concrete brug tussen beide anchor-projecten: integratie, geen inbouw. | Na stap 2 |
| 4 | **Alleen op verzoek een taalonafhankelijke gateway bouwen.** Heeft een echte niet-.NET afnemer gegoverneerde registertoegang nodig, verpak de spine dan als zelfstandige sidecar of proxy met een dunne lokale API, met PDP, FSC-egress en LDV erachter. Niet eerder. | Op verzoek |
## Gevolgen
**Positief:** eigen software blijft minimaal. Hergebruik volgt op validatie in plaats van eraan vooraf
te gaan. Beide anchor-projecten krijgen een concreet, benoemd integratiepunt (stap 3). Zaken die
alleen een afnemer aangaan, blijven ingesloten.
**Negatief en kosten:** de .NET-module uit stap 2 dient geen niet-.NET afnemers. Dat aanvaarden wij,
omdat stap 4 dat geval dekt zodra het echt is. Stap 3 vraagt een afgesproken schema voor het
verwerkingsevent, stabiel genoeg voor OpenMetadata om te consumeren.
**Vervolgwerk:**
1. Neem stap 3 als expliciet integratiepunt op in beide projectpagina's in het Innovation Lab-repo:
`projects/open-register-fd/README.md` en `projects/openmetadata/README.md`.
2. Herzie de trigger van stap 4 bij elke portfolio-review. Bouw niet vooruit.
3. Regel governance op het schema van het verwerkingsevent; dat is een gedeelde afhankelijkheid van
stap 1 en stap 3.
## Overwogen alternatieven
- **De taalonafhankelijke gateway vooraf bouwen** — afgewezen: speculatieve architectuur voordat er een
tweede afnemer bestaat. De latency en de operationele kosten zijn niet te rechtvaardigen.
- **De spine in OpenMetadata inbouwen** — afgewezen: OpenMetadata is geen afnemer. Dit is een
categoriefout.
- **De spine permanent in-process houden, zonder extractie** — afgewezen: dat geeft het hergebruik
tussen projecten en applicaties weg, en dat is een kerndoel van de Open Register-inzet.

Some files were not shown because too many files have changed in this diff Show More