Compare commits

..
Author SHA1 Message Date
not e6aaed7c8c docs(k8s): ADR-0033 + the Talos deployment runbook (refs #25)
CI / build (pull_request) Successful in 1m9s
CI / lint (pull_request) Successful in 1m27s
CI / unit (pull_request) Successful in 1m37s
CI / frontend (pull_request) Successful in 3m16s
CI / mutation (pull_request) Successful in 6m20s
CI / verify-stack (pull_request) Successful in 9m58s
ADR-0033 records why one values-driven chart rather than 30 subcharts, the four
platform-forced deviations from compose, and the alternatives (kompose, bitnami
subcharts, ingress-nginx, Helm hooks for ordering, a laptop-side registry).

The runbook is the walkthrough as actually performed on a single-node Talos v1.14
VM under virt-manager, including the parts that bite: virt-manager ejecting the
install ISO on first shutdown, Talos 1.14 moving the install disk into its own
config document, the control-plane taint, and why the portals must be reached
over localhost (crypto.subtle needs a secure context for PKCE).
2026-09-04 17:51:21 +02:00
not 7a5840149c feat(k8s): Helm chart for the whole stack on a single-node cluster (refs #25)
One chart whose values.yaml is a near-literal transcription of
infra/docker-compose.yml, rendered by three generic templates (Deployment, Job,
Service) over a `workloads` map — so the two stacks can be diffed by eye instead
of by archaeology, and adding a service is a values edit.

Platform-forced deviations, each commented where it appears:
- `args`, never `command`: compose replaces the image CMD, Kubernetes replaces the
  ENTRYPOINT. The chart fails to render on `command`, because the symptom (postgres
  refusing to run as root, Keycloak exec-ing `start-dev`) is nothing like the cause.
- The four Django services apply their own setup_configuration in the web pod
  rather than in a separate init Job: both scripts migrate, and without compose's
  depends_on they race the same database.
- OpenZaak and Objecten are addressed by service FQDN, because Django rejects a
  single-label host in a URL — the reason compose passes container IPs around.
- NodePorts, no ingress; databases are emptyDir until persistence.storageClass is
  set, so the stack comes up on a cluster with no CSI driver.

The upstream config inputs stay in the repo and become ConfigMaps via
infra/helm/seed-configmaps.sh — the Kubernetes sibling of infra/seed-config.sh —
so the compose stack and the chart cannot fork. infra/helm/registry.yaml runs an
in-cluster registry because Talos cannot side-load an image and a laptop-side one
needs a root-level firewall change.
2026-09-04 17:51:21 +02:00
not 916d671d49 test(k8s): gate the Helm chart with a render + schema check (refs #25)
`make k8s-lint` runs `helm lint` plus a full `helm template`, so a values typo or a
malformed resource is caught without a cluster — the only automated check the chart
can have while CI has no Kubernetes to deploy into.

Red: there is no chart to lint yet.
2026-09-04 17:51:21 +02:00
not 2d40c84e2c docs(portals): ADR-0034 — Caddy serves the portals (refs #166)
Records the decision, the directive-order footgun that shapes the Caddyfiles, and
the measured cost (the images grew 75.7 MB → 90.6 MB). Also updates the three
frontend-decisions entries and the two other docs that named nginx.
2026-09-04 17:51:21 +02:00
not 4edcf00267 feat(portals): serve each portal with Caddy instead of nginx (refs #166)
nginx resolves a variable `proxy_pass` upstream itself, using only the `resolver`
directive and never the search domains in /etc/resolv.conf. That cost two
workarounds in one script: rewriting the resolver address for rootless podman
(Docker's 127.0.0.11 is wrong there), and injecting a full FQDN so the bare `bff`
name could resolve on Kubernetes at all.

Caddy dials its upstream per request through the system resolver, which reads
nameserver *and* search domains, so `reverse_proxy bff:8080` resolves on every
engine with no per-engine configuration — and it still starts before the BFF
exists and picks up its restarts. Both workarounds are deleted with the script.

Routing uses mutually-exclusive `handle` blocks, not a bare `try_files`: Caddy
sorts rewrites *before* reverse_proxy, so a top-level SPA fallback would rewrite
every API path to /index.html before the proxy saw it.
2026-09-04 17:51:21 +02:00
not 51d99855d1 test(portals): assert each portal proxies only its own endpoint group (refs #166)
The four portal proxy configs are near-identical, so a copy-paste slip is cheap to
introduce and expensive to find: proxying another portal's endpoint group hands a
browser an endpoint its token is not for, and the failure surfaces as a 401 three
services away. Asserts each portal proxies exactly its own groups to the BFF and
keeps the SPA fallback for Angular's client-side routes.

Red: the Caddyfiles it reads do not exist yet.
2026-09-04 17:50:50 +02:00
not d6b3f9764f fix(e2e): bound the Playwright run and make a failed login say why (closes #161) (#165)
CI / build (push) Successful in 1m9s
CI / lint (push) Successful in 1m27s
CI / unit (push) Successful in 1m32s
CI / frontend (push) Successful in 3m19s
CI / mutation (push) Successful in 6m24s
CI / verify-stack (push) Successful in 9m33s
## What & why

#161 is really two defects, and the second one is why the first was undiagnosable.

**A wedged suite consumed the job, and took the post-mortem with it.** Nothing bounded the
Playwright run, so CI stopped the job mid-suite — and `if: always()` does not survive that. Run
739's job metadata shows every step after the e2e as a **0-second failure** stamped at the kill:

```
14 failure  09:48:17 -> 10:14:54  Self-service e2e (Playwright …)
15 failure  10:14:54 -> 10:14:54  verify-stack check summary       ← if: always()
16 failure  10:14:54 -> 10:14:54  e2e spec summary                 ← if: always()
17 failure  10:14:54 -> 10:14:54  Dump container logs on failure   ← if: failure()
18 failure  10:14:54 -> 10:14:54  Tear down                        ← if: always()
```

So the per-spec summary, the container-log dump and the teardown never ran, and the log lost
whatever the killed process had buffered — leaving the single `✘` line the issue was filed from.
`globalTimeout` now makes Playwright stop and *report*: the JSON report is written and those steps
still get their turn. (A `timeout-minutes` on the job would have reproduced the same failure, so
there isn't one.) The "~24-minute gap" is that kill, not necessarily a hang — note run 739 shows
`run_attempt: 2`, and `concurrency.cancel-in-progress` kills an in-flight run on any re-run or push.

**A login that never got its form ate the 90-second test timeout.** Playwright actions auto-wait
until the *test* timeout, not `expect.timeout` — so a portal that serves its page but never
bootstraps (its `config.json` fetch or the OIDC discovery behind `authorize()` failed; `main.ts`
only `console.error`s) spent 90s to report `locator.fill: Test timeout of 90000ms exceeded`: the
symptom, not the cause. That is catalogus.spec's 1.8 minutes. Both Keycloak forms are now asserted
visible first, with a 20s budget and a message naming the step that never happened.

Verified against a real blank-bootstrap portal — the beheer image served with a `config.json` that
is not JSON — which fails in **20.2s** with *"the Keycloak login form never appeared — the portal
did not reach Keycloak (check its config.json fetch and the OIDC discovery …)"*.

**And the summary now says why.** The per-spec table (#136) rendered a verdict icon and nothing
else, so even a surviving summary cost a log dive. Failing specs now carry their first error,
flattened for a table cell (ANSI stripped, newlines collapsed, `|` escaped, clipped) — shape
verified against a real @playwright/test 1.61 failing report, with a stdlib assert self-check on
`make unit`.

Closes #161

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit follows (login helper dedup).
- [x] Conventional Commits referencing the issue (`refs #161`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (untouched).
- [x] Docs updated — `docs/runbooks/gitea-actions-gotchas.md` §9.
- [x] ADR — not needed: no boundary, dependency or coupling rule touched (test/CI infra only).
- [x] Demo note — not applicable: nothing user-visible.

## Notes for reviewers

**What this does not do: identify why the beheerder login failed that once.** The evidence to do
that was destroyed by defect 2, which is what this PR fixes. The suite ran green here five times
today (catalogus.spec 1.1–5.3s each) — but a local box is not the loaded CI runner, so that is weak
evidence and I am not claiming the flake is gone. What changes is that the next occurrence is
bounded and self-describing: it fails in 20s naming the failing step, the JSON report survives, and
the summary prints the error. Please keep #161 in mind rather than treating this as proof.

**Two follow-ups I did not pull into this PR:**
- *All four portals show a permanently blank page if their startup fetch fails* — `main.ts` does
  `fetch('config.json').then(bootstrap).catch(console.error)`, one shot, no UI and no recovery. That
  is a real product gap (the deliberately-broken portal above is exactly what a user would see) and
  wants its own slice, not a test-infra PR.
- `retries: 1` is untouched. CLAUDE.md §15 says flaky tests are fixed rather than retried, but
  removing retries while a real flake is unexplained would trade a rare red for a frequent one.
  Worth revisiting once #161 recurs (or doesn't) with the new diagnostics.

The login-helper rename (`medewerker-login.ts` → `keycloak-login.ts`, citizen logins routed through
`loginBurger`) is its own no-behaviour-change commit: the three citizen specs each duplicated the
same three-line login, so guarding the login path once meant routing them through it first.Reviewed-on: #165
2026-09-04 10:53:35 +00:00
not 8b206a005f S-26/#162 · Werkbak refreshes itself when a registration is ready for beoordeling (#164)
CI / build (push) Successful in 1m8s
CI / lint (push) Successful in 1m23s
CI / unit (push) Successful in 1m27s
CI / frontend (push) Successful in 3m8s
CI / mutation (push) Successful in 6m13s
CI / verify-stack (push) Successful in 10m12s
## What & why

The behandel werkbak now **refreshes itself** while it is open, so a registration that reaches
beoordeling after the behandelaar opened the page shows up on its own — no reload.

`interval(WERKBAK_REFRESH_MS)` (5 s) re-reads the existing BFF endpoint, scoped to the page with
`takeUntilDestroyed()`. A *background* read leaves the rows and states on screen alone until it has
an answer, so a tick never flashes the loading state over rows being read and one failed poll never
swaps the list for the error alert; a read that comes back also clears an earlier failure, so the
view recovers on its own rather than needing the very reload this slice removes.

No new endpoint, dependency or server-side state, and no service boundary moves — rxjs and
`GET /behandel/werkbak` are both already here. **ADR-0032** records why polling rather than a pushed
stream: nothing notifies the BFF either, so SSE/WebSockets would poll the domain *inside* the BFF for
the same freshness, plus connection lifecycle, nginx buffering and a stateful BFF. Proposal: #163.

Closes #162

## 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 #162`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (unchanged; only the behandel bundle differs).
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added in `docs/architecture/` (ADR-0032).
- [x] Demo note in `docs/demo-script.md` (user-visible).

## Notes for reviewers

**The e2e is the real acceptance test, and it took two goes to make it one.** Simply dropping the
`staff.reload()` from the happy path proved nothing: the werkbak was visited *after* the documents
were supplied, so the row was already there at page load. The spec now logs the behandelaar in
**first**, asserts the row is not there yet, and only then has the citizen supply the documents that
route it to Beoordelen — so the row can only reach that already-open, never-reloaded page via the
refresh. Verified both ways against a live stack: with the interval stubbed out it fails at
`Goedkeuren <ref> … element(s) not found` after 30 s; with it, the behandel nginx logs the poll that
delivers the row. The page is foregrounded before the assertion because Chromium throttles timers in
a hidden tab.

**Ceiling (named in the ADR):** a fixed 5 s interval, per open page, that keeps polling in a
background tab; each tick costs one Flowable task query plus a store read per open task. Upgrade
path: publish task events from the domain, then swap the `interval` for a stream — the endpoint
contract and the rendering stay put. Gate on `document.visibilityState` first if request volume is
the concern.

**Two housekeeping notes, neither blocking:**
- #162 is on **no milestone** (DoD item 1). It is portal UX, so it fits neither *Data Governance*
  nor *Production Posture* cleanly — your call where it lands.
- The issue titles itself **S-26**, which already belongs to the self-service resume slice (#111,
  `BACKLOG.md`). Everything here references **#162**; worth renumbering the title if the S-ids are
  meant to stay unique. `BACKLOG.md` is untouched for the same reason (it mirrors the active
  milestone, and this slice is on none).Reviewed-on: #164
2026-09-04 09:34:14 +00:00
not d0fb2b3e8c S-15c · Enforce MFA on the medewerker (Keycloak) realm (#158)
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m22s
CI / unit (push) Successful in 1m24s
CI / frontend (push) Successful in 3m5s
CI / mutation (push) Successful in 6m13s
CI / verify-stack (push) Successful in 8m39s
Closes #132.

Staff logins (behandel + beheer portals) now need a second factor; the citizen realms are unchanged.

**How:** every seeded medewerker carries a TOTP credential, which activates Keycloak's stock *conditional OTP* step in both the browser flow and the direct grant — no custom browser-flow JSON in the export. `CONFIGURE_TOTP` is a default required action so a medewerker added later must enrol first. ADR-0031 records the choice and, explicitly, that the shared fixture secret is a demo posture only.

**Tests (red first, 30c5279):**
- `check_realms.py` asserts the medewerker password-only grant is **refused**, then that password + TOTP succeeds and still carries the `behandelaar` role. It failed with `[MFA NOT ENFORCED]` against the old export.
- The three medewerker e2e logins move to `loginMedewerker()` (`tests/e2e/medewerker-login.ts`), which submits Keycloak's OTP prompt. Both TOTP implementations (Python `hmac`, Node `crypto`) are ~6 lines of RFC 6238 — no new dependency.

Verified locally against Keycloak 26.1: password-only → `invalid_grant`, password + code → 200, and the browser flow's `#otp` prompt accepts a computed code and issues an auth code.

## Definition of Done
- [x] Failing test/verify committed first; implementation makes it pass.
- [x] Conventional Commits referencing the issue (`refs #132`).
- [ ] CI green (verify-stack compose smoke + relevant checks).
- [x] `docker compose up` reaches green health within 3 minutes (Keycloak change is import-time only).
- [x] Docs touched (runbook, synthetic-data, demo-script) + ADR-0031 + demo note.
- [x] Closed by the merging PR (`closes #132`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #158
2026-09-04 08:27:52 +00:00
eho 321ee50dcb docs(architecture): import the FDS architecture decisions from the lab repo (closes #159) (#160)
CI / build (push) Successful in 1m36s
CI / lint (push) Successful in 1m45s
CI / unit (push) Successful in 2m1s
CI / frontend (push) Successful in 3m30s
CI / mutation (push) Successful in 8m4s
CI / verify-stack (push) Successful in 9m25s
## What & why

Brings the engineer-facing FDS documentation next to the code it describes. Imported from `projects/open-register-fd/` in `Respellion/innovation-lab` and translated to Dutch: **six ADRs**, the ADR index and template, the **L3 component view**, and the **slice-1 proposal**.

The architecture blueprint, the FDS gap analysis and the two privacy views stay in the lab repo — the OKRs cite them and they feed tender responses. Each side names the split in a "Wat ligt waar" table, so nothing is documented twice.

Closes #159

### Why `docs/architecture/fds/` and not `docs/architecture/`

This repo's own ADR series now runs `adr-0001-loose-coupling` … `adr-0010-bff-oidc`. The imported set is numbered 0001–0006, so a flat import would collide across the whole imported range. The subfolder preserves the imported numbering, and with it roughly thirty `ADR-000N` cross-references inside the imported text that would otherwise all need rewriting.

In the MkDocs sidebar the imported six appear as **FDS ADR-000N** so they are not confused with this repo's series. `docs/architecture/fds/README.md` explains the two series.

### Mermaid support was missing

`pymdownx.superfences` had no `custom_fences`, so the imported diagrams would have published to Gitea Pages as raw code blocks. This PR adds the mermaid custom fence, the nav group, and one link under *Where to go* in the docs index.

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. — n/a, documentation only.
- [ ] Implementation makes the test pass. — n/a, documentation only.
- [x] Conventional Commits referencing the issue (`refs #159`).
- [x] Rebased on current `main`; no conflicts.
- [ ] CI green — n/a for content; the docs verification is below.
- [ ] `docker compose up` reaches green health checks. — n/a, no runtime change.
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added in `docs/architecture/` if a non-obvious decision was made. — six imported, plus the numbering decision recorded in the folder README.
- [ ] Demo note in `docs/demo-script.md`. — n/a, nothing user-visible.

## Verification run

- `mkdocs build` — clean. No missing-nav warning for any `architecture/fds/` entry. The two remaining warnings are pre-existing on `main` and untouched here: the set of pages absent from `nav`, and a broken link in `runbooks/ci.md` to `services/acl/stryker-config.json`.
- Mermaid renders as a diagram, not a code block: `site/architecture/fds/c4-component-view/index.html` contains `class="mermaid"`.
- All relative markdown links in the repo resolve.

## Notes for reviewers

- **Language.** The imported documents are Dutch; this repo's own documents remain English. Deliberate, not an oversight — the lab repo standardised on Dutch and these pages moved with it. Translating the rest is a separate decision.
- **Ownership.** This repo sits in the `eho/` namespace while it now holds the canonical FDS architecture decisions that tender answers point at. Worth deciding whether it should move to `Respellion/`.
- **Scope drift, not fixed here.** The imported text is faithful to its source, so the slice-1 proposal and the ADRs assume NHR/KVK for slice 1, while the lab-side blueprint still uses BAG as its example register. The lab-side documents carry a banner about this; Blueprint v2 (slice 5) is where the diagrams get corrected.
- **Companion PR:** `Respellion/innovation-lab` #34 holds the lab-side half of this split.Reviewed-on: #160
2026-09-03 12:35:01 +00:00
not 94720f0fcb fix(observability): stop single-binary Tempo evicting its only ingester (closes #156) (#157)
CI / build (push) Successful in 1m3s
CI / lint (push) Successful in 1m21s
CI / unit (push) Successful in 1m23s
CI / frontend (push) Successful in 3m11s
CI / mutation (push) Successful in 6m19s
CI / verify-stack (push) Successful in 9m57s
## What & why

`verify-tracing` flaked on `verify-stack` run 722 — `FAIL — no single trace spanned ['bff', 'projection-api']` — and went green on a plain re-run of the same commit. **The trace chain was not broken; Tempo could not ingest:**

```
removing distributor_pool failing healthcheck addr=127.0.0.1:9095
  reason="rpc error: code = DeadlineExceeded"
pusher failed to consume trace data  err="context canceled"   (x18)
```

The root cause is the *mechanism* of the data loss, not whatever caused the stall. Tempo runs **single-binary**, so the distributor and the ingester are the same process and the distributor's ingester pool holds exactly one, in-process, member. dskit nevertheless health-checks that member over loopback gRPC with a **1 s** deadline (`checkinterval: 15s`, confirmed from the running image's `/status/config`). On the shared runner a transient stall blows the deadline, the only ingester is evicted from the pool, and every subsequent push fails until the next check interval — spans silently dropped.

With one in-process ingester the health check can **never** route around a failure. Its only possible effect is to discard data. So it is off:

```yaml
ingester_client:
  pool_config:
    healthcheckenabled: false
```

This lands at the point where *both* candidate triggers named in #156 (GC pressure near `mem_limit`, CPU contention from the grown stack) turn into lost spans, so **`mem_limit: 400m` is untouched** — raising it on a memory-tight runner risks reintroducing the `verify-e2e` OOM of #144. It also does not paper over anything the way a longer `TRACING_TIMEOUT` would (#156's own note).

Second change: `infra/tracing-check.py` prints `tempo_distributor_ingester_clients` on its failure path. From the check's side, Tempo-dropped-spans and missing instrumentation look identical — that ambiguity is what cost a container-log dive on run 722. A recurrence now names itself.

Closes #156

## Definition of Done

- [x] Linked Gitea issue (#156).
- [ ] **Failing test committed before the implementation — N/A, and deliberately so.** The trigger is runner load, so no deterministic red exists; the "red" is run 722's observed `verify-tracing` failure plus its Tempo logs. Same precedent as d5e5fa2 (#115, Playwright OOM) and 4aafd32 (#147, uWSGI caps). A test asserting the config says what the config says would add no gate: Tempo hard-fails on an unknown key (verified — `field health_check_enabled not found in type client.PoolConfig`), so a typo or a config rename on a Tempo bump already turns `verify-up` red.
- [x] Conventional Commits referencing the issue (`refs #156`).
- [ ] CI green — the point of the change.
- [x] `docker compose up` health unaffected (Tempo is not in `WAIT_SVCS`; config-only change, same image).
- [x] Docs updated — ADR-0023 Consequences.
- [x] ADR — amended **ADR-0023** rather than adding a new one: this is a consequence of that ADR's single-binary Tempo choice, not a new decision (one decision per ADR, §12).
- [x] Demo note — N/A, not user-visible.

## Notes for reviewers

Verified locally against the built image (the flake itself is not locally reproducible — see the runner-load point above):

1. `docker run --rm register-referentie/tempo:dev -config.file=/etc/tempo.yaml -config.verify=true` → parses.
2. `GET /status/config` on the running container → `healthcheckenabled: false` (was `true`).
3. The new diagnostic reads `tempo_distributor_ingester_clients` off a live Tempo.

Worth knowing: that metric is legitimately `0` on an idle Tempo — the pool is populated lazily on first push. It only prints on the failure path of a check that has already generated traffic, so the reading is meaningful there, but don't read a bare `0` on a quiet stack as an eviction.

Follow-up left undone: if `verify-tracing` still flakes after this, the next suspect is the .NET OTLP exporter timeout (#156's last note), not Tempo's memory cap.Reviewed-on: #157
2026-09-01 08:31:36 +00:00
not 94742a261f feat: read projection sourced from the register in Objecten (closes #153) (#155)
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m26s
CI / unit (push) Successful in 1m37s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 6m42s
CI / verify-stack (push) Failing after 11m26s
## What & why

S-19b-2, closing out ADR-0028's stated direction: **the read projection is now derived from the
`RegisterRecord` in Objecten, not from ZGW zaak events.**

Until now the subscriber listened on `zaken` and *inferred* register state from case events — a
`zaak/create` meant INGEDIEND, and any `status/create` was assumed to be the approval (it may not
read OpenZaak, so it could not tell statustypen apart). The reference wasn't in the notification
at all, so every projection made a second hop to the ACL. The register — a fact about a person —
was being reconstructed by guessing at the lifecycle of the case that produced it.

- The subscriber's abonnement moves to the `objecten` kanaal (S-19b-1 made it publish).
- An Objecten notification carries **no record data**, only the object URL, so the record is read
  back through the ACL (`POST /register-records/read`) — §8.1 applies to Objecten exactly as
  ADR-0028 established.
- The record carries `id`, `status` and `reference`, so the row *is* the record: `IsZaakCreated`,
  `IsZaakStatusSet`, `ZaakUrl`, `ZaakId` and `ToEntry`'s `Resource == "status"` inference are all
  gone, and so is the ACL enrichment hop.
- **The ACL now writes an INGEDIEND record on submit.** Without it, re-sourcing would silently
  drop every submitted registration from the public register, since only approval wrote a record.
- `processed_notifications` holds the projected row (`register_id`, `status`, `reference`) instead
  of the ZGW event, so a rebuild is a replay with no mapping rules and no upstream reads at all.

**ADR-0030** records it. ADR-0028's open caveat — record written but not yet read, "the two must
agree" — is closed: there is one source now.

Closes #153

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing tests committed before the implementation — two red/green pairs, ACL side
      (06c0444566ef7d) and subscriber side (142ed458af09b2).
- [x] Refactor commit follows (b496ac9).
- [x] Conventional Commits referencing the issue (`refs #153`).
- [x] CI green — all six jobs on b30fa66, `verify-stack` end to end including the e2e.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes
      (`verify-stack`'s bring-up step — see the wait-healthy fix below).
- [x] Docs updated — ADR-0030 added, ADR-0028's consequence + caveat annotated, BACKLOG.md,
      e2e header comment.
- [x] ADR added in `docs/architecture/`.
- [x] Demo note in `docs/demo-script.md` — n/a: no user-visible change. The openbaar register
      shows the same two statuses for the same registrations; only where they come from changed.

## Notes for reviewers

**The decision I'd most like a second opinion on** is the one the issue didn't settle: what
happens to INGEDIEND. Objecten held only INGESCHREVEN records, so re-sourcing forced a choice
between (a) the ACL also writing on submit, (b) a public register that lists only actual
registrations, or (c) a hybrid keeping both kanalen. I took (a): visible behaviour is unchanged
and the register holds the whole lifecycle. (b) is arguably the better *semantics* for a public
register but narrows what the portal shows and reads against PRD §68 ("~50 register entries with
diverse statuses"); (c) leaves the projection half-derived from ZGW, which is the coupling
ADR-0028 set out to remove. All three are laid out in ADR-0030.

**The dedup key is the projected row**, `objecten:object:{url}:{status}:{reference}` — not the
object URL (the ACL upserts *one object per registration*, so submit and approval notify about
the same URL and the approval would be swallowed as a duplicate) and not URL+actie (a retried
approval is a second `update`). Redeliveries collapse, genuine state changes don't. §8.6.

**The migration drops columns rather than renaming them.** EF scaffolded renames — `resource` →
`register_id`, `zaak_id` → `status` — which would have carried ZGW values into columns meaning
something else, and a rebuild would then have projected that garbage. It also empties both
tables: a pre-slice row describes a zaak event the new projector can't reproject, and those
registrations have no RegisterRecord in Objecten either, so they're not re-derivable from the new
source. Stated as a ceiling in the ADR — fine while stacks are ephemeral, backfill from Objecten
if a long-lived environment ever needs it.

**`run-projection-check.sh` now opens its zaak through the ACL** instead of straight against
OpenZaak, because the ACL is what writes the record. A zaak created behind the ACL's back
produces no projection row — that's the re-source working, not a gap.

## Three fixes CI found, none of them in the projection logic

1. **`wait-healthy.sh` matched the wrong container** (744f91a). Bring-up timed out with
   `TIMEOUT: 'objecten' not healthy (status=none)` while the `docker ps` it dumps showed
   objecten `Up 9 minutes (healthy)`. `--filter name=` is a substring match, so `objecten` also
   matches `objecten-db`/`objecten-redis`/`objecten-celery`, and `head -1` took whichever docker
   listed first — the celery worker has no healthcheck, hence `status=none`. Latent since those
   services landed and decided purely by listing order; `objecttypen` matches `objecttypen-db`
   the same way. Anchored on the compose replica suffix, which the verify scripts already do.
2. **The ACL had to be repointed at OpenZaak's IP** (7e0897a). Opening the zaak through the ACL
   put this check in the same bind run-domain-check.sh already handles:
   `400 {"name":"zaaktype","code":"bad-url","reason":"Voer een geldige URL in."}`. OpenZaak
   reflects the request Host into the zaaktype URL and then rejects it on zaak-create when
   single-label — the mechanism compose already documents on `ACL_OPENZAAK_BASEURL`.
3. **Approval arrives as `partial_update`, not `update`** (0dd26a7b30fa66) — the one real bug
   in the slice. The ACL upserts with PATCH; DRF routes it through the notifying `update()` but
   names the action `partial_update`, so the projector dropped every approval. Only the e2e could
   catch it: `verify-projection` drives a submit, and per ADR-0028 the e2e is the only check that
   drives a *real* approval.

`verify-tracing` also failed once (run 722) on a path this PR doesn't touch, and passed on a
plain re-run of the same commit. Tempo logged `pusher failed to consume trace data` /
`distributor_pool failing healthcheck` — it dropped spans under runner load rather than the trace
chain being broken. Filed as **#156** rather than absorbed here.

**Correction to the #152 PR notes:** I wrote there that celery concurrency was "the next knob" if
verify-stack got tight. It isn't — `CELERY_WORKER_CONCURRENCY` already defaults to 1 in the Maykin
image, so `objecten-celery` is already a single-process worker. Noted in #156.

**Possible follow-up, deliberately not done here:** an `openzaak.local` network alias mirroring
`objecten.local` would remove the ACL-repoint dance from both run-domain-check.sh and
run-projection-check.sh. It changes the host in every zaak URL the system produces, which is too
broad a ripple to land inside an unrelated slice — worth its own issue.

**Known costs, all in the ADR:** submission is now two writes across two modules and eventually
consistent (same posture ADR-0028 accepted for approval); projecting now depends on the ACL being
reachable on the main path, not just for enrichment (NRC retries, so it converges); and OpenZaak
still publishes to `zaken` with nothing in the product listening — kept because `verify-nrc`
asserts that path.Reviewed-on: #155
2026-09-01 07:26:33 +00:00
not 2125fb0cfd feat(infra): Objecten publishes register events to NRC (closes #152) (#154)
CI / build (push) Successful in 1m14s
CI / lint (push) Successful in 1m28s
CI / unit (push) Successful in 1m28s
CI / frontend (push) Successful in 3m8s
CI / mutation (push) Successful in 6m35s
CI / verify-stack (push) Successful in 9m27s
## What & why

S-19b-1. A write to the Objecten API now produces a **delivered** notification on the
`objecten` kanaal in Open Notificaties. ADR-0028 switched Objecten's notifications off on
purpose — there was no broker, worker, kanaal or abonnement, so wiring only the client side
would have dropped every message on the floor. This slice builds the real path and turns it
back on.

- `objecten-celery` worker (mirrors `oz-celery`) + `CELERY_BROKER_URL`/`RESULT_BACKEND` on
  objecten-redis db 1 (db 0 is already the cache). `notifications_api_common` only *queues*
  the send; without a worker every register write is silently undelivered.
- `nrc` service + `notifications_config` in Objecten's `setup_configuration`, reusing the
  `big-reference-seed` credential OpenZaak publishes with (NRC authorizes it via OpenZaak's
  AC, which grants it `heeft_alle_autorisaties` — no second credential needed).
- The `objecten` kanaal in NRC's `setup_configuration`. The name is fixed by the Objects API
  (`NOTIFICATIONS_KANAAL`), not chosen here; publishing to an unregistered kanaal is exactly
  what the red check reported first.
- `NOTIFICATIONS_DISABLED: "false"` in both compose files.
- Writers address Objecten as `objecten.local` — see *Notes for reviewers*.
- `make verify-objecten-notifications` — registers an abonnement on `objecten` pointing at a
  throwaway sink, writes a `RegisterRecord` exactly as the ACL does on approval, asserts the
  delivery. One assertion covering the whole chain: Objecten -> objecten-celery -> NRC ->
  nrc-beat -> callback. Wired into the CI `verify-stack` job and the summary table.

**ADR-0029** records the decisions; ADR-0028's ceiling now points at it.

Closes #152

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (dc9ca2c, red at the first hop:
      `NRC POST /api/v1/abonnement -> 400 "Kanaal met deze naam bestaat niet."`).
- [x] Implementation makes the test pass (4488962, + two fixes found by CI, below).
- [x] Conventional Commits referencing the issue (`refs #152`).
- [x] CI green — all six jobs on a5fd47e, including `verify-stack` end to end (e2e included).
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes
      (`verify-stack`'s bring-up step).
- [x] Docs updated — ADR-0029 added, ADR-0028's ceiling annotated, BACKLOG.md split.
- [x] ADR added in `docs/architecture/`.
- [x] Demo note in `docs/demo-script.md` if user-visible — n/a, infrastructure only; nothing
      consumes the kanaal until S-19b-2 (#153).

## Notes for reviewers

**The one genuinely non-obvious bit: writers address Objecten as `objecten.local:8000`, not
`objecten:8000`.** NRC types a notification's `hoofdObject`/`resourceUrl` as DRF `URLField`,
so Django's `URLValidator` runs on them — and it rejects a **single-label** host. Objecten
fills both from the object url DRF built with `request.build_absolute_uri`, i.e. *the Host
the caller used*. Writing via the plain service name returns 201 and then fails every
publish in the background, forever, with

```
400 {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
```

So the `objecten` service carries an `objecten.local` network alias and every writer uses it
— `Acl__Objecten__BaseUrl`, `ObjectenGatewayIntegrationTests`, this slice's verify driver.
An alias rather than a bare dotted `SITE_DOMAIN` so the host still *resolves*: a subscriber
following `resourceUrl` reaches the record, which S-19b-2 will do. Readers keep the plain
name. Same class of constraint as ADR-0028's Objecttypen base-URL rule.

**Ceiling, stated in the ADR:** nothing enforces the alias — a future writer using
`objecten:8000` gets a 201 and silently no notification. If a second writer ever appears,
rename the compose service rather than adding a lint.

**Two CI-only failures on the way here**, both worth knowing:
1. `SITE_DOMAIN` was my first guess at the mechanism and is simply not what builds those
   URLs — dropped in d76abf2.
2. The check correlated the delivery on the `reference` inside the record it wrote. An NRC
   notification carries `kanaal`/`resource`/`kenmerken`/`hoofdObject`/`resourceUrl` and
   **never the record data**, so it correlates on the object URL now (a5fd47e).

**Cost:** one more long-running container on the memory-tight runner. It inherits the capped
`UWSGI_PROCESSES: "1"` env, which the celery command ignores; if `verify-stack` gets tight
again, celery concurrency is the next knob.

**Follow-up:** S-19b-2 (#153) sources the projection from these events. Nothing subscribes to
the `objecten` kanaal in the product yet — only the verify check does.Reviewed-on: #154
2026-08-28 10:11:54 +00:00
not 0cd70ae8c3 S-19a · ACL writes the RegisterRecord to Objecten on approval (closes #149) (#151)
CI / build (push) Successful in 1m8s
CI / lint (push) Successful in 1m23s
CI / unit (push) Successful in 1m22s
CI / frontend (push) Successful in 3m3s
CI / mutation (push) Successful in 6m22s
CI / verify-stack (push) Successful in 7m54s
Closes #149.

**Outcome:** approving a registration now writes the canonical register record to the **Objecten** API as a `RegisterRecord` object, alongside the ZGW eindstatus. OpenZaak holds the process, Objecten holds the register (ADR-0028). The write goes through the ACL (§8.1) and is idempotent on the zaak id, so a replayed approval updates the existing object rather than creating a second one.

S-19 (#20) was split first (CLAUDE.md §13) — it bundled this with re-sourcing the read projection, which is now #150.

### What landed

- `IRegisterRecordGateway` + `RegisterRecord` in `Acl.Application`; `ObjectenGateway` in `Acl.Infrastructure` (static Token auth, CRS headers, objecttype resolved by name to its highest **published** version).
- `AclService.ApproveZaakAsync` writes the record after the eindstatus, keyed on the zaak UUID with the zaak's identificatie as reference.
- Compose wiring for both stacks; `ADR-0028`; demo note; PRD §15 out-of-scope line retired.

### Three things only a live stack found

Running the gateway against a real Objecten + Objecttypen pair while writing this turned up blockers CI would have hit after the fact:

1. **Objecten rejects an objecttype it has not been configured with**, by UUID — assigned at seed time by a one-shot that runs *after* Objecten's static setup_configuration. The UUID is now pinned on both sides.
2. **Objecten 500s on every write when its Notificaties config is absent** (`notifications_api_common` raises rather than skipping). Objecten → NRC has no broker, worker, kanaal or abonnement, so notifications are **disabled** rather than wired to drop every message; #150 turns them on for real.
3. **Objecttypen echoes the request Host into the objecttype `url`**, and Objecten only accepts the one matching its configured `api_root` — so the ACL must read Objecttypen at `http://objecttypen:8000`. This is why the new integration test only passes inside the compose network.

All three are recorded in ADR-0028.

### Verification

- `ObjectenGatewayIntegrationTests` (verify-acl, in-network): two writes for one id leave exactly one object with the second write's status. **Passing locally against live Objecten.**
- The **Playwright happy path** asserts, after the behandelaar approves, that Objecten holds exactly one `RegisterRecord` for *that* reference — missing, duplicated, or non-public-safe all fail.
- ACL mutation score **92.23%** (baseline 91.37%, break 90).
- `make lint` / `make unit` green locally; full-stack `make verify` runs in CI.

## Definition of Done

- [x] A linked Gitea issue exists (#149).
- [x] Failing test written and committed first.
- [x] Implementation makes the test pass.
- [x] Refactor commit follows if structure improved.
- [x] Conventional Commit messages referencing the issue (`refs #149`).
- [x] All Gitea Actions CI jobs green (run 684).
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (verify-stack step 1).
- [x] Docs touched — ADR-0028, demo note, PRD §15, BACKLOG.
- [x] ADR added: `docs/architecture/adr-0028-objecten-holds-the-register.md`.
- [x] Demo note appended to `docs/demo-script.md`.
- [x] Closed by the merging PR (`closes #149`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #151
2026-08-14 09:34:04 +00:00
not d37d4c96c6 S-18c · RegisterRecord objecttype defined + registered (closes #141) (#146)
CI / build (push) Successful in 1m29s
CI / lint (push) Successful in 1m37s
CI / unit (push) Successful in 2m13s
CI / frontend (push) Successful in 4m31s
CI / mutation (push) Successful in 7m0s
CI / verify-stack (push) Successful in 8m52s
## What & why

S-18c, the **final** slice of the S-18 (#19) split (after S-18a #142, S-18b #143). Defines the **RegisterRecord** objecttype — the schema S-19 (#20) will write canonical register records against on approval — and registers it in the Objecttypen API at startup.

Closes #141

### What

- **Schema** (`infra/objecttypen-registerrecord/registerrecord.schema.json`): public-safe by construction — `id`, `status` (enum `INGEDIEND`/`INGESCHREVEN`), `reference` only, `additionalProperties: false`, `dataClassification: open`. Mirrors the BFF's `OpenbaarEntry` — **no `bsn`/`naam`** (ADR-0027).
- **Registration**: a `registerrecord-init` compose one-shot (stdlib Python on the stack network) POSTs the objecttype + a **published** version over the API once Objecttypen is healthy. The Objecttypen `setup_configuration` (3.4.2) only provisions tokens — no declarative objecttype step — so this follows the ADR-0020 self-seed pattern. **Idempotent**: if a `RegisterRecord` with a version already exists it is a no-op.
- **Wiring**: schema + `register.py` streamed into the external `rr-registerrecord-config` volume by `seed-config.sh registerrecord` (main) / bind-mounted (local); added to `SEED`, `CFG_VOLS`, and the CI log-dump. `registerrecord-init` is a one-shot (not in `WAIT_SVCS`).
- **Smoke**: `verify-registerrecord` (`run-registerrecord-check.sh` + `registerrecord-check.py`) asserts the objecttype exists, has a **published** version, and that version's schema carries `id`/`status`/`reference`; added as a verify-stack step + a row in the #136 summary.
- **ADR-0027**: records the public-safe schema decision (mirror the BFF public view, not the internal projection; API-seeded one-shot). The slice issue #141 flagged the schema as ADR-worthy, so no separate adr-proposal issue was opened.

## Verified locally (end to end, real compose)

Seeded `rr-registerrecord-config`, brought Objecttypen up, ran `registerrecord-init` → `registered RegisterRecord <uuid> v1 (published)`. `make verify-registerrecord` → **OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']**. Re-running the one-shot → **no-op** (idempotent). `docker compose config` clean on both files; schema + script + ci.yaml validated.

## Definition of Done

- [x] Failing smoke committed first (`test(infra): …`, "no objecttype named RegisterRecord"); implementation makes it pass.
- [x] Conventional Commits referencing #141.
- [x] CI green (verify-stack registerrecord step — validated locally; runner already unstarved by #145).
- [x] `docker compose up` reaches health (one-shot registers after Objecttypen healthy).
- [x] Docs: ADR-0027 + demo note.
- [x] Closed by the merging PR (`closes #141`).

This closes out the S-18 (#19) split — Objecttypen (S-18a) + Objecten (S-18b) + RegisterRecord (S-18c) are all up. Next: **S-19 (#20)** — ACL writes the register record to Objecten on approval, against this schema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #146
2026-07-27 15:14:16 +00:00
not 159f014c1e perf: cap OpenZaak + NRC uWSGI workers — shrink verify-stack footprint (closes #147) (#148)
CI / build (push) Successful in 1m50s
CI / lint (push) Successful in 1m54s
CI / unit (push) Successful in 2m14s
CI / frontend (push) Successful in 4m45s
CI / mutation (push) Successful in 10m9s
CI / verify-stack (push) Failing after 11m19s
## What & why

Closes #147. Follow-up to #144/#145. As the stack grew to **37 services** on one runner, `verify-stack` is under memory pressure. #145 capped Objecten/Objecttypen; this caps the two biggest remaining uncapped Django apps.

**OpenZaak** and **NRC** (`nrc-web`) are Maykin/vng uWSGI images running the image default of **4 processes × 4 threads** — ~4 full-Django worker processes (~800 MB) each, idle, serving only single-request smoke checks.

### What

- `UWSGI_PROCESSES: "1"` + `UWSGI_THREADS: "2"` on the `&oz-env` and `&nrc-env` anchors, in both compose files. Frees ~1.2 GB. The anchors are shared with the `-init` (setup_configuration) and `-celery` containers, which ignore the var — they don't run uwsgi.

### Not included (considered, deferred to #147 notes)

JVM heap caps on Keycloak/Flowable; compose profiles to boot per-check subsets.

## Verified locally

OpenZaak brought up healthy with the cap; uwsgi processes **6 → 3** (master + http-router + 1 worker); `/admin/` still 302. `docker compose config` clean on both files. (Full NRC bring-up needs OpenZaak + the seed chain — same image family/lever, validated via OpenZaak.)

## Definition of Done

- [x] Linked issue (#147).
- [x] Conventional Commit referencing #147.
- [x] Verified locally (OpenZaak healthy + worker count dropped + still serving).
- [x] Closed by the merging PR (`closes #147`).

No ADR: config-only tuning of existing services, same class as #145.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #148
2026-07-27 14:32:13 +00:00
not dd54688f86 fix: cap Objecten/Objecttypen uWSGI to 1 worker — unstarve verify-stack e2e (closes #144) (#145)
CI / build (push) Successful in 1m32s
CI / lint (push) Successful in 1m41s
CI / unit (push) Successful in 2m21s
CI / frontend (push) Successful in 4m56s
CI / mutation (push) Successful in 7m54s
CI / verify-stack (push) Failing after 16m47s
## What & why

**P0 — red `main`.** Fixes #144: `verify-stack` fails on the Playwright e2e step (main runs 2177 after #142, 2190 after #143), while the PR runs passed.

Closes #144

### Root cause

The Maykin **Objecttypen** (S-18a) and **Objecten** (S-18b) images run their `web` under uWSGI with **4 processes × 4 threads by default** (`UWSGI_PROCESSES:-4`). Two web services × 4 idle Django workers (~200 MB each) sat idle during the e2e step and starved the single shared self-hosted runner — Keycloak and the portals stopped responding (the login `#username` never appeared) and Chromium hit `Target crashed`. The runner margin was already thin; the second chain tipped it over (main green through run 2159, red from 2177).

### Fix

Cap `UWSGI_PROCESSES: "1"` + `UWSGI_THREADS: "2"` on both `objecten` and `objecttypen` in both compose files. These APIs only serve single-request smoke checks and are idle during e2e, so 1 worker is plenty — it frees ~1–1.5 GB. The `-init` containers ignore it (they run `setup_configuration`, not uwsgi).

## Verified locally

Brought the objecten chain up with the cap: both services reach healthy, worker count drops from 6 (master + http + 4 workers) to 3 (master + http + 1 worker) per service, and `make verify-objecten` / `make verify-objecttypen` both still → **OK — no-auth 401, token 200**. `docker compose config` clean on both files.

## Definition of Done

- [x] Linked issue (#144).
- [x] Conventional Commit referencing #144.
- [x] Verified locally (both APIs healthy + smoke green with 1 worker).
- [x] Closed by the merging PR (`closes #144`).

No ADR: config-only tuning of existing services — no boundary, dependency, or coupling change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #145
2026-07-27 13:07:55 +00:00
not 0a97fa4bf7 S-18b · Objecten API up in compose, wired to Objecttypen (closes #140) (#143)
CI / build (push) Successful in 1m29s
CI / lint (push) Successful in 1m45s
CI / unit (push) Successful in 2m6s
CI / frontend (push) Successful in 4m21s
CI / mutation (push) Successful in 7m0s
CI / verify-stack (push) Failing after 30m21s
## What & why

S-18b, second of the S-18 (#19) split (after S-18a #139/#142). Stands up the upstream Maykin **Objecten API** in the compose stack and wires it to the Objecttypen API — the authoritative object store the ACL will write register records to (S-19).

Closes #140

### What

- **Compose** (main + local): `objecten-db` (**PostGIS** — objects carry geometry), `objecten-redis`, `objecten-init` (RUN_SETUP_CONFIG → migrate + provision token + register the Objecttypen service), `objecten` web (health on `/admin/`, host `:8021`). Verbatim upstream image `maykinmedia/objects-api` pinned to `3.4.0` (nearest release to objecttypes-api `3.4.2`; the two speak over the stable Objecttypes API v2).
- **Seed**: `infra/seed-config.sh objecten` streams `infra/objecten/setup_configuration/data.yaml` into the external `rr-objecten-config` volume — same pattern as S-18a. The data.yaml (1) registers **Objecttypen** as a trusted `zgw_consumers` service (`api_type: orc`, api-key auth with the S-18a dev token) so an object can reference its objecttype, and (2) provisions a dev **static API token** so peers (the ACL, S-19) can write objects.
- **Wiring**: added to `WAIT_SVCS`, `CFG_VOLS`, the `SEED` invocations, `seed-config.sh`, and the CI log-dump. `objecten-init` waits on `objecttypen` being healthy so the service registration is meaningful end to end.
- **Smoke**: `verify-objecten` (`infra/run-objecten-check.sh` + `objecten-check.py`) asserts unauth → 401, token → 200 on `/api/v2/objects`; added as a verify-stack step + a row in the #136 check-summary table.

## Verified locally (end to end, real compose)

Seeded + brought up the real `infra/docker-compose.yml` objecten chain (pulls in objecttypen via `depends_on`): `objecten-init` ran setup_configuration — `token_configuration_success` **and** "Successfully executed step: Configuration to connect with external services" — the web reached healthy, and `make verify-objecten` → **"OK — no-auth 401, token 200"**. Confirmed the registered service via the Objecten django shell:

```
objecttypen | orc | http://objecttypen:8000/api/v2/ | api_key
```

YAML (both compose files + ci.yaml) + shell + python all validated; `docker compose config` clean on both files.

## Definition of Done

- [x] Failing smoke committed first (`test(infra): …`, "no running objecten container"); implementation makes it pass.
- [x] Conventional Commits referencing #140.
- [ ] CI green (verify-stack objecten step).
- [x] `docker compose up` reaches health (objecten healthy on first poll locally).
- [x] Demo note in `docs/demo-script.md`.
- [x] Closed by the merging PR (`closes #140`).

No new ADR: follows the established verbatim-image + seed-config CG-module pattern (S-18a/ADR-0023-era).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #143
2026-07-27 09:53:01 +00:00
not 23ea91de32 feat(infra): Objecttypen API up in compose with a seeded static token (closes #139) (#142)
CI / build (push) Successful in 1m17s
CI / lint (push) Successful in 1m29s
CI / unit (push) Successful in 1m34s
CI / frontend (push) Successful in 3m24s
CI / mutation (push) Successful in 6m33s
CI / verify-stack (push) Failing after 12m2s
## What & why

S-18a, first of the S-18 (#19) split. Stands up the upstream Maykin **Objecttypen API** in the compose stack — the objecttype catalogue the register record (S-18b/S-18c, S-19) will build on.

Closes #139

### What

- **Compose** (main + local): `objecttypen-db` (Postgres), `objecttypen-redis`, `objecttypen-init` (RUN_SETUP_CONFIG → migrate + provision token), `objecttypen` web (health on `/admin/`, host `:8020`). Verbatim upstream image `maykinmedia/objecttypes-api` pinned to `3.4.2`.
- **Seed**: `infra/seed-config.sh objecttypen` streams `infra/objecttypen/setup_configuration/data.yaml` into the external `rr-objecttypen-config` volume — same pattern as OpenZaak/NRC. The data.yaml provisions a dev **static API token** (`tokenauth` setup_configuration step) so peers (Objecten, ACL) can authenticate.
- **Wiring**: added to `WAIT_SVCS`, `CFG_VOLS`, the `SEED` invocations, and the CI log-dump.
- **Smoke**: `verify-objecttypen` (`infra/run-objecttypen-check.sh` + `objecttypen-check.py`) asserts unauth → 401, token → 200; added as a verify-stack step + a row in the #136 check-summary table.

### Split note

#19 was oversized (two CG modules + config + objecttype) → split (§13) into **S-18a** (this), **S-18b** (#140, Objecten wired to Objecttypen), **S-18c** (#141, RegisterRecord objecttype).

## Verified locally (end to end, real compose)

Seeded + brought up the real `infra/docker-compose.yml` objecttypen chain: `objecttypen-init` ran setup_configuration (`token_configuration_success`), the web reached healthy, and `make verify-objecttypen` → **"OK — no-auth 401, token 200"**. YAML (both compose files + ci.yaml) + shell + python all validated.

## Definition of Done

- [x] Smoke check validates the outcome (live, against the running stack).
- [x] Conventional Commits referencing #139.
- [ ] CI green — see note.
- [x] `docker compose up` reaches health (objecttypen healthy on first poll locally).
- [x] Demo note in `docs/demo-script.md`.

## Note on CI

Additive (a new service + its own smoke step). The fast jobs are unaffected. The **verify-stack** job still can't go green until the pre-existing 1.27/act_runner-2.0.0 bring-up P0 is resolved (fails on plain `main` too) — but the objecttypen bring-up itself is validated locally above. No new ADR: this follows the established verbatim-image + seed-config CG-module pattern (ADR-0023-era).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #142
2026-07-27 08:51:14 +00:00
not 0494730223 feat(portal-beheer): ACL default-fill configuration editor (closes #131) (#138)
CI / build (push) Successful in 1m34s
CI / lint (push) Successful in 1m36s
CI / unit (push) Successful in 1m45s
CI / frontend (push) Successful in 3m37s
CI / mutation (push) Successful in 6m25s
CI / verify-stack (push) Successful in 7m56s
## What & why

S-15b, second of the S-15 (#16) split, on top of S-15a (#133). A beheerder edits the ACL's ZGW **default-fill** values from the beheer portal, and the next zaak is stamped with the new values — no restart.

Closes #131

### The vertical

portal → BFF `GET/PUT /beheer/default-fill` (medewerker realm + `beheerder` role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads **per zaak**.

- **ACL**: `IDefaultFillStore` / `InMemoryDefaultFillStore` (thread-safe, seeded from `Acl:Defaults`); `AclService` reads `fill.Current` per zaak (not cached at construction); `GET`/`PUT /default-fill` with required-field validation.
- **BFF**: `IAclClient` gains `GetDefaultFillAsync`/`UpdateDefaultFillAsync`; `GET`/`PUT /beheer/default-fill` behind the `beheerder` policy. OpenAPI + generated client regenerated.
- **Frontend**: a *Default-fill* editor page in the beheer app (load → edit → save, with saved/failure states) + nav between Catalogus and Default-fill.

### Scope decision → ADR-0026

Only the **three ZGW fill fields** (bronorganisatie, verantwoordelijke organisatie, vertrouwelijkheidaanduiding) are editable. The S-27 catalog-resolution keys stay **static config** — editing them would desync the zaaktype-URL cache (ADR-0021), and they're catalogus wiring, not "default fill". The store is **in-memory** (seeded from config): an edit reverts on restart. That's the reference-app-appropriate ceiling (no DB added to the stateless ACL); upgrade path documented. Recorded in **ADR-0026**.

## Verified locally

lint (`dotnet format`) ✓ · .NET unit — acl 60 / bff 45 / domain 152 / event-subscriber 19 / acceptance 17 ✓ · frontend lint+test (8 projects) ✓ · beheer build ✓. Clean full-solution build (caught + fixed the acceptance `AclService` ctor drift). TDD red→green per layer (ACL store, ACL endpoints, BFF, frontend).

## Definition of Done

- [x] Failing test committed before each implementation (red→green per layer).
- [x] Conventional Commits referencing #131.
- [ ] CI green — see note below.
- [x] Docs: ADR-0026 + S-15b demo note.
- [x] Demo note in `docs/demo-script.md`.

## Note on CI

The bulk validates in the fast jobs (lint/build/unit/frontend/mutation). The **verify-stack e2e** (incl. the new `default-fill.spec.ts`) can't go green until the pre-existing **verify-stack bring-up failure on the 1.27/2.0.0 runner** is resolved (that fails on plain `main` too — unrelated to this PR). Additive change; no existing e2e touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #138
2026-07-24 14:22:22 +00:00
not fff88ca23d ci: richer step reports via Gitea 1.27 job summaries (closes #136) (#137)
CI / build (push) Successful in 1m16s
CI / lint (push) Successful in 1m29s
CI / unit (push) Successful in 1m44s
CI / frontend (push) Successful in 3m47s
CI / mutation (push) Successful in 6m40s
CI / verify-stack (push) Successful in 8m15s
## What & why

Use the standard `$GITHUB_STEP_SUMMARY` (Gitea 1.27 + act_runner 2.0.0) to surface on the run page what was previously buried in logs or download-only artifacts. All five quick wins from #136, **reporting-only** — no job's pass/fail gating changes.

Closes #136

### Items

1. **Mutation scores** — added the `markdown` reporter to each `stryker-config.json`; the `mutation` job concatenates each service's `mutation-report.md` into the summary (`if: always()`). Also reveals where `make mutation` stopped on a ratchet break.
2. **Per-frontend tests** — the 4 apps' `test` targets emit vitest JSON to `test-output/{projectName}.json` (Nx token interpolation); `infra/vitest-summary.py` renders a per-frontend table.
3. **Per-service unit tests** — `make unit` now writes TRX; `infra/trx-summary.py` renders a per-service table (service name derived from the `services/<name>/` path, so `domain` shows, not `big.tests`).
4. **e2e per-spec results** — Playwright writes `playwright-report.json`; `run-e2e-check.sh` copies it out of the container (capturing the exit code first); `infra/playwright-summary.py` renders a per-spec table. Turns a red e2e into a one-glance "which spec".
5. **verify-stack check table** — each live-stack check has an `id`; a final `if: always()` step tabulates each check's //⏭️.

Docs: `gitea-actions-gotchas.md` §8 (version requirement + `$GITHUB_STEP_SUMMARY` guard + step-level `always()` note).

### Notes

- Every summary write is guarded with `[ -n "${GITHUB_STEP_SUMMARY:-}" ]`, so it no-ops on an unsupported runner / locally.
- New helper scripts are stdlib-only Python, matching the existing `infra/*.py` check scripts (no new dependency — a few lines of parsing rather than a test-logger package).
- `TestResults/` and `test-output/` gitignored.
- This is also the first PR-run exercising the #135 verify-stack fix end to end.

## Verified locally

`make unit` (TRX) ✓ · 4 apps' vitest JSON ✓ · ACL Stryker markdown report ✓ · all four parsers + the two summary shell blocks ✓ · `ci.yaml` + `run-e2e-check.sh` syntax ✓. The rendered summaries themselves only appear on the run page — this PR's CI run is the end-to-end check.

## Definition of Done

- [x] Each item writes to `$GITHUB_STEP_SUMMARY` (guarded), renders on the run page.
- [x] No change to any job's pass/fail gating.
- [x] Conventional Commits referencing #136 (one per item + docs).
- [ ] CI green; summaries visible on the run.
- [x] Runbook note (gotchas §8).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #137
2026-07-24 13:27:53 +00:00
not 849bf4723b ci: unstick verify-stack on Gitea 1.27 + act_runner 2.0.0 (closes #134) (#135)
CI / lint (push) Successful in 1m34s
CI / build (push) Successful in 1m30s
CI / unit (push) Successful in 1m47s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 7m27s
CI / verify-stack (push) Canceled after 0s
## What & why

After the Gitea 1.27 + act_runner 2.0.0 upgrade, `verify-stack` never starts: the run sits in `waiting` forever with no logs for that job, while the other five jobs pass — so `main` stays pending/red (P0). See #134.

Closes #134

### Root cause

`verify-stack` was the only job gated by a status-function `if` on top of `needs`:

```yaml
verify-stack:
  needs: [mutation]
  if: ${{ !cancelled() }}
```

Gitea 1.27 reworked cancellation/aggregation so that `always()`/`cancelled()`-gated `needs` jobs route through a new transitional **`Cancelling`** state + server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On this 1.27 + 2.0.0 pairing that handshake doesn't resolve, so the job is never dispatched and never leaves `waiting`. Plain jobs (no `if`/`needs`) are unaffected — exactly the observed pattern. It worked pre-upgrade (old runner).

### Fix

Drop the `if: ${{ !cancelled() }}`; keep `needs: [mutation]`. Default `if: success()` dispatches normally and still serialises the two memory-heavy jobs (OOM avoidance, #126).

**Trade-off:** the `!cancelled()` (added in #127) let verify-stack run even when the mutation ratchet fails. Now a failing mutation skips verify-stack; the fix-and-re-push re-run exercises it, so the signal isn't lost — just deferred to the green-mutation run. If we later want both signals on one run, serialise via a `concurrency` group rather than `needs` + `always()`.

Documented as §7 in `docs/runbooks/gitea-actions-gotchas.md`.

## Note on the stuck run

Run 582 (the #133 merge) will **not** clear itself and must be force-cancelled from the Actions UI (plain cancel can also stall on this version, gitea#35782). This PR's own run is the first real test of the fix — if `verify-stack` dispatches and runs here, the fix holds.

## Definition of Done

- [x] Linked issue (#134).
- [x] Conventional Commit referencing the issue.
- [ ] CI green — this PR's run is the verification (verify-stack must dispatch).
- [x] Runbook updated (gotchas §7).
- [ ] Closed by the merging PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #135
2026-07-24 11:59:27 +00:00
not 4698c869f3 feat(portal-beheer): beheer portal + read-only catalogus viewer (closes #130) (#133)
CI / build (push) Successful in 4m31s
CI / lint (push) Successful in 4m48s
CI / unit (push) Successful in 1m50s
CI / frontend (push) Successful in 4m44s
CI / mutation (push) Successful in 7m12s
CI / verify-stack (push) Canceled after 0s
## What & why

S-15a, the first of the S-15 (#16) split. A new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus — the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE zaaktype.

Closes #130

### The vertical

portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm + `beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API.

- **ACL**: new read-only `GET /catalogi/zaaktypen` listing published zaaktypen (reuses the ADR-0021 Catalogi client; public-safe `identificatie`/`omschrijving`).
- **BFF**: new typed `IAclClient` + `Downstream:Acl:BaseUrl`, and `GET /beheer/catalogi/zaaktypen` behind a new `beheerder` policy (reuses the medewerker bearer scheme + realm-role lifting). OpenAPI spec + generated Angular client regenerated.
- **Keycloak**: `beheerder` realm role + `bram-beheerder` test user in the medewerker realm.
- **Frontend**: new `apps/beheer` Angular app (copied from behandel) with a read-only catalogus page; `SECURE_API_ROUTES=['/beheer/']`.
- **Infra**: `beheer` compose service (port 8143), added to `WAIT_SVCS` + CI log-dump; a Playwright e2e (beheerder login → catalogus shows BIG-REGISTRATIE).

### New boundary → ADR-0025

The BFF now reaches the **ACL directly** for the catalogus read — a new service-to-service edge (§14). The catalogus is neither a domain nor a projection concern, and §8.1 means only the ACL may read ZGW; routing through the domain would pollute it with a non-domain passthrough. §8.1/§8.3 stay intact. Recorded in **ADR-0025**.

## Definition of Done

- [x] Failing test committed before each implementation (red→green per layer: ACL, BFF, frontend).
- [x] Conventional Commits referencing #130.
- [ ] CI green — pending Gitea Actions run.
- [x] `docker compose up` brings up `beheer` (health-gated in `WAIT_SVCS`).
- [x] Docs — ADR-0025 + demo-script S-15a note.
- [x] Demo note in `docs/demo-script.md`.

## Verified locally

lint (`dotnet format`) ✓ · .NET unit (Acl 57 / Big 152 / EventSubscriber 19 / Bff 40) ✓ · frontend lint+test (8 projects) ✓ · frontend build (4 apps) ✓. Mutation ratchet: added a gateway unit test for the new `ListZaaktypenAsync` mapping so the ACL score holds. verify-stack (compose smoke + e2e) runs in CI.

## Notes for reviewers

- The BFF drops the ZGW URL from `BeheerZaaktype` (public-safe: identificatie + omschrijving only).
- The catalogus e2e asserts on the stable seeded `BIG-REGISTRATIE` (not a per-test reference), safe on the shared verify stack.
- Follow-ups: **S-15b** (#131) default-fill CRUD, **S-15c** (#132) medewerker-realm MFA.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #133
2026-07-24 11:08:17 +00:00
not d5dfbdc0b2 feat(obs): Prometheus metrics on /metrics + golden-signal Grafana dashboard (closes #124) (#129)
CI / build (push) Successful in 1m47s
CI / lint (push) Successful in 1m59s
CI / unit (push) Successful in 1m57s
CI / frontend (push) Successful in 3m58s
CI / mutation (push) Successful in 6m57s
CI / verify-stack (push) Successful in 8m29s
## What & why

S-16c, the last of the S-16 (#17) split, on top of the backplane (#122) and distributed tracing (#123). The five .NET services now expose OpenTelemetry **metrics** in Prometheus format at `/metrics`; Prometheus scrapes each (one job per service); and Grafana ships a pre-built **Request path — golden signals** dashboard (traffic / errors / latency / saturation), split by service.

Closes #124

### How

- Each service adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation + AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`. Same shape as the S-16b tracing wiring already in these `Program.cs` files.
- `infra/observability/prometheus/prometheus.yml`: one scrape job per service (`acl`, `domain`, `bff`, `event-subscriber`, `projection-api`), reached by compose service name.
- `infra/observability/grafana/provisioning/dashboards/`: dashboard provider + `golden-signals.json` (baked into the Grafana image by the existing `COPY provisioning/`).
- `verify-metrics` (new CI verify-stack step + Makefile target): generates BFF traffic and asserts Prometheus scraped the golden-signal metric from every service. Mirrors `verify-tracing`.

### Dependency (CLAUDE.md §13/§14)

Adds `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` (matched to the `1.17.0` core already in use). It gives the OTel-native `/metrics` pull endpoint; replacing it would mean hand-rolling Prometheus exposition over a `MeterListener`; the risk is that it is a **prerelease** package (the whole OTel .NET Prometheus line is `-beta`) — pinned, wired only in `Program.cs`, and gated by `verify-metrics`. Recorded in **ADR-0024**.

## Definition of Done

- [x] Linked Gitea issue (#124).
- [x] Failing test committed before the implementation (`test(bff): /metrics exposes http-server request duration`).
- [x] Implementation makes the test pass.
- [ ] CI green — pending Gitea Actions run.
- [x] `docker compose up` reaches green health within 3 min (backplane images unchanged in shape; not on the health gate, ADR-0023).
- [x] Docs updated — demo-script S-16c entry.
- [x] ADR added — ADR-0024.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- `/health` polls are counted as traffic (metrics aren't path-filtered, unlike traces). Fine for a demo dashboard and honest — real load stacks on top.
- `projection-api` has no Stryker config (unchanged); the four mutated services carry the metrics wiring in `Program.cs`, same as the merged S-16b tracing code.
- Metric names verified against a live service: `http_server_request_duration_seconds{,_bucket,_count}`, label `http_response_status_code`, `dotnet_process_cpu_time_seconds_total`.

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

Reviewed-on: #129
2026-07-24 08:31:41 +00:00
not 6771fccf47 ci: parallelise jobs at runner capacity >1, keep heavy jobs apart (closes #127) (#128)
CI / build (push) Successful in 2m3s
CI / lint (push) Successful in 2m13s
CI / unit (push) Successful in 2m30s
CI / frontend (push) Successful in 4m29s
CI / mutation (push) Successful in 7m4s
CI / verify-stack (push) Successful in 8m41s
## What & why

The runner's `capacity` was raised to 2. The six CI jobs have no `needs:` between them, so they already schedule concurrently now — this PR makes that safe and tidy rather than enabling it.

- **Keep the two memory-heavy jobs apart.** `verify-stack` now `needs: [mutation]` — not a data dependency, but so Stryker and the full-stack-bring-up + Playwright browser never run at once on the one host and re-trigger the e2e OOM (#126, commit d5e5fa2). `if: ${{ !cancelled() }}` keeps verify-stack running even when the mutation ratchet fails, so we don't lose its signal, while still honouring cancellation.
- **Light jobs stay dependency-free** (lint / build / unit / frontend) → they parallelise up to runner capacity.
- **Supersede stale runs** via a workflow `concurrency` group, so a new push cancels the previous run and frees the slot instead of piling up.

Net effect at capacity 2: the light jobs pair up (and overlap `mutation`), then `verify-stack` runs alone — shorter wall-clock, no heavy-heavy collision.

Closes #127

## Definition of Done

- [x] Linked issue (#127).
- [x] Conventional Commit referencing it.
- [ ] CI green — this PR **is** the test: it exercises `needs`, `if: !cancelled()`, and the `concurrency` group on Gitea. Watch that (a) verify-stack starts only after mutation, (b) verify-stack still runs, (c) the workflow parses (concurrency accepted).
- [x] No app/docs/ADR impact (CI-only).

## Notes for reviewers

- **One thing to watch on this first run:** if this Gitea version doesn't support the top-level `concurrency` key, drop that hunk — the `needs`/`if` guard is the load-bearing part and is plain job-graph syntax.
- **Cross-run collisions** (two different PRs' `verify-stack` on the 2-capacity runner) aren't controllable via intra-workflow `needs`. If that becomes a problem, the clean fix is a second runner (or a dedicated capacity-1 label for the stack job) rather than ordering — out of scope here.

Reviewed-on: #128
2026-07-23 15:18:16 +00:00
not 88338396f6 feat(obs): distributed traces across the .NET services (S-16b, closes #123) (#126)
CI / verify-stack (push) Successful in 12m13s
CI / build (push) Successful in 1m50s
CI / lint (push) Successful in 1m58s
CI / unit (push) Successful in 2m8s
CI / frontend (push) Successful in 4m29s
CI / mutation (push) Successful in 11m51s
## What & why

S-16b, second of the S-16 split, on top of the #125 backplane. The five .NET services now emit OpenTelemetry traces so a request is **one connected trace** across them.

- Each host wires `AddOpenTelemetry().WithTracing(...)` with `AddAspNetCoreInstrumentation` (incoming) + `AddHttpClientInstrumentation` (outgoing) + `AddOtlpExporter` to **Tempo**.
- Because every cross-service call already goes through a typed `HttpClient` (§8 boundaries), the W3C `traceparent` propagates with no manual code — bff → domain → acl → openzaak and bff → projection-api stitch into a single trace.
- Service name + OTLP endpoint come from `OTEL_*` env set per app service in compose. `/health` is filtered out so liveness polls don't flood the traces.

No new ADR — ADR-0023 already records the stack + the two documented gaps (browser-side tracing is out of scope, so the trace begins at the BFF; the async Flowable-poll boundary is a separate trace).

Closes #123

## Definition of Done

- [x] Failing test committed first (`verify-tracing` fails with no instrumentation).
- [x] Implementation makes it pass — **validated locally end to end**: a real connected trace spanning `bff` + `projection-api` was found in Tempo (BFF→projection→db + Tempo subset, no OpenZaak/egress).
- [x] Conventional Commits referencing the issue (`refs #123`).
- [ ] CI green — awaiting Gitea Actions (verify-tracing added to verify-stack after verify-bff).
- [x] `docker compose up` health unaffected — services boot healthy even when Tempo is unreachable (exporter no-ops; verified).
- [x] Docs — demo-script + BACKLOG.
- [x] ADR — none needed (covered by ADR-0023).

## Notes for reviewers

- **Per-service wiring, no shared lib:** the block is duplicated across the five hosts by design — services don't share code across boundaries here (§8), same as the duplicated typed clients.
- **Packages:** OpenTelemetry.Extensions.Hosting / Instrumentation.AspNetCore / Instrumentation.Http / Exporter.OpenTelemetryProtocol, all 1.17.0, pinned per-csproj (no central props file).
- **The check** generates anonymous BFF→projection traffic (no auth, no OpenZaak), then queries Tempo (TraceQL search → fetch trace → assert both service.names present) from a python:3-slim container in-network — same idiom as run-projection-check.sh.
- **Next:** #124 (S-16c) adds `/metrics` + Prometheus scrape targets + golden-signal Grafana dashboards.

Reviewed-on: #126
2026-07-23 14:38:26 +00: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
228 changed files with 11495 additions and 592 deletions
+138 -5
View File
@@ -9,6 +9,12 @@ on:
permissions:
contents: read
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
@@ -64,6 +70,12 @@ jobs:
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
- name: Unit test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
@@ -78,6 +90,12 @@ jobs:
node-version: '24'
cache: 'pnpm'
- run: make frontend
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
- name: Frontend test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
mutation:
runs-on: ubuntu-latest
@@ -93,6 +111,29 @@ jobs:
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
- name: Mutation score summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
{
echo "## 🧬 Mutation testing"
echo
for svc in acl event-subscriber domain bff; do
echo "### $svc"
echo
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
if [ -n "$report" ]; then
sed '1s/^\xef\xbb\xbf//' "$report"
else
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
fi
echo
done
} >> "$GITHUB_STEP_SUMMARY"
# 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
@@ -129,35 +170,127 @@ jobs:
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
# One stage for every check that needs the live stack. 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).
#
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
#
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
# re-run exercises verify-stack, so we still get the signal.
verify-stack:
needs: [mutation]
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).
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
- name: Bring up the full stack & wait for health
id: up
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
id: obs
run: OBS_TIMEOUT=180 make verify-observability
- name: Objecttypen API up + token authenticates
id: objecttypen
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
- name: Objecten API up + token authenticates + trusts Objecttypen
id: objecten
run: OBJECTEN_TIMEOUT=120 make verify-objecten
- name: RegisterRecord objecttype registered + published
id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ACL ↔ OpenZaak integration tests
id: acl
run: make verify-acl
- name: OpenZaak → NRC notification delivery
id: nrc
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
id: projection
run: make verify-projection
- name: Objecten → NRC notification delivery
id: objecten_nrc
run: make verify-objecten-notifications
- name: Domain → Flowable → ACL → OpenZaak
id: domain
run: make verify-domain
- name: BFF → Keycloak + domain + projection
id: bff
run: make verify-bff
- name: Distributed traces reach Tempo (one connected trace across services)
id: tracing
run: TRACING_TIMEOUT=120 make verify-tracing
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
id: metrics
run: METRICS_TIMEOUT=120 make verify-metrics
- name: Self-service e2e (Playwright, login → submit → success)
id: e2e
run: make verify-e2e
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
# job-level status-function `if` of #134) so it renders even after a check fails.
- name: verify-stack check summary
if: always()
env:
UP: ${{ steps.up.outcome }}
OBS: ${{ steps.obs.outcome }}
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
ACL: ${{ steps.acl.outcome }}
NRC: ${{ steps.nrc.outcome }}
PROJECTION: ${{ steps.projection.outcome }}
DOMAIN: ${{ steps.domain.outcome }}
BFF: ${{ steps.bff.outcome }}
TRACING: ${{ steps.tracing.outcome }}
METRICS: ${{ steps.metrics.outcome }}
E2E: ${{ steps.e2e.outcome }}
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
{
echo "## 🔌 verify-stack checks"
echo
echo "| Check | Result |"
echo "| ----- | :----: |"
echo "| Bring up + health | $(icon "$UP") |"
echo "| Observability backplane | $(icon "$OBS") |"
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
echo "| OpenZaak → NRC | $(icon "$NRC") |"
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
} >> "$GITHUB_STEP_SUMMARY"
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
- name: e2e spec summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
# 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 2>&1 || true
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 beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
+4
View File
@@ -57,3 +57,7 @@ vitest.config.*.timestamp*
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
__pycache__/
TestResults/
test-output/
tests/e2e/playwright-report.json
+31 -6
View File
@@ -249,32 +249,57 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
### S-15 · Beheer-portal — catalogus & default-fill rules
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
**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
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
### 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.
---
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
### S-19 · ACL extension: write register-record to Objecten on approval
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
- **S-19b** (#150, ✅) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
- **S-19b-1** (#152, ✅) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
- **S-19b-2** (#153, ✅) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. The ACL also writes an INGEDIEND record on submit, so the register holds the whole lifecycle. Carries ADR-0030.
---
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
+139 -8
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# 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
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
# 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
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
# 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
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
# 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.
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down 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
.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-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications 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 k8s-lint k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
## 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).
@@ -70,8 +70,13 @@ build:
dotnet build $(SLN) -c Release
## unit: run unit tests (excludes the container-backed Integration lane)
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
# The CI reporting scripts are stdlib Python with their own assert-based self-checks (#161) — they
# ride this lane so a broken job summary is caught by CI rather than by the next red pipeline.
unit:
dotnet test $(SLN) -c Release --filter "Category!=Integration"
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
python3 infra/test_playwright_summary.py
python3 infra/test_portal_caddyfiles.py
## 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`
@@ -93,14 +98,14 @@ mutation:
# 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:
$(SEED) oz nrc kc fl
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
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'
## 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
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
@@ -114,6 +119,11 @@ 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
@@ -133,7 +143,7 @@ changelog:
## 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
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
@@ -165,17 +175,53 @@ verify-bff:
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-metrics: assert the services expose /metrics and Prometheus scrapes the golden
## signals (S-16c), against the already-running stack.
verify-metrics:
bash infra/run-metrics-check.sh
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
## (S-18a), against the already-running stack.
verify-objecttypen:
bash infra/run-objecttypen-check.sh
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
## trusts the Objecttypen API (S-18b), against the already-running stack.
verify-objecten:
bash infra/run-objecten-check.sh
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
## Objecttypen API (S-18c), against the already-running stack.
verify-registerrecord:
bash infra/run-registerrecord-check.sh
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
verify-objecten-notifications:
bash infra/run-objecten-notifications-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
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
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-objecten-notifications-check.sh \
&& bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
@@ -284,6 +330,91 @@ flowable-down:
docker compose -f $(FL_COMPOSE) down --volumes
-docker volume rm -f rr-fl-bpmn
# ── Kubernetes (single-node Talos) ─────────────────────────────────────────────
# The Helm chart in infra/helm/big-reference is a port of infra/docker-compose.yml
# (ADR-0033). Full walkthrough: docs/runbooks/kubernetes-talos.md.
# TALOS_HOST the address the BROWSER uses — pins Keycloak's issuer and the portals'
# OIDC authority. Use `localhost` with `make k8s-portals`: the OIDC
# library needs crypto.subtle, which browsers only expose on a secure
# context (https, or localhost) — see docs/runbooks/kubernetes-talos.md §5
# K8S_REGISTRY the registry both sides use for this repo's images (see k8s-registry)
K8S_NS ?= big
K8S_CHART := infra/helm/big-reference
K8S_REGISTRY ?=
TALOS_HOST ?=
# The images built from this repo — compose service name == image name == chart workload.
K8S_IMAGES := acl domain bff event-subscriber projection-api self-service openbaar behandel beheer
## k8s-lint: render + schema-check the Helm chart (no cluster needed)
k8s-lint:
helm lint $(K8S_CHART)
helm template big $(K8S_CHART) -n $(K8S_NS) --set images.registry=registry.invalid:5000 >/dev/null
## k8s-registry: deploy the in-cluster image registry (NodePort 30500)
k8s-registry:
kubectl apply -f infra/helm/registry.yaml
kubectl -n registry rollout status deploy/registry --timeout=180s
## k8s-images: build this repo's images (via compose) and push them to $(K8S_REGISTRY)
# `docker save | crane push` rather than `docker push`: the registry speaks plain
# HTTP, which the Docker daemon refuses without a root-level insecure-registries
# entry, while crane just takes --insecure. Install: see docs/runbooks/kubernetes-talos.md.
k8s-images:
@command -v crane >/dev/null || { echo "crane not found — see docs/runbooks/kubernetes-talos.md §0" >&2; exit 2; }
@test -n "$(K8S_REGISTRY)" || { echo "set K8S_REGISTRY=<registry host:port>" >&2; exit 2; }
docker compose -f $(COMPOSE) build $(K8S_IMAGES)
@tar=$$(mktemp -t rr-img-XXXX.tar); \
for i in $(K8S_IMAGES); do \
docker save register-referentie/$$i:dev -o $$tar; \
crane push --insecure $$tar $(K8S_REGISTRY)/register-referentie/$$i:dev; \
done; rm -f $$tar
## k8s-seed: create the ConfigMaps the chart mounts (upstream config + bootstrap scripts)
k8s-seed:
bash infra/helm/seed-configmaps.sh $(K8S_NS)
## k8s-up: seed the config and install/upgrade the release
k8s-up: k8s-seed
@test -n "$(TALOS_HOST)" || { echo "set TALOS_HOST=<node ip>" >&2; exit 2; }
@test -n "$(K8S_REGISTRY)" || { echo "set K8S_REGISTRY=<registry the node can pull from>" >&2; exit 2; }
helm upgrade --install big $(K8S_CHART) -n $(K8S_NS) --create-namespace \
--set host=$(TALOS_HOST) --set images.registry=$(K8S_REGISTRY) $(K8S_SET)
kubectl -n $(K8S_NS) get pods
## k8s-reseed: re-run the bootstrap jobs (after a database was wiped, or after
## changing a Job in the chart — Job pod templates are immutable, so a plain
## `helm upgrade` is rejected)
k8s-reseed:
kubectl -n $(K8S_NS) delete job -l app.kubernetes.io/component=init --ignore-not-found
$(MAKE) k8s-up
# The projection's schema is created on service start (Projection.ReadModel migrates in a
# hosted service), so a wiped database also needs these two restarted — otherwise they keep
# writing to a schema-less DB and fail with `relation "processed_notifications" does not exist`.
kubectl -n $(K8S_NS) rollout restart deploy/event-subscriber deploy/projection-api
kubectl -n $(K8S_NS) rollout status deploy/event-subscriber deploy/projection-api --timeout=180s
## k8s-portals: forward the browser-facing services to localhost (Ctrl-C stops them all)
# The portals' OIDC flow needs a *secure context* for crypto.subtle (PKCE), and browsers
# only grant that to https or localhost — a NodePort on the VM's IP is neither. Forwarding
# to localhost on the same port numbers keeps Keycloak's pinned issuer valid. Deploy with
# TALOS_HOST=localhost for this to line up.
k8s-portals:
@echo "self-service http://localhost:30140 · openbaar :30141 · behandel :30142 · beheer :30143 · keycloak :30180"
@trap 'kill 0' INT TERM; \
for f in self-service:30140:80 openbaar:30141:80 behandel:30142:80 beheer:30143:80 keycloak:30180:8080; do \
svc=$${f%%:*}; rest=$${f#*:}; lport=$${rest%%:*}; rport=$${rest#*:}; \
kubectl -n $(K8S_NS) port-forward --address 127.0.0.1 svc/$$svc $$lport:$$rport >/dev/null & \
done; wait
## k8s-down: uninstall the release (database PVCs are kept)
k8s-down:
helm uninstall big -n $(K8S_NS)
## k8s-purge: uninstall AND drop the namespace, including the database volumes
k8s-purge:
-helm uninstall big -n $(K8S_NS)
kubectl delete namespace $(K8S_NS) --ignore-not-found
## help: list available targets
help:
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //'
+22
View File
@@ -0,0 +1,22 @@
:80 {
# Same-origin API: behandelaars authenticate against the medewerker realm; the BFF validates it
# for /behandel/* (S-12c).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /behandel/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+6 -9
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Multi-stage build for the behandel portal (Angular → Caddy).
# 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
@@ -13,15 +13,12 @@ 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
FROM caddy:2-alpine AS runtime
COPY apps/behandel/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/behandel/browser /usr/share/caddy
# 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
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/caddy/config.json
EXPOSE 80
-24
View File
@@ -1,24 +0,0 @@
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;
}
}
+3 -1
View File
@@ -64,7 +64,9 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface RuntimeConfig {
/**
* 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
* the api-client actually calls (same-origin via the Caddy 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.
*/
@@ -4,7 +4,7 @@ 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';
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
@@ -81,6 +81,94 @@ describe('WerkbakPage', () => {
});
});
it('picks up a newly submitted registration without a reload', async () => {
// S-26 (#162): a registration reaches Beoordelen asynchronously, after the citizen supplies
// documents — so the werkbak must refresh itself rather than wait for the behandelaar to reload.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(of([sample[0]]))
.mockReturnValue(of(sample));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
expect(screen.getByText('reg-1')).toBeTruthy();
expect(screen.queryByText('reg-2')).toBeNull();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
expect(screen.getByText('reg-2')).toBeTruthy();
// A background refresh must not flash the loading state over the rows the behandelaar is reading.
expect(screen.queryByText(/bezig met laden/i)).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('keeps the rows on screen when a background refresh fails', async () => {
// A blip on a background poll must not replace the list with the load-failure alert; the next
// tick recovers. Only the first load speaks for whether the werkbak is readable at all.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(of(sample))
.mockReturnValue(throwError(() => new Error('503')));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(screen.getByText('reg-1')).toBeTruthy();
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('stops refreshing once the page is destroyed', async () => {
vi.useFakeTimers();
try {
const { getBehandelWerkbak, providers } = setup();
const { fixture } = await render(WerkbakPage, { providers });
fixture.destroy();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS * 3);
expect(getBehandelWerkbak).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('clears a load failure once a refresh succeeds', async () => {
// Without this the werkbak stays stuck on the error until the behandelaar reloads — the very
// thing this slice removes. A recovered read must put the rows back.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(throwError(() => new Error('503')))
.mockReturnValue(of(sample));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
expect(screen.getByText(/kon de werkbak niet laden/i)).toBeTruthy();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
expect(screen.getByText('reg-1')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
+34 -3
View File
@@ -1,7 +1,15 @@
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* How often an open werkbak re-reads itself (S-26/#162, ADR-0032). Exported so the spec advances the
* clock by exactly one interval instead of hard-coding the number.
*/
export const WERKBAK_REFRESH_MS = 5_000;
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
@@ -10,6 +18,11 @@ type Besluit = 'goedkeuren' | 'afwijzen';
* 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.
*
* The page also re-reads itself every {@link WERKBAK_REFRESH_MS} while it is open, so a registration
* that reaches beoordeling after the behandelaar opened the werkbak shows up on its own — no reload
* (S-26/#162). Polling rather than a pushed stream: nothing notifies the BFF either, so a stream
* would poll the domain in the BFF instead and add connection state for the same freshness (ADR-0032).
*/
@Component({
selector: 'app-werkbak-page',
@@ -27,19 +40,37 @@ export class WerkbakPage {
constructor() {
this.load();
// ponytail: a fixed interval, polled while the page lives — it keeps refreshing in a background
// tab. Gate on `document.visibilityState` if the request volume ever matters.
interval(WERKBAK_REFRESH_MS)
.pipe(takeUntilDestroyed())
.subscribe(() => this.load({ background: true }));
}
load(): void {
this.loading.set(true);
this.failed.set(false);
/**
* Read the werkbak. A `background` read is the interval refresh: it leaves the rows and the states
* the behandelaar is looking at alone until it has an answer — no loading flash on every tick, and
* a blip does not swap the list for the failure alert (the next tick recovers). Only a foreground
* read — on open, or after a decision — speaks for whether the werkbak is readable at all.
*/
load(options: { background?: boolean } = {}): void {
const background = options.background ?? false;
if (!background) {
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);
// A read that came back is the answer, so a refresh also clears an earlier failure — the
// werkbak recovers on its own instead of showing the error until someone reloads.
this.failed.set(false);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
if (background) return;
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
+21
View File
@@ -0,0 +1,21 @@
:80 {
# Same-origin API: beheerders use the same medewerker realm as behandel (S-15a).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /beheer/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+24
View File
@@ -0,0 +1,24 @@
# Multi-stage build for the beheer portal (Angular → Caddy).
# 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/beheer apps/beheer
COPY libs libs
RUN pnpm nx build beheer
FROM caddy:2-alpine AS runtime
COPY apps/beheer/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/beheer/browser /usr/share/caddy
# 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).
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/caddy/config.json
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: {},
},
];
+82
View File
@@ -0,0 +1,82 @@
{
"name": "beheer",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/beheer/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/beheer",
"browser": "apps/beheer/src/main.ts",
"tsConfig": "apps/beheer/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/beheer/public"
}
],
"styles": ["apps/beheer/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": "beheer:build:production"
},
"development": {
"buildTarget": "beheer:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "beheer:build",
"staticFilePath": "dist/apps/beheer/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

+65
View File
@@ -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 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 beheer 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('beheer 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 catalogus call', () => {
bff.getBeheerCatalogiZaaktypen().subscribe();
const req = http.expectOne('/beheer/catalogi/zaaktypen');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
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 Caddy proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/beheer/'];
/**
* 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
+5
View File
@@ -0,0 +1,5 @@
<nav aria-label="Beheer" class="utrecht-theme">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
</nav>
<router-outlet></router-outlet>
+9
View File
@@ -0,0 +1,9 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { CatalogusPage } from './catalogus/catalogus-page';
import { DefaultFillPage } from './default-fill/default-fill-page';
export const appRoutes: Route[] = [
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
{ path: 'default-fill', component: DefaultFillPage, 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 CatalogusPage 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 = 'beheer';
}
@@ -0,0 +1,40 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Catalogus</utrecht-heading-1>
<p utrecht-paragraph>
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
in een latere slice.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Gepubliceerde zaaktypen
</caption>
<thead>
<tr>
<th scope="col">Identificatie</th>
<th scope="col">Omschrijving</th>
</tr>
</thead>
<tbody>
@for (zaaktype of items(); track zaaktype.identificatie) {
<tr>
<td>{{ zaaktype.identificatie }}</td>
<td>{{ zaaktype.omschrijving }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -0,0 +1,75 @@
import { signal } from '@angular/core';
import { render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { CatalogusPage } from './catalogus-page';
const sample: BeheerZaaktype[] = [
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised here */
}
logout(): void {
/* not exercised here */
}
}
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
const getBeheerCatalogiZaaktypen =
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
return {
getBeheerCatalogiZaaktypen,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('CatalogusPage', () => {
it('lists the published zaaktypen on open', async () => {
const { getBeheerCatalogiZaaktypen, providers } = setup();
await render(CatalogusPage, { providers });
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
expect(screen.getByText('BIG-registratie')).toBeTruthy();
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
});
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
await render(CatalogusPage, { providers });
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(CatalogusPage, { providers });
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(CatalogusPage, { providers: setup().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 { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
*/
@Component({
selector: 'app-catalogus-page',
imports: [UtrechtComponentsModule],
templateUrl: './catalogus-page.html',
})
export class CatalogusPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<BeheerZaaktype[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBeheerCatalogiZaaktypen().subscribe({
next: (rows: BeheerZaaktype[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
}
@@ -0,0 +1,60 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Default-fill</utrecht-heading-1>
<p utrecht-paragraph>
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
voor de eerstvolgende zaak.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (loaded()) {
<form (submit)="save(); $event.preventDefault()">
<p>
<label for="bronorganisatie">Bronorganisatie</label><br />
<input
id="bronorganisatie"
name="bronorganisatie"
[value]="bronorganisatie()"
(input)="bronorganisatie.set($any($event.target).value)"
/>
</p>
<p>
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
<input
id="verantwoordelijkeOrganisatie"
name="verantwoordelijkeOrganisatie"
[value]="verantwoordelijkeOrganisatie()"
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
/>
</p>
<p>
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
<input
id="vertrouwelijkheidaanduiding"
name="vertrouwelijkheidaanduiding"
[value]="vertrouwelijkheidaanduiding()"
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
/>
</p>
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
Opslaan
</button>
</form>
@if (saved()) {
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
}
@if (failed()) {
<p utrecht-paragraph role="alert">
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
</p>
}
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
het opnieuw.
</p>
}
</utrecht-article>
</main>
@@ -0,0 +1,90 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { DefaultFillPage } from './default-fill-page';
const current: BeheerDefaultFill = {
bronorganisatie: '517439943',
verantwoordelijkeOrganisatie: '517439943',
vertrouwelijkheidaanduiding: 'openbaar',
};
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised */
}
logout(): void {
/* not exercised */
}
}
function setup(
overrides: {
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
return {
getBeheerDefaultFill,
putBeheerDefaultFill,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('DefaultFillPage', () => {
it('loads the current default-fill into the form on open', async () => {
const { getBeheerDefaultFill, providers } = setup();
await render(DefaultFillPage, { providers });
expect(getBeheerDefaultFill).toHaveBeenCalled();
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
expect(bron.value).toBe('517439943');
});
it('saves the edited values via the BFF', async () => {
const { putBeheerDefaultFill, providers } = setup();
await render(DefaultFillPage, { providers });
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
fireEvent.input(bron, { target: { value: '999999999' } });
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
);
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
});
it('surfaces a save failure instead of swallowing it', async () => {
const { providers } = setup({
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(DefaultFillPage, { providers });
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(DefaultFillPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,72 @@
import { Component, inject, signal } from '@angular/core';
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
*/
@Component({
selector: 'app-default-fill-page',
imports: [UtrechtComponentsModule],
templateUrl: './default-fill-page.html',
})
export class DefaultFillPage {
private readonly bff = inject(BffApiV1Service);
protected readonly bronorganisatie = signal('');
protected readonly verantwoordelijkeOrganisatie = signal('');
protected readonly vertrouwelijkheidaanduiding = signal('');
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly saving = signal(false);
protected readonly failed = signal(false);
protected readonly saved = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.saved.set(false);
this.bff.getBeheerDefaultFill().subscribe({
next: (d: BeheerDefaultFill) => {
this.bronorganisatie.set(d.bronorganisatie);
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
this.loading.set(false);
this.loaded.set(true);
},
error: () => {
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
save(): void {
this.saving.set(true);
this.failed.set(false);
this.saved.set(false);
this.bff
.putBeheerDefaultFill({
bronorganisatie: this.bronorganisatie(),
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
})
.subscribe({
next: () => {
this.saving.set(false);
this.saved.set(true);
},
error: () => {
this.saving.set(false);
this.failed.set(true);
},
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Beheerportaal 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"]
}
+21
View File
@@ -0,0 +1,21 @@
:80 {
# Same-origin API: the public register is anonymous, but still reads through the BFF (S-09).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /openbaar/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+4 -8
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the openbaar portal (Angular → nginx).
# Multi-stage build for the openbaar portal (Angular → Caddy).
# 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
@@ -13,13 +13,9 @@ 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
FROM caddy:2-alpine AS runtime
COPY apps/openbaar/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/caddy
# 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
-23
View File
@@ -1,23 +0,0 @@
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;
}
}
+3 -1
View File
@@ -64,7 +64,9 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
+1 -1
View File
@@ -8,7 +8,7 @@ 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.
* same-origin as the BFF (Caddy proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
-17
View File
@@ -1,17 +0,0 @@
#!/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
+26
View File
@@ -0,0 +1,26 @@
:80 {
# Same-origin API: the api-client uses relative URLs, so the browser calls this origin and Caddy
# forwards to the BFF — no CORS, and the DigiD token is attached by the app interceptor
# (S-08d/ADR-0010).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /self-service/* {
reverse_proxy bff:8080
}
handle /openbaar/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+6 -9
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the self-service portal (Angular → nginx).
# Multi-stage build for the self-service portal (Angular → Caddy).
# 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
@@ -13,15 +13,12 @@ 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
FROM caddy:2-alpine AS runtime
COPY apps/self-service/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/self-service/browser /usr/share/caddy
# 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
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/caddy/config.json
EXPOSE 80
-29
View File
@@ -1,29 +0,0 @@
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;
}
}
+3 -1
View File
@@ -64,7 +64,9 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
+1 -1
View File
@@ -15,7 +15,7 @@ export interface RuntimeConfig {
/**
* 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`,
* api-client actually calls (same-origin via the Caddy 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.
*/
@@ -21,16 +21,20 @@ 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,
@@ -56,6 +60,21 @@ describe('RegistrationPage', () => {
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 });
@@ -1,5 +1,5 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
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';
@@ -8,13 +8,16 @@ import { UtrechtComponentsModule } from 'ui';
* 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 {
export class RegistrationPage implements OnInit {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
@@ -31,6 +34,23 @@ export class RegistrationPage {
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);
+1 -1
View File
@@ -207,7 +207,7 @@ A slice is done when:
## 15. Out of scope for v1
- OpenMetadata data governance module (v3 slice).
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
- ~~Objecten as the authoritative register record store~~**delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
- Production-grade Helm chart (sketch only).
- Multi-tenancy.
- Real outbound notifications (email/SMS) — logged to console in v1.
@@ -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,82 @@
# 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.
- Tempo runs **single-binary**, so its distributor and ingester are one process and
some of its distributed-mode machinery is not just redundant but harmful. Its
ingester-pool health check is disabled (`ingester_client.pool_config`) because with
a single in-process ingester the check can never route around a failure — a 1s
loopback-gRPC deadline missed under CI load only evicted the one ingester and made
Tempo drop spans, which is how `verify-tracing` flaked (#156). Expect the same
shape from other distributed-mode knobs if we tune them; the fix is to switch to
real multi-ingester Tempo, not to re-enable them here.
## 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.
@@ -0,0 +1,53 @@
# ADR-0024: Expose OTel metrics with the (prerelease) Prometheus AspNetCore exporter
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-16c (#124), last of the S-16 (#17) split
## Context
ADR-0023 already fixed the shape of metrics collection: **Prometheus scrapes each
service's `/metrics`** (pull, no collector). S-16c implements it. That needs a package
that turns the OpenTelemetry `MeterProvider` into a Prometheus scrape endpoint inside
ASP.NET Core. The canonical one is `OpenTelemetry.Exporter.Prometheus.AspNetCore`
(`AddPrometheusExporter()` + `app.MapPrometheusScrapingEndpoint()`).
The catch: that exporter has **never had a stable release** — the whole OTel .NET
Prometheus exporter line is versioned `-beta` (we pin `1.17.0-beta.1`, matched to the
`1.17.0` core we already use). Adding it is a new dependency (CLAUDE.md §14), and taking
a prerelease package into all five services is the decision worth recording.
## Decision
**Add `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` to the five .NET
services and expose `/metrics` with it.**
- What it gives us: the OTel-native pull endpoint, so the meters we already register for
tracing-adjacent instrumentation surface as Prometheus text with zero extra plumbing.
- What we'd write to replace it: a hand-rolled `IMetricsListener`/`MeterListener` that
formats Prometheus exposition text — real work, and a reimplementation of a widely-used
library for no gain.
- Risk it adds: a prerelease API that can shift between betas. Contained: it is only
wired in `Program.cs` (two calls per service, excluded from mutation), the version is
pinned, and `verify-metrics` proves the endpoint + scrape actually work each CI run.
The alternative — pushing metrics over OTLP to a collector that re-exposes them — was
already rejected in ADR-0023 (no collector hop). Not revisited here.
## Consequences
**Positive**
- Golden-signal metrics on `/metrics` with the standard OTel names
(`http_server_request_duration_seconds`, `dotnet_*`), scraped straight by Prometheus.
- No collector, no bespoke exposition code.
**Negative / costs**
- A `-beta` package in production services. Mitigated by the pin + the `verify-metrics`
CI gate; upgrading tracks the OTel core version bumps.
## Coupling rules touched (CLAUDE.md §8)
None. Metrics are passive: Prometheus pulls; no service calls into the stack.
@@ -0,0 +1,58 @@
# ADR-0025: The BFF reads the catalogus directly from the ACL
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-15a (#130), first of the S-15 (#16) split
## Context
The beheer portal shows a read-only view of the ZTC catalogus (the published
zaaktypen). Two coupling rules constrain where that data can come from:
- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the
catalogus read *must* originate in the ACL.
- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only
through the BFF.
That leaves the question of *how the BFF gets the data*. Until now the BFF fanned
out to exactly two backends — the Domain Service and the read projection. The
catalogus is neither: it is not a registration (domain) nor a projected read model.
## Decision
**The BFF calls the ACL directly for the beheer catalogus read** — a new typed
`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`,
mirroring the existing `IDomainClient` / `IProjectionClient` pattern.
Rejected alternative — **route it through the Domain Service** (BFF → domain →
ACL): the catalogus is not a domain concern, so the domain would gain a
pass-through endpoint that owns no aggregate and no invariant, blurring the
domain's responsibility purely to avoid a new edge. That is worse coupling, not
better.
This adds one service-to-service edge (BFF → ACL) — an architecturally
significant boundary change (§14), hence this ADR. It does **not** bend §8: the
ACL stays the only code that reads ZGW, and the portal still talks only to the
BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the
beheerder authorization lives at the BFF (medewerker realm + `beheerder` role).
## Consequences
**Positive**
- The catalogus read follows the shortest honest path; the domain stays about
registrations.
- Symmetric with the other downstream clients — nothing new to learn.
**Negative / costs**
- The BFF now depends on three backends instead of two. The ACL must be reachable
for the beheer portal to load (it already is — the BFF is on the same network).
- A second consumer of the ACL (alongside the domain and event-subscriber), so
ACL read endpoints are now part of more than one caller's contract.
## Coupling rules touched (CLAUDE.md §8)
A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the
reason this ADR exists.
@@ -0,0 +1,61 @@
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-15b (#131), second of the S-15 (#16) split
## Context
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
and have the next zaak reflect them — so the defaults must become mutable at runtime.
Two questions: **what** is editable, and **where** the mutable state lives.
## Decision
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
zaak; the beheer `PUT /default-fill` replaces it.**
### Only the three ZGW fill fields are editable
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
### In-memory, not persisted
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
reverts to the configured env. That is acceptable for this reference app: the slice
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
compose service.
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
audited, multi-instance config is needed — the port stays the same.
## Consequences
**Positive**
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
**Negative / costs**
- Edits don't survive a restart and aren't shared across replicas (single-instance
assumption). Documented ceiling above.
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
store) — a deliberate split by editability.
## Coupling rules touched (CLAUDE.md §8)
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
The ACL remains the owner of the ZGW-facing config.
@@ -0,0 +1,81 @@
# ADR-0027: The RegisterRecord objecttype is public-safe by construction
- **Status:** Accepted
- **Date:** 2026-07-27
- **Deciders:** Respellion engineering
- **Slice:** S-18c (#141), third of the S-18 (#19) split
## Context
S-18 stands up Objecttypen (S-18a) and Objecten (S-18b) as the authoritative
register-record store (PRD §"Objecten as the authoritative register record store").
S-19 (#20) will, on approval, write the canonical register record to the Objecten API
instead of OpenZaak zaak-eigenschappen, and the openbaar (public) register will read it.
Objecten validates every object against a **objecttype version's JSON schema**. So the
schema is a contract: it fixes which fields a register record may carry. The register is
read **anonymously** by the openbaar portal (ADR-0010), so the schema is also a
disclosure boundary — anything the schema allows can end up public.
Two questions: **which fields** the schema defines, and **how** the objecttype gets into
the Objecttypen API (which has no declarative objecttype step).
## Decision
**Define a `RegisterRecord` objecttype whose published schema carries exactly the
public-safe fields — `id`, `status`, `reference` — and register it over the API at
startup with a one-shot, idempotently.**
### The schema mirrors the BFF's public projection, not the internal one
The public-safe field set already exists: the BFF's `OpenbaarEntry`
(`services/bff/Bff.Api/DownstreamClients.cs`) — `id`, `status`, `reference` — is what
`OpenbaarProjection.PublicView` narrows every row down to, dropping `bsn` and
`naamPlaceholder` at the boundary (S-09). The RegisterRecord schema mirrors that record,
**not** the internal `RegisterEntry` / `RegisterEntryRow` (which carry bsn/naam):
| field | type | notes |
|-------|------|-------|
| `id` | string (required) | zaak id — the entry's stable key |
| `status` | string (required) | enum `INGEDIEND` \| `INGESCHREVEN` (`RegistrationStatus`) |
| `reference` | string \| null | citizen-facing zaak identificatie (ADR-0012) |
`additionalProperties: false` so a record can't smuggle a field the schema didn't
sanction, and `dataClassification: "open"` records the intent that this objecttype is
public. **`bsn` and `naamPlaceholder` are deliberately absent** — public-safe by
construction, so S-19 cannot write a personal-data field into the public register even by
mistake.
### Registered over the API by a one-shot, not setup_configuration
The Objecttypen API's `setup_configuration` (3.4.2) provisions only tokens — it has no
declarative step to create an objecttype with a schema. So a `registerrecord-init`
compose one-shot (stdlib Python, on the stack network) creates the objecttype + a
**published** version over the API once Objecttypen is healthy, following the ADR-0020
self-seed pattern. It is **idempotent**: if a `RegisterRecord` with a version already
exists it is a no-op, so it is safe on every `up`.
- ponytail ceiling: no schema-migration/versioning story — a schema change means editing
`registerrecord.schema.json` and bumping the version by hand; the one-shot only ever
adds v1 if none exists.
- Upgrade path: if the schema evolves, have the one-shot diff the published schema and
POST a new version, or move to a declarative step once the upstream supports one.
## Consequences
**Positive**
- The public register's disclosure surface is fixed in one reviewed artifact
(`registerrecord.schema.json`) and enforced by Objecten's own validation.
- Self-seeds on a fresh `make up` / bare local compose; no manual step, no built image.
**Negative / costs**
- The public-safe field set now lives in two places — the BFF's `OpenbaarEntry` and this
schema — that must be kept in sync by hand (a drift check is a candidate for later).
- Hand-managed schema version (ceiling above).
## Coupling rules touched (CLAUDE.md §8)
None new. Registration talks to the Objecttypen API over its documented API. S-19 will
write records via the ACL (§8.1) — this ADR only fixes the schema they conform to.
@@ -0,0 +1,175 @@
# ADR-0028: Objecten holds the register, OpenZaak holds the process
- **Status:** Accepted
- **Date:** 2026-08-14
- **Deciders:** Respellion engineering
- **Slice:** S-19a (#149), first of the S-19 (#20) split
## Context
Until this slice the register existed only as a **derived** thing: the read projection
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
system anywhere that holds "who is registered" as a first-class record — drop the
projection database and the only way back is to replay ZGW history and re-derive it.
That is the wrong shape for a register. A BIG registration is a **fact about a person**
that outlives the case that produced it: it is looked up, corrected, superseded, and
retained on its own schedule. The zaak that produced it is a **process record** — it
opens, moves through statussen, and closes. Storing the fact inside the process record
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
lifecycles together: the register can then never be read, retained, or corrected without
going through the case system that happened to create it.
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
register record lives, and who writes it.**
## Decision
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
register record.**
### Not zaak eigenschappen
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
They inherit the zaak's lifecycle and its archiving regime, and they give the public
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
lifecycle the zaak cannot drag around with it.
### The ACL writes it, not the domain or the Event Subscriber
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
Event Subscriber write the record when it sees the status notification — would make the
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
removes.
### Two writes, converging rather than transactional
Approval is now two writes across two modules, so it cannot be atomic. Both are made
idempotent instead:
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
existing object with that `id`, then PATCH it or POST a new one.
A caller that retries a half-failed approval therefore converges. This is the same
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
not an exception carved out for this path.
### The objecttype is resolved by name, lazily
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
cannot be pinned in config — the ACL resolves them by the configured name
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
same reasoning as ADR-0021 for zaaktypen.
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
is not cached, so it is retried on the next approval.
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
transient typed `HttpClient` — in practice one extra GET per approval against a
neighbouring container.
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
if approvals ever get hot enough for that GET to matter.
### The objecttype's UUID is pinned, not server-assigned
Objecten refuses to store an object whose objecttype it has not been configured with
(`ObjectType with url=… is not configured`), and its configuration identifies an
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
when the container starts, before the `registerrecord-init` one-shot has run.
Rather than thread a seed-time UUID from one container into another's config, the UUID is
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
fixed UUID (the Objecttypen API accepts a client-supplied one), and
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
declared up front, both stay idempotent, and neither has to wait for the other.
The cost is a constant duplicated across two files that must be kept in step; each carries
a comment pointing at the other.
### The ACL must reach Objecttypen at the URL Objecten knows it by
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
matches an incoming object's `type` against the `api_root` it was configured with. So an
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
URL that Objecten then rejects as "not one of the available choices" — even though it is
the same objecttype.
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
integration tests only pass from inside the compose network.
### Objecten's notifications are off for this slice
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
no abonnement for it. Configuring only the client side would make writes succeed while
every message was dropped on the floor — a delivery path that looks wired and isn't. So
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
register write yet.
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
## Consequences
**Positive**
- The register is a first-class record with its own schema, lifecycle and query surface,
independent of the case that produced it.
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
discipline in projection code.
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW —
done in S-19b-2 (#153), ADR-0030.
**Negative / costs**
- Approval writes to two modules and is eventually consistent; a failure between them
leaves a zaak in eindstatus without a register record until the approval is retried.
Nothing repairs that automatically yet.
- One more upstream module on the approval path, and one more dev credential
(`Acl__Objecten__Token`) in compose.
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
name (compose + `register.py`).
- ~~Until S-19b lands, the public register is still read from the NRC-derived projection, so
the register record is written but not yet read — the two must agree.~~ Closed by ADR-0030:
the projection is now derived from the register, so there is only one source to agree with.
## Coupling rules touched (CLAUDE.md §8)
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
and no service reaches Objecten's database.
## Verification
The end-to-end assertion lives in the Playwright happy path
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
public-safe schema.
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
calls the ACL. The e2e is the only check that drives a real approval.
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
pair: two writes for the same id leave exactly one object, carrying the second write's
status and nothing outside the public-safe schema.
All three findings above — the pinned UUID, the notifications block, and the base-URL
constraint — came out of running the gateway against those live modules while writing the
slice, not out of CI.
@@ -0,0 +1,122 @@
# ADR-0029: Objecten publishes register events to NRC
- **Status:** Accepted
- **Date:** 2026-08-14
- **Deciders:** Respellion engineering
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
## Context
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
turning the client side on alone would have produced a delivery path that looks wired and
drops every message.
S-19b-2 (#153) wants the read projection sourced from register writes rather than
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
builds the four missing pieces and lifts the ceiling.
## Decision
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
exactly as ADR-0007 did for OpenZaak.**
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
alongside `zaken`.
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
`objecten-redis` db 1 (db 0 is already the cache).
### One NRC, one credential, one kanaal per publisher
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
authorization is needed. A second NRC, or a second credential, would buy isolation this
reference application has no use for.
The kanaal name is **not ours to choose**: the Objects API sends
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
```
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
```
which `objecten-celery` then retries with exponential backoff, forever, in the background —
the write itself having returned 201.
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
caller side: the `objecten` service carries an `objecten.local` network alias, and every
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
integration tests, this slice's verify check — addresses it there. An alias rather than a
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
unaffected and keep using the plain service name.
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
match Objecten's configured `api_root`": these modules put request-derived hosts into data
another module then validates or dereferences.
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
and silently no notification.
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
so the plain name stops working, rather than adding a lint.
### A worker, not a synchronous send
`notifications_api_common` only schedules the send on transaction commit. Without a worker
the task sits in redis forever and every register write is silently undelivered — the exact
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
subscriber, and `nrc-beat` already drains NRC's delivery queue.
## Verification
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
asserts the notification reaches the sink. That is the whole chain in one assertion:
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
config.
## Consequences
**Positive**
- A register write is now observable by anything that subscribes, which is what S-19b-2
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
**Negative / costs**
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
now touches two modules.
- Objecten now has two in-network names, and which one a caller uses silently decides
whether its writes are notified (ceiling above).
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
is visible only in the worker log.
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
read NRC's own delivery admin rather than building a retry layer here.
## Coupling rules touched (CLAUDE.md §8)
None bent. This is infrastructure between two upstream modules, over their documented
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
@@ -0,0 +1,141 @@
# ADR-0030: The read projection is sourced from the register, not from ZGW
- **Status:** Accepted
- **Date:** 2026-08-28
- **Deciders:** Respellion engineering
- **Slice:** S-19b-2 (#153), second of the S-19b (#150) split
- **Builds on:** ADR-0008 (read projection store), ADR-0028 (Objecten holds the register), ADR-0029 (Objecten publishes to NRC)
## Context
ADR-0028 moved the authoritative register record into the Objecten API, and said what should
follow: "the read projection can become a cache of Objecten rather than a re-derivation of
ZGW." Until this slice it was still the latter — the Event Subscriber listened on the `zaken`
kanaal and inferred register state from case events:
- a `zaak`/`create` meant INGEDIEND;
- any `status`/`create` was taken to be the approval, so meant INGESCHREVEN — the subscriber
may not read OpenZaak (§8.1), so it could not tell one statustype from another;
- the citizen-facing reference was not in the notification at all, so every projection had a
second hop: ask the ACL for the zaak's identificatie (#78).
So the register — a fact about a person — was reconstructed by guessing at the lifecycle of the
case that happened to produce it. ADR-0029 made the register itself publish. This ADR switches
the projection over to it.
## Decision
**The Event Subscriber listens on the `objecten` kanaal and projects the `RegisterRecord` the
notification points at. The projection is a cache of the register; ZGW is no longer a source.**
- The subscriber's abonnement moves from `zaken` to `objecten` (`register-abonnement.py`, and
the CI projection check).
- An Objecten notification carries **no record data** — only the object URL and the objecttype
as a kenmerk — so the record is read back through the ACL (`POST /register-records/read`).
§8.1 applies to Objecten exactly as ADR-0028 established: the ACL is the only code that talks
to it.
- The accepted acties are `create`, `update` and `partial_update`. The last one is not
defensive breadth: the ACL upserts with PATCH, and DRF routes a PATCH through the notifying
`update()` while naming the action `partial_update` — which is what Objecten publishes. So
every approval arrives as `partial_update`, and accepting only `create`/`update` drops the
one state change this slice exists to project. `destroy` is deliberately not accepted:
removing a registration from the public register is its own decision.
- The record already carries `id`, `status` and `reference`, so the row is the record. The
zaak-shaped surface goes: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId`, and
`ToEntry`'s `Resource == "status"` inference are replaced by `IsRegisterRecordWritten` +
`ObjectUrl`, and the ACL enrichment hop disappears.
### The ACL writes an INGEDIEND record on submit
Before this slice only approval wrote a record, so re-sourcing alone would have silently
dropped every INGEDIEND row from the public register. `OpenZaakAsync` therefore upserts a
record with status INGEDIEND after opening the zaak, keyed on the same zaak id that approval
later upserts to INGESCHREVEN.
This is the same two-writes-converging posture ADR-0028 already accepted for approval, now on
the submit path too: both writes are idempotent, so a retried submit updates the record rather
than adding a second one (§8.6). The reference comes from the registration itself, so unlike
approval this path needs no ZGW read-back.
The alternative — a register holding only INGESCHREVEN — is arguably the more correct reading
of "public register", but it narrows what the openbaar portal shows and reads against PRD §68
("~50 register entries with diverse statuses"). Rejected as a behaviour change this slice was
not asked to make.
### The dedup key is the projected row, not the notification
NRC carries no notification id and may redeliver, so the idempotency key is derived from
content (as before). The obvious candidates both break here:
- **the object URL alone** — the ACL upserts *one object per registration*, so submit and
approval notify about the same URL, and the approval would be swallowed as a duplicate;
- **object URL + actie** — a retried approval is a second `update`, so it would be dropped
while genuinely being the same state (harmless), but a *third* distinct state would collide
with it (not harmless).
The key is therefore the object plus the state that write puts in the projection —
`objecten:object:{url}:{status}:{reference}`. A redelivery collapses; a genuine state change
does not. That is exactly the property §8.6 asks for, and it needs no version field from
Objecten's internals.
### The notification log holds the row, not the event
`processed_notifications` stops describing ZGW events (`actie`, `zaak_id`, `resource`) and
holds the projected row itself (`register_id`, `status`, `reference`). A rebuild becomes a
replay with no mapping rules and no upstream reads at all — §8.4 held before via the ACL hop;
now it holds outright.
The migration **drops** the old columns rather than renaming them. EF scaffolded renames
(`resource``register_id`, `zaak_id``status`) that would have carried ZGW values into
columns meaning something else entirely, and a rebuild would then have projected that garbage.
- ponytail ceiling: the migration empties both tables. A pre-slice row describes a zaak event
the new projector cannot reproject, and the registrations behind those rows have no
RegisterRecord in Objecten (only approvals wrote one), so they are not re-derivable from the
new source either.
- Upgrade path: fine while stacks are ephemeral. If a long-lived environment ever needs to keep
them, backfill by walking Objecten's objects rather than replaying the log.
## Consequences
**Positive**
- The register is read from the register. The projection is a derived cache of a first-class
record, not an inference over someone else's lifecycle.
- The "any status-create is the approval" guess is gone — a real source of wrongness the moment
the zaaktype grows a second statustype.
- One hop fewer per notification: the record carries its own reference, so the ACL enrichment
call disappears.
- A rebuild needs nothing but its own log (§8.4).
**Negative / costs**
- Submission is now two writes across two modules and eventually consistent. A failure between
them leaves a zaak with no register record until the submit is retried; nothing repairs that
automatically yet — the same gap ADR-0028 recorded for approval, now on a second path.
- The projection lags the register by a notification round trip, where it used to lag the zaak
by one. In practice the same order of magnitude.
- Projecting now depends on the ACL being reachable, where the reference enrichment used to be
the only ACL dependency. A failed read means the notification is not logged and not
projected — NRC retries, so it converges, but the failure mode is now on the main path.
- OpenZaak still publishes to `zaken` and nothing in the product listens. Kept because the
`verify-nrc` check asserts that path, and turning off a working publisher to save nothing
would be its own risk.
## Coupling rules touched (CLAUDE.md §8)
None bent. §8.1 holds — the subscriber reaches Objecten only through the ACL. §8.4 is
strengthened: the projection is rebuildable from its own log, with no upstream reads at all.
§8.6 is what the dedup-key discussion above is about.
## Verification
`make verify-projection` (`infra/run-projection-check.sh`, in CI's `verify-stack`) opens a zaak
**through the ACL** and asserts projection-api serves a row for it with status INGEDIEND — the
whole new chain in one assertion: ACL → Objecten → `objecten-celery` → NRC → `nrc-beat`
Event Subscriber → projection → projection-api. A zaak created behind the ACL's back produces
no row, which is the re-source working rather than a gap.
`RegisterProjectieBijwerken.feature` covers the use case in business language, including the
approval case — the same row moving INGEDIEND → INGESCHREVEN, which is now one registration's
record being updated rather than two unrelated ZGW events.
@@ -0,0 +1,49 @@
# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret
- **Status:** Accepted
- **Date:** 2026-09-03
- **Slice:** S-15c (Gitea #132)
## Context
Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's
default-fill: the highest-privilege logins in the platform. The medewerker realm protected
them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock
brokers that carry their own assurance levels. A reference application that demonstrates a
government architecture should show MFA on the staff realm.
Two things had to be decided: **how** to enforce OTP in a realm export, and **how the
automated checks and a human demo obtain a code** — the e2e drives a real browser login and
`make keycloak-smoke` drives a real password grant, so neither can scan a QR.
## Decision
**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing
Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`.
Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP*
subflow that fires when the user has an OTP credential. Seeding the credential therefore
turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines
of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required
action**, so a medewerker created later must enrol before their first login.
**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all
medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib
`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in
about six lines — no OTP dependency on either side, and no enrolment step in the tests.
## Consequences
- A password alone no longer yields a token on the medewerker realm; `check_realms.py`
asserts that refusal, so the enforcement cannot silently regress.
- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP
form. New staff specs must use it.
- **The secret is public.** It is a demo fixture and worthless outside this synthetic
stack, in the same class as the committed `test123` passwords and the mock DigiD broker.
A real deployment enrols per-user authenticators (or federates to DigiD Machtigen /
eHerkenning at the required assurance level) and seeds no credentials at all.
- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional
subflow is what fires. A medewerker whose OTP credential were removed would fall back to
the required action at next login (enrol, then challenge) rather than skipping MFA — an
acceptable equivalence for this purpose, and the reason the required action is set.
- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block.
@@ -0,0 +1,79 @@
# ADR-0032: The werkbak refreshes itself by polling, not by a pushed stream
- **Status:** Accepted
- **Date:** 2026-09-04
- **Deciders:** Respellion engineering
- **Slice:** #162 (proposal #163). The issue titles it S-26; that id already belongs to
the self-service resume slice (#111), so #162 is the identifier that counts.
## Context
The werkbak (S-12) is a read of the open Flowable `Beoordelen` tasks: portal → BFF
`GET /behandel/werkbak` → domain `Werkbak` query → workflow engine, each task enriched
from its aggregate. A registration reaches `Beoordelen` **asynchronously**, only once the
citizen supplies its documents and the DMN routes it (S-10a) — so it appears in a werkbak
that is already open, and until now a behandelaar had to reload the page to see it.
Three forces shape the mechanism:
- **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish
task events, and there is no bus between the domain and the BFF.
- **The BFF is stateless** and sits behind each portal's reverse proxy.
- **This is the repo's first live-updating view**, so the choice sets a precedent.
## Decision
**The werkbak page re-reads the existing BFF endpoint on a fixed interval
(`WERKBAK_REFRESH_MS`, 5 s) while it is open. No new endpoint, dependency or server-side
state.**
The refresh is a *background* read: it leaves the rows and the loading/failure states
untouched until it has an answer, so a tick never flashes a spinner over rows a
behandelaar is reading and a single failed poll never swaps the list for the error alert.
A read that comes back also clears an earlier failure, so the view recovers on its own —
the same reload this slice set out to remove would otherwise be needed to escape a
transient error. Only a foreground read (on open, after a decision) speaks for whether the
werkbak is readable at all.
### Why not SSE or WebSockets
Neither buys freshness here, because **nothing notifies the BFF either**:
- **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the
domain and forwards diffs — the same latency, plus connection lifecycle, proxy
buffering, and auth on a long-lived connection.
- **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and
sticky-session-bound. A genuine push path would *also* need the domain to publish task
events. Warranted by high-frequency, bidirectional or fan-out-heavy traffic; the werkbak
is none of those.
Polling meets the acceptance ("a registration can be seen in the werkbak once it is ready
for review") in a handful of lines inside one component.
- ponytail ceiling: a fixed 5 s interval, per open page, that keeps polling in a
background tab. Each tick costs one Flowable task query plus a store read per open task.
- Upgrade path: publish task events from the domain, then swap the component's `interval`
for a stream. The endpoint contract and the component's rendering stay as they are;
gate on `document.visibilityState` first if request volume is the concern.
## Consequences
**Positive**
- The outcome is delivered with no new endpoint, dependency, or server-side state, and no
service boundary moves.
- Self-healing: a transient read failure no longer strands the view until a manual reload.
- The e2e got *simpler* — the happy path waits for the werkbak row without reloading the
page, which is itself the live-refresh assertion.
**Negative / costs**
- Staleness is bounded by one interval (≤5 s) rather than instant.
- One `GET /behandel/werkbak` per open werkbak per interval, including in hidden tabs.
- The precedent is polling; a future view with genuinely high-frequency updates will have
to revisit this (see the upgrade path above).
## Coupling rules touched (CLAUDE.md §8)
None. The poll reuses the existing portal → BFF → domain read path: §8.3 (portals talk
only to the BFF) and §8.2 (only the Workflow Client talks to Flowable) are unchanged.
@@ -0,0 +1,161 @@
# ADR-0033: Kubernetes deployment is one values-driven Helm chart, not a chart per service
- **Status:** Accepted
- **Date:** 2026-09-04
- **Deciders:** Respellion engineering
- **Slice:** _(none yet — raised directly as a deployment-target request; see
"Process note" at the end)_
## Context
The stack is defined once, in `infra/docker-compose.yml`: 30-odd containers made of six
upstream Common Ground modules (OpenZaak, Open Notificaties, Objecten, Objecttypen,
Keycloak, Flowable), their databases and workers, five .NET services, four portals, six
one-shot bootstrap containers, and an observability backplane (off by default here). Compose is the
CI-canonical stack: `make verify` and every `verify-*` script drive it.
We now also want the stack on Kubernetes — first target a **single-node Talos VM on a
laptop**. Four properties of this particular stack shape the answer:
- **The upstream images are used verbatim** and read their configuration from a mounted
directory (`setup_configuration/data.yaml`, Keycloak realm exports, BPMN/DMN). Compose
streams those files into external volumes (`infra/seed-config.sh`) because bind mounts
don't reach sibling containers on the CI runner. Kubernetes needs the same files as
ConfigMaps — from *somewhere*.
- **Django's `URLValidator` rejects single-label hosts.** Compose works around it by
handing the ACL and the seeds a container *IP* (ADR-0009, ADR-0020, ADR-0029, and the
`objecten.local` network alias). In Kubernetes a Service FQDN is already multi-label, so
the workaround has a natural replacement — but the hosts have to line up exactly, since
Objecten reflects the request Host into the URLs it publishes to NRC.
- **The OIDC issuer must be one string** for both the browser and the BFF (ADR-0010).
`infra/host-browser.yml` already solved this for a host browser: pin `KC_HOSTNAME`, keep
backchannel discovery in-cluster, and mount a `config.json` per portal.
- **Nothing here is highly available.** One replica of everything, on one node.
## Decision
**One chart — `infra/helm/big-reference` — whose `values.yaml` is a near-literal
transcription of the compose file, rendered by three generic templates (Deployment, Job,
Service) over a `workloads` map.** Adding a service is a values edit.
Consequences of that shape, each chosen deliberately:
- **Config files are not copied into the chart.** `infra/helm/seed-configmaps.sh` creates
the ConfigMaps from the files that already live in the repo — the Kubernetes sibling of
`infra/seed-config.sh`. The chart therefore needs `make k8s-seed` before `helm install`,
which is the same two-step dance compose already has.
- **Bootstrap one-shots become Jobs, with no ordering mechanism.** Every one is idempotent
(ADR-0020); each waits for the TCP ports it needs via a busybox init container and
Kubernetes retries the rest. `make k8s-reseed` re-runs them.
- **The four Django services apply their own `setup_configuration`**
`args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]` — instead of getting a
separate `*-init` Job like compose. Both of those image scripts run
`manage.py migrate`, and compose serialises them with
`depends_on: service_completed_successfully`; Kubernetes has no such edge, so a Job and
its web pod migrate the same database concurrently and Django dies with
*"relation zgw_consumers_service already exists"*. Running the two steps in order inside
the one container leaves exactly one migrator per database, and deletes four workloads.
- **`args`, never `command`.** Compose's `command:` replaces the image's CMD; Kubernetes'
`command:` replaces its ENTRYPOINT. Transcribing one to the other silently broke every
upstream image that relies on its entrypoint — postgres ran as root and refused to
start, Keycloak tried to exec `start-dev` as a binary. The chart now `fail`s at render
time if a workload sets `command`, because the symptom (a crashloop three layers down)
is nothing like the cause.
- **Published ports are NodePorts.** No ingress controller, no LoadBalancer, no TLS. The
four portals are the exception in *use*, not in wiring: PKCE needs `crypto.subtle`, which
browsers expose only in a secure context, so a portal has to be reached over `localhost`
(`make k8s-portals` forwards them) or eventually over HTTPS. `.Values.host` is therefore
"the address the browser uses", not "the node's address" — it pins Keycloak's issuer and
each portal's `config.json`, and both must agree with the URL bar (ADR-0010).
- **Databases are `emptyDir` by default**, so the stack comes up on a cluster with no CSI
driver; setting `persistence.storageClass` switches every database to a PVC.
- **Only two hosts become FQDNs** — OpenZaak (for the ACL and the zaaktype seed) and
Objecten (for the ACL's register writes), the two that Django validates as URLs.
Everything else keeps the short compose service name, because the upstream
`setup_configuration` files name those and Objecten matches an objecttype URL against the
one it was configured with. The portals used to be a third case — nginx's `resolver` never
appends search domains, so the bare `bff` upstream could not resolve on Kubernetes — which
ADR-0034 removed by serving them with Caddy, whose resolver honours `/etc/resolv.conf`.
- **Compose stays CI-canonical.** The chart is a second deployment target, not a
replacement; the acceptance, verify and e2e lanes are unchanged.
### Alternatives considered
- **A chart per service, or an umbrella of 30 subcharts.** The conventional layout, and
roughly 1,500 lines of near-identical YAML for a stack where 28 of 30 workloads are
"one pod, one image, some env". It buys independent versioning we don't want (the stack
is demoed as a whole) and costs the eye-diffability against the compose file that keeps
the two stacks honest.
- **`kompose convert`.** One-shot generation, no ongoing artefact to maintain — but it
drops exactly the parts that carry the design (init ordering, the config volumes, the
issuer pinning) and produces output nobody owns.
- **Bitnami PostgreSQL/Redis subcharts.** Six more dependencies (CLAUDE.md §13) and a
second way of expressing the same three-line database.
- **ingress-nginx with hostname routing.** Needs a controller, `/etc/hosts` entries and a
matching issuer host; NodePorts need none of it and reuse the mechanism
`infra/host-browser.yml` already proves.
- **A registry on the laptop** (the obvious home for images built there). Talos cannot
side-load an image, so a registry is required either way — but reaching one on the host
means opening an inbound port on firewalld's `libvirt` zone, which needs root, and
pushing to it over plain HTTP means an `insecure-registries` entry in the Docker daemon,
which needs root again. `infra/helm/registry.yaml` runs the registry *in* the cluster on
a NodePort instead: pushing laptop → node is outbound and unfiltered, the node pulls from
its own NodePort, and `docker save | crane push --insecure` needs no daemon
configuration. Cost: one more (throwaway, `emptyDir`) workload, and a re-push if its pod
is replaced.
- **Helm hooks (`pre-install`/`post-install`) for bootstrap ordering.** Hooks run after
`--wait`, which would deadlock: OpenZaak's readiness needs the migrations that the hook
is supposed to run. Idempotent Jobs plus retries need no such sequencing.
- ponytail ceiling: single-node assumptions are baked in — one replica per workload,
`Recreate` rollouts, ReadWriteOnce volumes, no PodDisruptionBudgets, no resource
requests or limits (a laptop VM schedules everything or nothing), plain HTTP.
Upgrade path for a real cluster: add requests/limits per workload (the field is already
passed through), swap NodePorts for an Ingress with TLS, and give the databases a real
StorageClass — none of which changes the workload graph.
## Consequences
**Positive**
- One file to read to see what the cluster runs, and it lines up with the compose file
line for line.
- The compose IP workarounds disappear: cluster DNS supplies multi-label hosts.
- `make k8s-lint` renders and schema-checks the whole stack without a cluster.
- The config inputs have exactly one home (the repo) for both stacks — no fork to drift.
**Negative / costs**
- A second deployment description to keep in step with compose. Nothing enforces that
today; a drift check belongs in CI (follow-up).
- `helm install` alone is not enough — the ConfigMaps must be seeded first, and a missing
one surfaces as `ContainerCreating`, not as a clear error.
- Generic templates mean a values typo can render valid-but-wrong YAML; `k8s-lint` catches
schema errors, not intent.
- The verify/e2e lanes do not run against the chart, so the Kubernetes path is verified by
hand (docs/runbooks/kubernetes-talos.md §5) rather than by CI.
- The chart deviates from compose in four places now (args, self-configuring Django pods,
FQDN hosts, NodePorts). Each is forced by the platform and commented where it appears,
but it is four more things that can drift.
## Coupling rules touched (CLAUDE.md §8)
None. The chart deploys the same graph: portals reach only the BFF (§8.3), only the ACL
holds ZGW credentials (§8.1), only the Workflow Client talks to Flowable (§8.2), each
service keeps its own database (§8.5). No workload gained a peer it didn't have in compose.
## Verified
Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk)
under virt-manager: 29 pods ready and four bootstrap Jobs complete in under three minutes,
with zero restarts, using ~4.4 GB of the VM's 10 GB. The smoke test in the runbook's §5
walks the whole path — portal proxy → BFF → domain → Flowable → ACL → OpenZaak + Objecten →
NRC → event-subscriber → projection → public register — plus a werkbak read with an
MFA'd medewerker token. The browser flow itself was driven with Playwright against
`http://localhost:30140`: secure context, PKCE, Keycloak form, login, no console errors.
## Process note
CLAUDE.md §14 wants the ADR proposal issue opened before the code, and §7 wants a slice
issue behind the work. This landed the other way round — chart first, on request. The
issue and the CI drift check are the outstanding follow-ups.
@@ -0,0 +1,103 @@
# ADR-0034: The portals are served by Caddy, not nginx
- **Status:** Accepted
- **Date:** 2026-09-04
- **Deciders:** Respellion engineering
- **Slice:** _(none yet — raised directly alongside the Kubernetes deployment, ADR-0033)_
## Context
Each portal ships as one image that does two jobs: serve the built Angular app, and
reverse-proxy *its own* BFF endpoint group so the browser calls a single origin (no CORS,
and the DigiD/medewerker token rides along — ADR-0010, ADR-0013). Until now that was nginx
with a hand-written `nginx.conf` per app.
Two workarounds had accumulated around nginx's resolver, both for the same root cause:
**nginx resolves a variable `proxy_pass` upstream itself**, using only the `resolver`
directive, and never the search domains in `/etc/resolv.conf`.
1. `resolver 127.0.0.11` (Docker's embedded DNS) is wrong on rootless podman, which uses a
network-specific aardvark address — so `apps/portal-nginx-resolver.sh` rewrote the
directive at container start by reading the pod's actual nameserver.
2. On Kubernetes the bare `bff` name cannot resolve at all without the `svc.cluster.local`
search domain, so the same script gained a `BFF_HOST` override that the Helm chart set
per portal (ADR-0033).
Both existed only to tell the proxy how to resolve one hostname.
## Decision
**Serve the portals with `caddy:2-alpine` and a small `Caddyfile` per app, replacing the
nginx runtime stage, the four `nginx.conf` files, and the resolver workaround.**
Caddy dials its upstream per request through Go's resolver, which reads
`/etc/resolv.conf` — nameserver *and* search domains. So `reverse_proxy bff:8080` resolves
correctly under Docker, rootless podman and Kubernetes with no per-engine configuration,
and it still starts before the BFF exists and picks up its restarts (the property the
variable `proxy_pass` was there to buy). `apps/portal-nginx-resolver.sh`, its unit test and
the chart's `BFF_HOST` env are deleted.
The Caddyfile uses `handle` blocks rather than a bare `try_files`:
```
handle /behandel/* { reverse_proxy bff:8080 }
handle { root * /usr/share/caddy; try_files {path} /index.html; file_server }
```
`handle` blocks are mutually exclusive and matched most-specific-first. This matters:
Caddy's default directive order puts rewrites (`try_files`) *before* `reverse_proxy`, so a
top-level `try_files {path} /index.html` would rewrite every API path to `/index.html`
before the proxy ever saw it — the SPA fallback would silently eat the API. The `handle`
form makes the routing explicit instead of relying on directive-order trivia.
`infra/test_portal_caddyfiles.py` (in `make unit`) asserts each portal proxies exactly its
own endpoint groups and keeps the SPA fallback. The four files are near-identical, so a
copy-paste slip is cheap to make and expensive to find: proxying another portal's group
hands a browser an endpoint its token isn't for, and the failure surfaces as a 401 three
services away.
### Alternatives considered
- **Keep nginx.** Zero migration, and it works — but the resolver workaround stays, and it
had already grown a second head for Kubernetes. Both heads are nginx-specific.
- **Keep nginx, hard-code the FQDN.** Would need a different config per deployment target
(compose vs Kubernetes), which is exactly the fork the chart was written to avoid.
- **Drop the proxy and use CORS.** Turns the same-origin design (ADR-0010) inside out:
CORS preflights, an explicit origin allowlist in the BFF, and a token attached
cross-origin. Not a serving decision — an architectural regression.
- **Kubernetes Ingress in front of the portals.** Solves nothing about compose, adds a
controller, and the portals would still need something to serve static files.
- ponytail ceiling: plain HTTP on `:80`, no compression, no cache headers beyond Caddy's
defaults, and Caddy's automatic HTTPS deliberately unused (there is no hostname to get a
certificate for). Upgrade path: `encode zstd gzip` and a cache policy for immutable
Angular bundles; a real hostname makes TLS a one-line `Caddyfile` change, which is the
main reason this is worth having in place.
## Consequences
**Positive**
- One resolver behaviour across compose, podman and Kubernetes; a script, a unit test and a
chart env var are deleted rather than maintained.
- The images gain `curl` for free (the alpine nginx image had only busybox `wget`), which
the compose healthchecks can use.
- Routing intent is readable: one `handle` block per endpoint group, one for the app.
- TLS later is a one-line change instead of a new component.
**Negative / costs**
- A new runtime dependency in four images (CLAUDE.md §13): Caddy replaces nginx rather than
joining it, so the count is unchanged, but it is a less familiar config language for
anyone who has only read nginx configs.
- The images grew: 90.6 MB against nginx's 75.7 MB, because `caddy:2-alpine` carries a
bigger static binary than nginx's. Measured, not estimated.
- Caddy's directive-order rule is a genuine footgun (see above); the `handle` form and the
Caddyfile comments exist to keep the next person out of it.
- Any operational note that says "the portal's nginx" is now wrong; the ones in `docs/` were
updated with this ADR.
## Coupling rules touched (CLAUDE.md §8)
None. §8.3 is unchanged and unchanged in kind: the portals still talk only to the BFF, and
the proxy is still the thing that makes that same-origin.
+50
View File
@@ -0,0 +1,50 @@
# 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 in
[`../`](../adr-0001-loose-coupling.md) loopt van `adr-0001-loose-coupling` tot en met
`adr-0010-bff-oidc`. Vandaar de eigen map `fds/`: beide reeksen beginnen bij 0001, en de nummers
zouden anders over de volle breedte botsen.
In de MkDocs-navigatie staan deze zes daarom als **FDS ADR-000N**, zodat de zijbalk ze niet met de
reeks van de applicatie verwart.
Nieuwe FDS-ADR: kopieer [`adr/template.md`](adr/template.md), neem het volgende nummer, en open een
pull request.
@@ -0,0 +1,42 @@
# 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.
@@ -0,0 +1,44 @@
# 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.
@@ -0,0 +1,44 @@
# 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.
@@ -0,0 +1,48 @@
# 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.
@@ -0,0 +1,42 @@
# 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.
@@ -0,0 +1,68 @@
# 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.
+27
View File
@@ -0,0 +1,27 @@
# ADR-NNNN: <titel>
- **Status:** proposed
- **Datum:** JJJJ-MM-DD
- **Deciders:** <rollen>
- **Vervangt / vervangen door:**
## Context
<De krachten die spelen: het probleem, de beperkingen, de FDS- en AVG-drijfveren. Waarom er nu een
besluit nodig is.>
## Besluit
<De keuze, eenvoudig gesteld.>
## Gevolgen
**Positief:** <wat dit oplevert>
**Negatief en kosten:** <wat het kost, en wat wij aanvaarden>
**Vervolgwerk:** <welk werk dit oproept>
## Overwogen alternatieven
<De afgewezen opties, en waarom.>
+127
View File
@@ -0,0 +1,127 @@
# C4-componentview — register-applicatie en capability-laag
> Niveau 3, de componentview. Deze view zoomt in op de container van de .NET register-applicatie uit
> het L2-containerdiagram. Zij verbindt het geheel op componentniveau — domein, ports, adapters en de
> FDS-capability-componenten — en toont waar elk onderdeel externe tooling raakt.
>
> De hexagonale structuur is expliciet: het domein hangt alleen af van **ports** (interfaces). Elke
> concrete capability is een **adapter** die aan een port is gebonden.
>
> De containerview (L2), de blauwdruk en de privacy-datastroomviews staan in het Innovation Lab-repo,
> `Respellion/innovation-lab`, onder `projects/open-register-fd/`.
```mermaid
C4Component
title Componentview — register-applicatie (.NET) en de FDS-capability-laag
Person(user, "Behandelaar", "Behandelt zaken")
Container(spa, "Frontend", "Angular + NL Design System", "Zaakinterface")
Container_Boundary(app, "Register-applicatie (.NET, hexagonaal)") {
Component(api, "API / application services", ".NET", "Orkestreert use cases; verklaart doelbinding per vraag")
Component(domain, "Domeinmodel", ".NET / DDD", "Ubiquitous language; geen registervocabulaire")
Component(portReg, "Register Port", "interface", "De vraag van het domein: Personen / Organisaties")
Component(portPol, "Authorisation Port", "interface", "mag-deze-verwerking-doorgaan?")
Component(portLog, "Verwerking Port", "interface", "leg de verwerkingsgebeurtenis vast")
Component(portTm, "Terugmelding Port", "interface", "meld een vermoedelijke fout")
Component(portCache, "Cache Port", "interface", "doelgebonden lezen, schrijven en verwijderen")
Component(aclBrp, "BRP-adapter", ".NET", "Vertaalt domein<->BRP; minimale velden")
Component(aclKvk, "NHR/KVK-adapter", ".NET", "Vertaalt domein<->NHR; UBO-bewust")
Component(pdpClient, "PDP Client", ".NET -> OPA", "Roept de policy engine; geeft doel en grondslag mee")
Component(ldvEmit, "LDV Emitter", ".NET", "Bouwt het verwerkingsevent; publiceert naar Redpanda")
Component(fscClient, "FSC Client", ".NET", "Stuurt contractuele aanroepen via de outway")
Component(cacheMgr, "Cache Manager", ".NET", "TTL en verwijderen op subjectsleutel")
Component(tmHandler, "Terugmelding Handler", ".NET -> Flowable", "Start het terugmeldproces")
Component(procClient, "Process Client", ".NET -> Flowable", "Uitvoering van BPMN en DMN")
}
System_Ext(opa, "OPA (PDP)", "Policies geversioneerd in Gitea")
System_Ext(fsc, "FSC Outway", "EUPL-referentie-implementatie")
System_Ext(flowable, "Flowable", "BPMN + DMN")
ContainerDb_Ext(cache, "Begrensde cache", "PostgreSQL")
System_Ext(redpanda, "Redpanda", "LDV-topic + CDC")
System_Ext(brp, "BRP", "via FSC inway")
System_Ext(kvk, "NHR / KVK", "via FSC inway")
System_Ext(kanidm, "Kanidm", "OIDC")
Rel(user, spa, "Gebruikt")
Rel(spa, api, "REST/JSON")
Rel(kanidm, api, "OIDC", "authenticatie")
Rel(api, domain, "Roept aan")
Rel(api, portPol, "Controleert vóór de bevraging")
Rel(api, portReg, "Vraagt data")
Rel(api, portTm, "Dient melding in")
Rel(api, procClient, "Voert proces uit")
Rel(portPol, pdpClient, "gebonden aan")
Rel(pdpClient, opa, "besluitverzoek")
Rel(portReg, aclBrp, "gebonden aan")
Rel(portReg, aclKvk, "gebonden aan")
Rel(aclBrp, fscClient, "via")
Rel(aclKvk, fscClient, "via")
Rel(aclBrp, portLog, "stuurt event")
Rel(aclKvk, portLog, "stuurt event")
Rel(aclBrp, portCache, "leest en schrijft")
Rel(aclKvk, portCache, "leest en schrijft")
Rel(fscClient, fsc, "contractuele aanroep")
Rel(fsc, brp, "mTLS + contract")
Rel(fsc, kvk, "mTLS + contract")
Rel(portLog, ldvEmit, "gebonden aan")
Rel(ldvEmit, redpanda, "publiceert")
Rel(portCache, cacheMgr, "gebonden aan")
Rel(cacheMgr, cache, "slaat op")
Rel(portTm, tmHandler, "gebonden aan")
Rel(tmHandler, flowable, "start proces")
Rel(procClient, flowable, "voert uit")
```
## Hoe je dit leest
1. **De ports zijn de naad.** Het domein en de application services hangen af van de vijf interfaces,
en nooit van adapters. FSC wisselen voor DSP, of OPA voor de latere FTV-client, verandert een
adapter — geen port, en niet het domein. Dit is de clock-speed boundary, concreet gemaakt.
2. **De compliance-componenten zijn adapters, geen domeinlogica.** De PDP-client, de LDV-emitter, de
FSC-client en de cache manager staan allemaal aan de adapterzijde. Een bevraging kan er fysiek niet
langs, omdat de adapter die de Register Port vervult dezelfde code is die het LDV-event uitstuurt
en via FSC routeert.
3. **Slechts twee componenten raken de registers**: de BRP-adapter en de NHR/KVK-adapter. Beide
bereiken ze uitsluitend via de FSC-client. Er is geen vierde pad.
## Componenten tegenover verplichtingen
| Component | Omvang eigen bouw | Verplichting die het afdekt |
|---|---|---|
| Domeinmodel | het product | correctheid van de bedrijfsregels |
| BRP- en NHR-adapters | dun | dataminimalisatie: vertalen en velden versmallen |
| PDP Client | klein | handhaven van grondslag en doelbinding |
| LDV Emitter | klein | verwerkingenlog (AVG art. 30 en LDV) |
| FSC Client | klein | geautoriseerde, gelogde connectiviteit |
| Cache Manager | klein | grenzen aan bewaring, en verwijdering |
| Terugmelding Handler | klein | de terugmeldplicht van de afnemer |
---
## Aanvullende views die voor engineers waarde hebben
De diagrammen tot hier verklaren *structuur* en *compliance-intentie*. Engineers die dit bouwen,
hebben er nog een aantal nodig. Wij tekenen geen view voordat er iets echt is om te beschrijven, dus
elke regel noemt de trigger.
| # | View | Wat het toevoegt | Trigger |
|---|---|---|---|
| 1 | **Deploymentview** (C4 deployment, topologie) | Waar elke container op De Werf draait: k3s-namespaces, welke services sidecar zijn en welke een eigen pod (is OPA een sidecar of centraal? waar eindigt de FSC outway?), netwerkpolicies tussen de vlakken van de vertrouwensgrens, en beheer van secrets en mTLS-certificaten voor FSC. Hier worden de privacy*grenzen* echte firewall- en netwerkregels. | Vóór de eerste deploy met meerdere services. **Hoogste waarde als volgende.** |
| 2 | **Sequences voor de niet-gelukkige paden** | Wij hebben het gelukkige pad. Engineers hebben de lastige nodig: PDP-*deny* midden in een transactie, een verlopen of ingetrokken FSC-contract, een register-timeout terwijl er een verouderde cache-entry ligt, en een gedeeltelijk NHR-antwoord waarbij een UBO-veld is achtergehouden. Dit bepaalt de foutafhandeling, en hier verstoppen de compliance-randgevallen zich. | Direct na slice 1. |
| 3 | **Domeinmodel en ERD** | De bounded contexts en aggregates in het domein, plus het cacheschema: welke persoonsgegevens blijven staan, op welke sleutel, en met welke purge-kolom. Dit is tegelijk het artefact dat de FG beoordeelt voor bewaartermijnen. | Zodra het domein in slice 1 stabiliseert. |
| 4 | **Dataclassificatie- en catalogusview** | Elk veld dat een grens kruist, getagd — persoonsgegeven? bijzondere categorie? UBO-beperkt? — en gemapt op zijn classificatie in OpenMetadata. Dit stuurt de GDPR-scrubbingregels en de lineage-tags. | Beter *uit* OpenMetadata gegenereerd zodra die gevuld is, dan met de hand getekend. |
| 5 | **Toestandsdiagram: levensloop van een cache-entry** | `fetched``valid` (binnen TTL) → `stale``purged` (TTL verstreken \| zaak gesloten \| verwijderingsverzoek). Klein, maar het pint de bewaarsemantiek vast die "begrensde cache" nu alleen in prose beschrijft. | Samen met ADR-0004-vervolgwerk. |
| 6 | **BPMN-view: de terugmelding-workflow** | Het Flowable-proces zelf: ingediend → verstuurd naar bronhouder → bevestigd → opgelost of afgewezen. Dit is uitvoerbaar BPMN, dus het diagram en de implementatie zijn hetzelfde artefact. | Wanneer de terugmelding-slice start. |
| 7 | **Threat model en vertrouwensgrensview** (STRIDE-stijl) | Dreigingen over de vertrouwensgrens leggen: tokendiefstal, cache poisoning, replay tegen FSC, policy bypass, en manipulatie van logs. Past natuurlijk bij de FSC-zoom, en is het anker van het securitygesprek. | Vóór het verwerken van echte persoonsgegevens. |
| 8 | **CI/CD- en policy-promotieview** | Hoe OPA-policies en BPMN/DMN-modellen van een pull request naar draaiende configuratie gaan. "Toegangsbeheer is configuratie in Gitea" geldt alleen als er een pijplijn is die review en promotie handhaaft. | Samen met het vervolgwerk uit ADR-0003. |
**Voorstel voor de volgende twee.** De **deploymentview**, omdat die de privacygrenzen omzet in
handhaafbare netwerkpolicy. En de **sequences voor de niet-gelukkige paden**, omdat compliance daar
werkelijk breekt.
+103
View File
@@ -0,0 +1,103 @@
# POC-voorstel — slice 1: walking skeleton (één register, gegoverneerde bevraging)
> Klaar om in een `poc-voorstel`-issue te plakken, met de labels `build` en `poc`. Dit is het bouwbare
> eerste increment dat de architectuurdocumenten beschrijven. Het bewijst met opzet de
> *compliance-spine* end-to-end op de dunst mogelijke functionaliteit.
## Probleem en strategische vraag
Kunnen wij een registerbevraging demonstreren die *structureel* gegoverneerd is — onmogelijk uit te
voeren zonder gehandhaafde grondslag en een automatische regel in het verwerkingenlog — op onze
soevereine stack?
Dit is de geloofwaardigheidstoets achter de hele Open Register-inzet (slice 1 van het charter) en
achter de FDS gap-analyse.
## Hypothese
Wij verwachten dat het doorverbinden van één registerbevraging door de volledige capability-spine —
Register Port → ACL-adapter → PDP-controle → FSC-aanroep → LDV-emissie → begrensde cache — de claim
"compliance is structureel" bewijst.
Wij weten dat wij het goed hebben als een geautomatiseerde test aantoont dat een bevraging **niet** kan
voltooien als de PDP weigert, en **altijd** een LDV-event oplevert als de PDP toestaat.
## Scope ter grootte van één blok
**Wel in scope**
| Onderdeel | Wat |
|---|---|
| Register | **NHR/KVK**, basisgegevens over onderneming en bestuurder. Gekozen boven BRP; zie de slotnotitie. |
| Use case | Geef bij een KVK-nummer de geregistreerde organisatie terug aan het domein, voor één verklaard doel. |
| Ports | De vijf ports als interface. Concrete adapters: NHR-ACL, PDP-client (OPA), FSC-client met sandbox- of test-outway, LDV-emitter (Redpanda-topic), en cache manager (PostgreSQL met TTL). |
| Policy | OPA draait met één handgeschreven voorbeeldpolicy in Gitea: één allow-regel en één deny-geval. |
| Log | Verwerkingsevent-schema v0 plus een minimale bevraagbare projectie; een tabelweergave is genoeg. |
| Tests | Tests die de twee compliance-invarianten vastleggen: deny blokkeert, allow logt. |
**Niet in scope** — even belangrijk om op te schrijven.
1. Afgewerkte interface of NL Design System-schermen, verder dan een dev-harness.
2. BRP en paden met veel persoonsgegevens. Die gaan naar slice 2, met een door de FG beoordeelde
policy.
3. UBO-data. Het regime van beperkte toegankelijkheid valt buiten deze slice.
4. De terugmelding-workflow (latere slice), DCAT-export, en Superset-dashboards.
5. Echte register-endpoints. Alleen sandbox en stubs.
## Definition of Done
- [ ] Een bevraging op KVK-nummer geeft een domein-`Organisatie` terug via de NHR-ACL-adapter, zonder
registervocabulaire in het domein (ADR-0001).
- [ ] De aanroep loopt via de FSC-client naar een sandbox-outway, en niet via een ruwe HTTP-client
(ADR-0002).
- [ ] Er vindt geen bevraging plaats tenzij de PDP allow teruggeeft voor de combinatie rol, doel en
grondslag (ADR-0003).
- [ ] Elke toegestane bevraging stuurt precies één verwerkingsevent naar Redpanda, bevraagbaar in de
projectie, zonder opgehaalde waarden (ADR-0005).
- [ ] Cache-entries dragen een TTL en een subjectsleutel; een purge-aanroep verwijdert ze (ADR-0004).
- [ ] **De tests op de compliance-invarianten slagen in CI:** (a) PDP-deny betekent geen FSC-aanroep;
(b) PDP-allow betekent precies één LDV-event; (c) te ruim gevraagde velden bereiken het domein
nooit.
- [ ] Het geheel draait lokaal uit een gedocumenteerd `compose`- of k3s-manifest met stubs, zonder
echte registertoegang.
- [ ] ADR-0001 tot en met ADR-0005 zijn vanuit de code gelinkt. Eén nieuwe ADR als er in slice 1 een
besluit ontstaat.
## Acceptatiedemo (bewijs voor de week-3-toets)
Live: een geslaagde bevraging plus de bijbehorende LDV-regel. Zet daarna de policy op deny en toon
dezelfde bevraging geweigerd, zonder registeraanroep en zonder data.
Dat contrast *is* de demo.
## Ontvangende Delivery Circle (voorlopig)
De register-reference Delivery Circle. De Handoff-ontvanger krijgt bij de kickoff een naam.
Waarschijnlijke adoptie: de capability-spine wordt het herbruikbare substraat voor de
register-reference-applicatie.
## Upstream-kandidaten
| Project | Wat wij kunnen bijdragen |
|---|---|
| fsc-nlx | Ergonomie van de sandbox en testomgeving, plus documentatie |
| OPA | Policy-patronen voor het modelleren van Nederlandse grondslagen |
| OpenMetadata | Later een DCAT-AP-NL exporter; dit verbindt het OpenMetadata-project |
## AVG- en soevereiniteitsoverwegingen
Alleen NHR-basisgegevens, over onderneming en bestuurder, en in slice 1 **gestubd**. Er worden geen
echte persoonsgegevens verwerkt.
Een FG-review is een voorwaarde voor slice 2, met echte data en BRP. Alle componenten draaien
zelfgehost op De Werf; OPA-policies en BPMN staan in Gitea.
## Slotnotitie: waarom NHR vóór BRP voor het skeleton
Beide registers bevatten persoonsgegevens, dus geen van beide is "gratis". NHR-basisgegevens over
onderneming en bestuurder zijn echter minder gevoelig dan BRP-gegevens over inwoners, en er is een
duidelijker verhaal rond een publieke sandbox.
Zo bewijst slice 1 het *mechanisme*, voordat slice 2 BRP oppakt onder een door de FG beoordeelde
policy. UBO-data blijft buiten scope tot het toegangsregime is gemodelleerd.
+398 -2
View File
@@ -5,6 +5,373 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-26/#162 — the werkbak refreshes itself (ADR-0032)
**Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open
**appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a
background refresh swaps the rows in without flashing the loading state, and a transient failure no
longer strands the view on its error message until someone reloads.
```bash
# 1. Two windows. Left: the behandel werkbak, already open and idle.
python3 infra/keycloak/check_realms.py otp # a code, valid right now
open http://localhost:8142 # merel-behandelaar / test123 + that code
#
# 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen).
open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF
#
# 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was
# never reloaded and never left the werkbak.
#
# 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the
# absence of the reload IS the assertion.
make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN"
#
# 5. Component level (background refresh, failure recovery, teardown):
pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards)
```
**The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the
page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`.
**Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF
for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path.
---
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process,
Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an
approval updates the existing object instead of creating a second one.
```bash
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
make up
#
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
#
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
make verify-acl # → "Writes a register record and updates it in place on a second write"
#
# 4. See it for yourself — every register record currently in Objecten:
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
-H 'Accept-Crs: EPSG:4326' \
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
```
Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything
else (ADR-0027), so no personal data can reach the world-readable register even by mistake.
**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW
`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the
register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time
is pinned in config (ADR-0028, same reasoning as ADR-0021).
**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten
is S-19b (#150).
---
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the
Objecttypen API at startup. The schema is public-safe by construction — `id`, `status`, `reference`
only, mirroring the BFF's `OpenbaarEntry` (no `bsn`/`naam`), `dataClassification: open`. This is the
schema S-19 writes register records against on approval. A `registerrecord-init` one-shot creates it
over the API once Objecttypen is healthy (the Objecttypen `setup_configuration` has no objecttype
step), idempotently.
```bash
make up
# The RegisterRecord objecttype exists with a published version:
curl -s -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
"http://localhost:8020/api/v2/objecttypes" | python3 -c \
'import sys,json; o=[x for x in json.load(sys.stdin)["results"] if x["name"]=="RegisterRecord"][0]; print(o["name"], o["dataClassification"], o["versions"])'
# → RegisterRecord open ['http://.../objecttypes/<uuid>/versions/1']
#
# Automated (a CI verify-stack step): asserts the objecttype exists, has a published version, and
# that version's schema carries id/status/reference.
make verify-registerrecord # → OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']
```
**The path:** `infra/objecttypen-registerrecord/registerrecord.schema.json` (the reviewed public-safe
contract) + `register.py` are streamed into an external config volume by `infra/seed-config.sh
registerrecord` (bind-mounted locally); the `registerrecord-init` one-shot POSTs the objecttype + a
published version. Re-running is a no-op. S-19 (#20) writes records against this schema in Objecten.
---
## S-18b — Objecten API up in compose, wired to Objecttypen (#140)
**Outcome:** the upstream Maykin **Objecten API** runs in the stack — own **PostGIS** DB + redis,
config seeded like the other CG modules (`objecten-init` runs `setup_configuration` from the
`rr-objecten-config` volume: migrate + provision a dev **static API token** + register the
**Objecttypen API** (S-18a) as a trusted service), a health-checked `objecten` web on host `:8021`.
An object can now reference its objecttype; the ACL writes register records here on approval (S-19).
```bash
make up
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8021/api/v2/objects # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 1234567890abcdef1234567890abcdef12345678" \
http://localhost:8021/api/v2/objects # 200
#
# 2. It trusts Objecttypen — the seeded zgw_consumers service points at the Objecttypen API:
docker exec infra-objecten-1 python src/manage.py shell -c \
"from zgw_consumers.models import Service; print(*[(s.slug,s.api_root) for s in Service.objects.all()])"
# → ('objecttypen', 'http://objecttypen:8000/api/v2/')
#
# 3. Automated (a CI verify-stack step): asserts unauth 401 + token 200, against the running stack.
make verify-objecten # → OK — no-auth 401, token 200
```
**The path:** verbatim upstream image (`maykinmedia/objects-api`, pinned 3.4.0) + the same seed
pattern as S-18a — `infra/seed-config.sh objecten` streams `data.yaml` into an external config
volume, `objecten-init` (RUN_SETUP_CONFIG) applies it. Its `zgw_consumers` step registers Objecttypen
(`api_type: orc`, api-key auth with the S-18a dev token). The RegisterRecord objecttype (S-18c) and
the ACL write path (S-19) build on this.
---
## S-18a — Objecttypen API up in compose (#139)
**Outcome:** the upstream Maykin **Objecttypen API** runs in the stack — own Postgres + redis, config
seeded like the other CG modules (`objecttypen-init` runs `setup_configuration` from the
`rr-objecttypen-config` volume: migrate + provision a dev **static API token**), a health-checked
`objecttypen` web service on host `:8020`. This is the objecttype catalogue the register record
(S-18b/S-18c, S-19) will use.
```bash
make up
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8020/api/v2/objecttypes # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
http://localhost:8020/api/v2/objecttypes # 200
#
# 2. Automated (a CI verify-stack step): asserts both, against the running stack.
make verify-objecttypen # → OK — no-auth 401, token 200
```
**The path:** verbatim upstream image (`maykinmedia/objecttypes-api`, pinned) + the same seed pattern
as OpenZaak/NRC — `infra/seed-config.sh objecttypen` streams `data.yaml` into an external config
volume, `objecttypen-init` (RUN_SETUP_CONFIG) applies it. Objecten (S-18b) and the RegisterRecord
objecttype (S-18c) build on this.
---
## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026)
**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie,
verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak
is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill`
(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per
zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the
zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart.
```bash
make up
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
# → "Default-fill" tab → change a value → Opslaan.
open http://localhost:8143/default-fill
#
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
# beheerder role (BFF unit):
# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update
# Bff.Tests → BeheerDefaultFillEndpointTests
```
---
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE
zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm +
`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL
directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next.
```bash
make up
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
# → the catalogus lists the published zaaktypen.
open http://localhost:8143
#
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus"
#
# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests):
# Bff.Tests → BeheerEndpointTests.
```
**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm
(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its
realm-role lifting, requiring `beheerder` rather than `behandelaar`.
---
## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023)
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
— ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus
scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path —
golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency**
(p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load.
```bash
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the
# golden-signal metric from every service.
make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...]
# 2. By hand: drive the stack, generate some load, then open the dashboard.
make up
for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api
open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals"
open http://localhost:9090/targets # Prometheus → every service target UP
```
**The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation +
AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes
`<service>:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the
dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over
OTLP — Prometheus pulls, so there is no collector hop (ADR-0023).
---
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
```bash
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
# spanning multiple services.
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
# 2. By hand: drive the stack, then explore traces in Grafana.
make up
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
```
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
datasource.
---
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
(`infra/observability/`) — no collector, no config-volume seeding.
```bash
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
# datasources reachable through Grafana). This is a CI verify-stack step.
make up
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
# 2. Or just the backplane, no full stack needed (no external egress):
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
open http://localhost:9090 # Prometheus
```
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics`**Prometheus** scrapes;
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
---
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
log line.
```bash
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
```
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
`HerregistratieReminderSweep``IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
---
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
empty, and the openbaar register showed nothing.)
```bash
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
make local
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
# the werkbak, and the reference appears in the openbaar register:
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
# test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at
# http://localhost:8141.
```
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
> single-label host on zaak-create (ADR-0020 + ADR-0021).
---
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
```bash
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
make verify-local # local stack — still green, now with no zaaktype-URL injection
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
```
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
---
## S-08d — Walking skeleton complete: browser → submit, end-to-end
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
@@ -22,7 +389,7 @@ make verify-e2e # → login as jan-burger → submit → "ontvangen" co
open http://localhost:8140
```
> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no
> The portal is served same-origin with the BFF (Caddy proxies `/self-service` + `/openbaar`), so no
> CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`.
---
@@ -259,7 +626,7 @@ or **afwijzen** — which also completes the Beoordelen task so the process adva
```text
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
# http://localhost:8142/ → merel-behandelaar / test123
# http://localhost:8142/ → merel-behandelaar / test123 + OTP
#
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
@@ -482,3 +849,32 @@ make verify-domain # → "the timed-out registration's zaak was cancelled to
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
job for redelivery rather than diverging the two (ADR-0019).
---
## S-15c — MFA on the medewerker realm (#132, ADR-0031)
**Outcome:** staff logins (behandel + beheer portals) need a **second factor**. The medewerker realm
seeds every medewerker with a TOTP credential, so Keycloak's conditional-OTP step challenges them in
both the browser flow and the direct grant; a password alone no longer yields a token. `CONFIGURE_TOTP`
is a default required action, so a medewerker added later must enrol first. Citizen realms (digid,
eherkenning, eidas) are unchanged — they mock brokers that carry their own assurance.
```bash
# 1. Manual: log in to the behandel portal. After username + password Keycloak asks for a code.
python3 infra/keycloak/check_realms.py otp # a valid code, right now
open http://localhost:8142 # merel-behandelaar / test123 + that code
#
# 2. Automated: the realm smoke check asserts the password alone is REFUSED, then that
# password + TOTP succeeds and still carries the behandelaar role:
make keycloak-smoke # → "medewerker merel-behandelaar password-only login refused [OK]"
#
# 3. End-to-end: every staff login in the e2e goes through the OTP prompt (loginMedewerker):
make verify-e2e # → registration.spec (behandelaar approves), catalogus.spec, default-fill.spec
```
**The path:** the seeded `otp` credential in `infra/keycloak/realms/medewerker-realm.json` activates
Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture secret is shared and
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
(ADR-0031).
+8 -6
View File
@@ -77,11 +77,13 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
## Serving + e2e (S-08d, #68)
- **Served by nginx, same-origin as the BFF.** The compose `self-service` image serves the built app
- **Served by Caddy, same-origin as the BFF.** The compose `self-service` image serves the built app
and **reverse-proxies** `/self-service/*` + `/openbaar/*` to the `bff` service. Because the
api-client uses **relative URLs**, the browser calls the app's own origin → nginx forwards to the
BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves
the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up.
api-client uses **relative URLs**, the browser calls the app's own origin → Caddy forwards to the
BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. Caddy dials
the BFF per request through the system resolver, so it starts before the BFF is up, picks up its
restarts, and resolves the bare `bff` name on every engine — compose, podman and Kubernetes
(ADR-0034; the `Caddyfile` sits next to each app's `Dockerfile`).
- **Runtime config.** The app fetches `/config.json` before bootstrap (`main.ts`); `appConfig` is a
factory. The dev default (`public/config.json`) points at `localhost:8180`; the Docker image bakes
the compose value (`keycloak:8080`). One build, per-environment OIDC authority.
@@ -110,7 +112,7 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
`angular-auth-oidc-client`, no interceptor, and no `config.json``main.ts` bootstraps `appConfig`
directly with just `provideHttpClient` + `provideRouter`. This is the deliberate contrast to
self-service and keeps the app trivially cacheable/CDN-able.
- **Same-origin via nginx, like self-service.** The compose `openbaar` image serves the built app and
- **Same-origin via Caddy, like self-service.** The compose `openbaar` image serves the built app and
reverse-proxies `/openbaar` to the BFF; the api-client's relative calls stay same-origin (no CORS).
Served on `:8141`, health-checked over IPv4 (`127.0.0.1`), no Keycloak dependency.
- **Public-safe by construction.** The portal only ever sees the BFF's `OpenbaarProjection.PublicView`
@@ -138,7 +140,7 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
403 for a non-behandelaar) rather than swallowing it.
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
- **Same-origin via Caddy, like the other portals.** The compose `behandel` image serves the built
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
+3
View File
@@ -9,6 +9,9 @@ should teach.
- **[Product Requirements](PRD.md)** — what we're building and why.
- **[ADR-0001: Loose coupling](architecture/adr-0001-loose-coupling.md)** — the
non-negotiable integration stance; the template for future ADRs.
- **[FDS architecture](architecture/fds/README.md)** — participating in the Federatief
Datastelsel as an afnemer: FDS ADR-0001…0006, the L3 component view, the slice-1 proposal.
In Dutch; the strategic framing lives in `Respellion/innovation-lab`.
- **[Working in Gitea](gitea-workflow.md)** — issues, milestones, branches, PRs.
- **[CI runbook](runbooks/ci.md)** — the pipeline and the `make ci` local gate.
+93
View File
@@ -196,3 +196,96 @@ service name; the notif verify harness also registers the sink callback by IP.
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
**401** without the configured `Authorization`. The verify sink
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
---
## 7. A job with `if: ${{ !cancelled() }}` (or `always()`) + `needs` sticks in "waiting"
**Symptom** — after upgrading to **Gitea 1.27** + **act_runner 2.0.0**, one job never
starts: the run sits in state `waiting` forever, the job has **no logs** (never
dispatched to a runner), and the other jobs finish normally. `main` stays pending/red.
Seen on the `verify-stack` job (#134).
**Why** — Gitea 1.27 reworked cancellation/aggregation: a job gated by a
**status-function `if`** (`always()` / `cancelled()` / `!cancelled()`) on top of
`needs` now routes through a new transitional **`Cancelling`** job state plus a
server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On the
1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never
offered to a runner and never leaves `waiting`. Jobs with no `if`/`needs` are
unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)
**Fix** — don't gate a `needs` job with a status-function `if`. Use the default
`if: success()` (i.e. omit the `if`). If you need "run even when an upstream job
fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
workflow fix to produce a fresh run.
---
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
(no artifact download). We use it for per-run reports (#136): mutation scores
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
**Requirements / conventions:**
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
(uploads them). Older pairings silently skip the upload.
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
- Use `if: always()` (step-level) on summary steps so they render even when the thing
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
status-function `if` of §7.
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
(capturing the test exit code first) so the summary step can read it.
---
## 9. `if: always()` does not survive the job being killed — bound the work itself
`if: always()` makes a step run when an *earlier step failed*. It does **not** help when
the job as a whole is stopped: the run's remaining steps are simply never dispatched.
That is how #161 lost its diagnosis. `verify-stack` entered `make verify-e2e` at 09:48:17
and the job ended at 10:14:54 — 26½ minutes later, mid-suite. Every step after the e2e
shows a **0-second `failure`** stamped at that same instant:
```
14 failure 09:48:17 -> 10:14:54 Self-service e2e (Playwright, login → submit → success)
15 failure 10:14:54 -> 10:14:54 verify-stack check summary ← if: always()
16 failure 10:14:54 -> 10:14:54 e2e spec summary ← if: always()
17 failure 10:14:54 -> 10:14:54 Dump container logs on failure ← if: failure()
18 failure 10:14:54 -> 10:14:54 Tear down ← if: always()
```
So the per-spec summary, the container-log dump and the teardown never ran, and the job
log — which also loses whatever the killed process had buffered — ended at a single `✘`
line. A job that dies takes its own post-mortem with it.
**Read the step timings, not just the log.** `GET /api/v1/repos/{owner}/{repo}/actions/jobs/{id}`
returns every step with `started_at`/`completed_at`; a row of identical zero-length
steps at the end means *killed*, not *silent*. (Job ids come from
`…/actions/runs/{run}/jobs`, and that route returns only the **latest attempt** — a
re-run hides the failed one, so keep the failing job id from the original report. Logs:
`…/actions/jobs/{id}/logs`, see also `gitea-ci-logs`.)
**Conventions that follow:**
- **Bound long-running work inside the tool**, where it can still report. Playwright's
`globalTimeout` (`tests/e2e/playwright.config.ts`) ends the run, writes the JSON
report and exits, so the summary and log-dump steps still get their turn. A
`timeout-minutes` on the job would reproduce the very failure above.
- **Never let an auto-waiting action be the timeout.** Playwright actions (`fill`,
`click`) inherit the *test* timeout, not `expect.timeout`, so a missing element costs
the full 90 s and reports `locator.fill: Test timeout …` — the symptom. Assert the
element visible first with its own budget and a message (`tests/e2e/keycloak-login.ts`).
- Remember `concurrency.cancel-in-progress: true` in `ci.yaml`: a new push to the same
ref, or a re-run, kills the in-flight run the same way. Check `run_attempt` before
concluding a job hung.
+36
View File
@@ -23,6 +23,9 @@ login per realm and asserts the identifying claim:
| eidas | pierre-dupont | `eidas_id` |
| medewerker | merel-behandelaar | role `behandelaar` |
The medewerker row also asserts that the password **alone** is refused — that realm
enforces MFA (below).
All test users / credentials are in [../synthetic-data.md](../synthetic-data.md).
## Notes
@@ -35,3 +38,36 @@ All test users / credentials are in [../synthetic-data.md](../synthetic-data.md)
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
claim); `medewerker` roles come through `realm_access.roles`.
## MFA on the medewerker realm (S-15c)
Staff logins (behandel + beheer portals) need a second factor; citizen/company realms
(digid, eherkenning, eidas) do not. Two halves in `medewerker-realm.json`:
- Every seeded medewerker carries a **TOTP credential** with the fixture secret
`BIGMEDEWERKEROTPSEED`, so Keycloak's built-in *conditional OTP* step fires on every
login — browser flow (an `#otp` prompt after the password) and direct grant (a `totp`
form field) alike.
- `CONFIGURE_TOTP` is a **default required action**, so any medewerker added later must
enrol an authenticator before the first login.
See [../architecture/adr-0031-mfa-on-the-medewerker-realm.md](../architecture/adr-0031-mfa-on-the-medewerker-realm.md).
### Getting a code
```bash
python3 infra/keycloak/check_realms.py otp # prints a valid 6-digit code right now
```
Or enrol a phone once: the secret in base32 is `IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`
(`otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`). The e2e computes its
own code in `tests/e2e/medewerker-login.ts`.
**A code is single-use.** Keycloak's `otpPolicyCodeReusable` defaults to false, so it refuses a
code it has already accepted — a second login as the same medewerker inside the same 30-second
window fails with `invalid_grant` / *Invalid user credentials*, even though the code is current.
Nothing to fix in the realm: wait for the next window, or spend the following counter, which is
what `nextUnusedCounter` in `tests/e2e/medewerker-login.ts` does for back-to-back specs.
**Fixture only.** A shared, committed secret is a demo convenience, never a production
posture — see the ADR's consequences.
+370
View File
@@ -0,0 +1,370 @@
# Deploying the stack to a single-node Talos cluster
The Helm chart in `infra/helm/big-reference` is a port of `infra/docker-compose.yml`
(ADR-0033). This runbook is the walkthrough that was actually used to bring the stack up
on a Talos VM under virt-manager on a laptop, including the parts that bite.
Compose remains the CI-canonical stack — `make verify`, the acceptance lane and the
Playwright e2e all still drive it. Kubernetes is a second deployment target.
## 0. What you need
On the laptop, four static binaries, all installable to `~/.local/bin` without root:
```bash
curl -sSLo ~/.local/bin/talosctl https://github.com/siderolabs/talos/releases/download/v1.14.0/talosctl-linux-amd64
curl -sSLo ~/.local/bin/kubectl https://dl.k8s.io/release/v1.37.0/bin/linux/amd64/kubectl
curl -sSL https://get.helm.sh/helm-v3.16.4-linux-amd64.tar.gz | tar xz -O linux-amd64/helm > ~/.local/bin/helm
curl -sSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xz -O crane > ~/.local/bin/crane
chmod +x ~/.local/bin/{talosctl,kubectl,helm,crane}
```
Match `talosctl` to the Talos ISO you booted (`talosctl version --insecure -n <ip>` reports
the server's tag). `crane` is what pushes images to a plain-HTTP registry without a
root-level Docker daemon change — see §2.
**VM sizing.** 6 vCPU / 10 GB RAM / 27 GB disk runs the whole stack with room to spare
(measured: ~4.4 GB used, 5.4 GB available with all 29 pods up). 4 GB is not enough. The
chart sets no resource requests or limits on purpose — on a single node the VM's RAM is the
only budget there is. Resize a stopped VM with:
```bash
virsh -c qemu:///system destroy talos # it's in maintenance mode; nothing is lost
virsh -c qemu:///system setmaxmem talos 10G --config
virsh -c qemu:///system setmem talos 10G --config
virsh -c qemu:///system setvcpus talos 6 --config --maximum
virsh -c qemu:///system setvcpus talos 6 --config
```
Two addresses matter throughout:
| Name | Meaning | Example |
|---|---|---|
| `TALOS_HOST` | the VM's IP — used by the browser, `talosctl` and `kubectl` | `192.168.122.33` |
| `K8S_REGISTRY` | `TALOS_HOST:30500` — the in-cluster registry (§2) | `192.168.122.33:30500` |
Find the VM's address with `virsh -c qemu:///system net-dhcp-leases default`.
## 1. Install Talos onto the VM
### The virt-manager trap
virt-manager treats the install ISO as one-shot: on the VM's **first shutdown** it ejects
the CD and rewrites the boot order to `hd`. A Talos VM booted from `metal-amd64.iso` runs
entirely in RAM, so the disk is still empty — the next start lands on
`Boot failed: not a bootable disk`. Put the ISO back before installing:
```bash
virsh -c qemu:///system change-media talos sda /path/to/metal-amd64.iso --config --insert
virt-xml -c qemu:///system talos --edit --boot cdrom,hd
virsh -c qemu:///system start talos
```
Wait for the maintenance-mode API, then confirm the install disk's device name — on virtio
it is `/dev/vda`, and Talos's default selector expects `/dev/sda`:
```bash
talosctl get disks --insecure -n <TALOS_HOST> -e <TALOS_HOST>
```
### Generate the machine config
Talos 1.14 moved several v1alpha1 fields into their own config documents. In particular
`machine.install` is now `UnattendedInstallConfig`, and patching the old field is rejected
with *"UnattendedInstallConfig config is incompatible with v1alpha1 config"*. Write
`patch.yaml` as a multi-document patch:
```yaml
machine:
certSANs:
- 192.168.122.33
registries:
mirrors:
# The in-cluster registry (§2) speaks plain HTTP.
"192.168.122.33:30500":
endpoints:
- http://192.168.122.33:30500
---
apiVersion: v1alpha1
kind: UnattendedInstallConfig
provisioning:
diskSelector:
match: disk.dev_path == "/dev/vda"
```
```bash
talosctl gen config big https://<TALOS_HOST>:6443 --output-dir ~/.talos/big --config-patch @patch.yaml
talosctl apply-config --insecure -n <TALOS_HOST> -e <TALOS_HOST> --file ~/.talos/big/controlplane.yaml
```
Talos installs to the disk and **kexecs straight into the installed system**, so the CD
boot order doesn't get in the way here. Then point the client at the node and bootstrap:
```bash
talosctl config merge ~/.talos/big/talosconfig
talosctl config endpoint <TALOS_HOST>
talosctl config node <TALOS_HOST>
talosctl bootstrap # wait for `talosctl version` to answer first
talosctl kubeconfig -f ~/.kube/config
```
A single-node cluster must run workloads on the control plane, or CoreDNS never schedules:
```bash
kubectl taint node --all node-role.kubernetes.io/control-plane-
```
Finally, make a VM restart boot the installed system rather than the ISO (takes effect at
the next full power cycle):
```bash
virsh -c qemu:///system change-media talos sda --eject --config
virt-xml -c qemu:///system talos --edit --boot hd
```
## 2. A registry the node can pull from
Talos has no Docker daemon and no way to side-load an image, so this repo's images have to
come from a registry. The registry runs **inside the cluster**, published on NodePort
30500 (`infra/helm/registry.yaml`):
```bash
make k8s-registry
```
Why in-cluster rather than on the laptop: a laptop-side registry needs an inbound port
opened on firewalld's `libvirt` zone (`sudo firewall-cmd --zone=libvirt --add-port=5000/tcp`),
which needs root. Pushing from the laptop *to* the node is outbound and always allowed, and
the node pulls from its own NodePort. If you do open that port, put a registry on the
laptop instead and point `K8S_REGISTRY` at `<laptop-ip>:5000` — the mirror patch in §1 has
an entry ready for it.
Its storage is `emptyDir`, so if the registry pod is ever replaced, re-run `make k8s-images`.
## 3. Build and push the images
```bash
make k8s-images K8S_REGISTRY=<TALOS_HOST>:30500
```
This builds the nine images with `docker compose build` — same contexts and Dockerfiles as
compose, no second build definition — then `docker save | crane push --insecure` each one.
`docker push` is not used: the registry speaks plain HTTP, which the Docker daemon refuses
without a root-level `insecure-registries` entry, while crane just takes `--insecure`.
## 4. Deploy
```bash
make k8s-up TALOS_HOST=<TALOS_HOST> K8S_REGISTRY=<TALOS_HOST>:30500
```
That does two things:
1. `make k8s-seed` — creates the ConfigMaps the chart mounts, from the config files that
already live in this repo (`infra/helm/seed-configmaps.sh`): the four
`setup_configuration/data.yaml` files, the Keycloak realm exports, the BPMN + DMN, and
the two bootstrap scripts. Re-run it after editing any of them.
2. `helm upgrade --install` of the chart into namespace `big`.
First bring-up takes a few minutes: the four Django services migrate their databases and
apply their `setup_configuration`, Flowable creates its schema, and the bootstrap Jobs
deploy the BPMN/DMN, seed the zaaktype and register the NRC abonnement.
```bash
kubectl -n big get pods -w
kubectl -n big get jobs # all four must reach COMPLETIONS 1/1
```
The Jobs are the stack's wiring; if one is not complete, the flow is broken somewhere
specific:
| Job | What breaks without it |
|---|---|
| `flowable-init` | no `registratie` process, no diploma DMN |
| `registerrecord-init` | the register has no RegisterRecord objecttype, so writes are refused |
| `seed-zaaktype` | the ACL can't resolve `BIG-REGISTRATIE`, so no zaak is created |
| `nrc-subscribe` | register writes never reach the projection — the public register stays empty |
## 5. Use it
### The portals must be reached over `localhost`
The portals' OIDC flow uses PKCE, which needs `crypto.subtle` — and browsers only expose
that in a **secure context**: HTTPS, or an origin on `localhost`/`127.0.0.1`. A NodePort on
the VM's IP is neither, so `http://<TALOS_HOST>:30140` fails before it can even build the
authorize URL:
```
ERROR TypeError: Cannot read properties of undefined (reading 'digest')
at t.calcHash → t.generateCodeChallenge → t.createUrlCodeFlowAuthorize
```
So deploy with `TALOS_HOST=localhost` — which pins Keycloak's issuer and the portals'
`config.json` authority to `http://localhost:30180` — and forward the browser-facing
services to those same ports:
```bash
make k8s-up TALOS_HOST=localhost K8S_REGISTRY=<TALOS_HOST>:30500
make k8s-portals # stays in the foreground; Ctrl-C stops all five forwards
```
| URL (needs `make k8s-portals`) | What |
|---|---|
| `http://localhost:30140` | self-service portal (DigiD) |
| `http://localhost:30141` | openbaar register (anonymous) |
| `http://localhost:30142` | behandel portal (medewerker) |
| `http://localhost:30143` | beheer portal (medewerker) |
| `http://localhost:30180` | Keycloak (admin/admin) |
The port numbers are deliberately the NodePort numbers: Keycloak's issuer is one fixed
string, so the port the browser uses has to match the one baked into `config.json`.
This is the same mechanism `infra/host-browser.yml` uses for the compose stack (which pins
`localhost:8180`); only the addresses differ.
### The admin UIs work straight off the NodePorts
These are server-rendered and need no secure context, so they are reachable at the VM's
address with no forwarding:
| URL | What |
|---|---|
| `http://<TALOS_HOST>:30000` | OpenZaak admin (admin/admin) |
| `http://<TALOS_HOST>:30001` | Open Notificaties admin (admin/admin) |
| `http://<TALOS_HOST>:30020` / `:30021` | Objecttypen / Objecten admin |
| `http://<TALOS_HOST>:30080` | BFF (`/health`) |
| `http://<TALOS_HOST>:30090` | Flowable REST (rest-admin/test) |
### Credentials
Log in with the test users from `docs/synthetic-data.md` (all password `test123`, e.g.
`jan-burger` for self-service, `merel-behandelaar` for behandel). The `medewerker` realm
enforces MFA (ADR-0031) — print a current code with
`python3 infra/keycloak/check_realms.py otp`. Walk the flow in `docs/demo-script.md`.
`TALOS_HOST` is not cosmetic: it pins Keycloak's issuer (`KC_HOSTNAME`) and the portals'
OIDC authority to the same string, which is what makes a browser token pass the BFF's
validation (ADR-0010). Change it and you must re-run `make k8s-up` — the chart rolls the
portals for you, because their `config.json` is a subPath mount and would otherwise keep
serving the old authority.
### Smoke-test the whole chain without a browser
With the forwards running:
```bash
TOK=$(curl -s -X POST http://localhost:30180/realms/digid/protocol/openid-connect/token \
-d grant_type=password -d client_id=big-portal \
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token)
# through the portal's Caddy, so this also proves the BFF reverse proxy
curl -s -X POST http://localhost:30140/self-service/registrations \
-H "Authorization: Bearer $TOK" -H 'Content-Length: 0'
# → {"registrationId":"…","status":"Ingediend"}
curl -s http://localhost:30141/openbaar/register
# → [{"id":"…","status":"INGEDIEND","reference":"<the registrationId>"}]
```
The second call proves the whole Common Ground path: portal → BFF → domain → Flowable →
ACL → OpenZaak + Objecten → NRC → event-subscriber → projection → openbaar register.
## 6. Keeping the databases (recommended if you iterate on the chart)
By default every database is an `emptyDir`: no CSI driver needed, and the data lives as
long as the pod. Note what that means in practice — **any** change to a database pod's
template (an image policy, an env value, a probe) recreates the pod and wipes it. The stack
then needs its bootstrap re-run:
```bash
make k8s-reseed TALOS_HOST=... K8S_REGISTRY=...
```
which re-runs the four Jobs *and* restarts `event-subscriber` + `projection-api`, because
those two create the projection schema on start and otherwise keep writing to a
schema-less database (`relation "processed_notifications" does not exist`). For persistence, install Rancher's local-path-provisioner — on Talos it
must write under `/var` and its namespace needs the privileged Pod Security label:
```yaml
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- github.com/rancher/local-path-provisioner/deploy?ref=v0.0.31
patches:
- patch: |-
kind: ConfigMap
apiVersion: v1
metadata:
name: local-path-config
namespace: local-path-storage
data:
config.json: |-
{ "nodePathMap":[ { "node":"DEFAULT_PATH_FOR_NON_LISTED_NODES", "paths":["/var/local-path-provisioner"] } ] }
- patch: |-
apiVersion: v1
kind: Namespace
metadata:
name: local-path-storage
labels:
pod-security.kubernetes.io/enforce: privileged
```
```bash
kubectl apply -k .
make k8s-up TALOS_HOST=... K8S_REGISTRY=... K8S_SET='--set persistence.storageClass=local-path'
```
The PVCs carry `helm.sh/resource-policy: keep`, so `make k8s-down` leaves the data behind;
`make k8s-purge` drops the namespace and with it the volumes.
## 7. Day-to-day
```bash
make k8s-lint # render + schema-check the chart, no cluster needed
make k8s-portals # forward the portals + Keycloak to localhost (browser access)
make k8s-images K8S_REGISTRY=... # after changing a service or a portal
make k8s-up TALOS_HOST=... K8S_REGISTRY=...
make k8s-seed # after editing a data.yaml, a realm export, or the BPMN
make k8s-reseed TALOS_HOST=... K8S_REGISTRY=... # re-run the bootstrap Jobs + reset the projection schema
make k8s-down # uninstall, keep the database PVCs
make k8s-purge # uninstall and drop the namespace
```
This repo's images are pulled with `imagePullPolicy: Always` (the `dev` tag is mutable), so
`kubectl -n big rollout restart deploy/<name>` after `make k8s-images` picks up a rebuild.
Upstream images stay `IfNotPresent`: their tags are pinned, and keeping them out of the pod
template avoids needless churn — a changed template makes a Job unpatchable.
`k8s-reseed` is also the path for *changing* a Job in the chart: a Job's pod template is
immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`.
## 8. When it doesn't work
| Symptom | Cause |
|---|---|
| `Boot failed: not a bootable disk` | virt-manager ejected the install ISO on first shutdown — see §1 |
| The VM comes back in maintenance mode after a restart | the ISO is still attached and boots first; eject it and set `--boot hd` (§1) |
| `apply-config` rejects the patch with *"incompatible with v1alpha1"* | Talos ≥1.14 owns that field in its own config document — patch the document, not `machine.*` (§1) |
| CoreDNS `Pending` forever | the control-plane taint is still on the only node (§1) |
| `ImagePullBackOff``pull QPS exceeded` | transient: the kubelet rate-limits pulls when ~30 pods start at once. It recovers on retry |
| `ImagePullBackOff` on a `register-referentie/*` image | the registry mirror patch is missing: `talosctl get registriesconfig` |
| Pod stuck in `ContainerCreating`, event names a ConfigMap | `make k8s-seed` |
| `seed-zaaktype` retrying | publishing a zaaktype validates the resultaattype against `selectielijst.openzaak.nl`, so this one Job needs outbound internet from the VM (ADR-0006) |
| `TypeError: Cannot read properties of undefined (reading 'digest')` on a portal | not a secure context: `crypto.subtle` is absent on `http://<ip>`. Use `localhost` + `make k8s-portals` (§5) |
| Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value |
| A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness |
| Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` |
| `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` |
| Pods `Evicted` / `OOMKilled` | the VM is too small (§0) |
| A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` |
## What is not ported
- **Observability** (Tempo, Prometheus, Grafana) is defined but disabled — those are built
images too, so switching them on means pushing them as well:
`K8S_SET='--set workloads.tempo.enabled=true --set workloads.prometheus.enabled=true --set workloads.grafana.enabled=true'`.
The .NET services still export OTLP; the exporter fails harmlessly when Tempo is absent.
- **The verify/e2e lanes.** `make verify*` and the Playwright e2e drive compose, not the
chart. The Kubernetes path is verified with §5's smoke test.
- **Ingress, TLS, and resource requests.** See the ponytail ceiling in ADR-0033.
+9
View File
@@ -14,10 +14,16 @@ All test users share the password **`test123`**.
| Realm | Mimics | User | Identifying claim |
|---|---|---|---|
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) |
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` |
| `medewerker` | Internal staff | `bram-beheerder` | role `beheerder` |
`medewerker` users additionally need a **second factor**: that realm enforces MFA (S-15c,
ADR-0031). All three share the fixture TOTP secret `BIGMEDEWERKEROTPSEED`; print a current
code with `python3 infra/keycloak/check_realms.py otp`.
The identifying claims are injected via OIDC protocol mappers on `big-portal`
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
@@ -31,5 +37,8 @@ curl -s -X POST \
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token
```
For a `medewerker` user, add `-d totp=$(python3 infra/keycloak/check_realms.py otp)`
without it the grant is refused with `invalid_grant`.
Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm
automatically.
+279 -11
View File
@@ -1,7 +1,12 @@
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# macOS, or rootless Podman on Linux).
#
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
# completes the whole flow with no manual steps. `make verify-local` asserts it.
#
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
# docker compose -f infra/docker-compose.local.yml down --volumes
@@ -51,6 +56,10 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — idle workers pressure the runner; the
# -init/-celery containers share this anchor and ignore it (they don't run uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -133,6 +142,9 @@ services:
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -257,38 +269,88 @@ services:
restart: "no"
volumes:
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
command:
- sh
- -c
- |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
fi
depends_on:
flowable-rest:
condition: service_started
networks: [cg]
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
local-seed:
image: docker.io/library/python:3-slim
restart: "no"
volumes:
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
- seed-env:/out
command: ["sh", "/work/seed-zaaktype.sh"]
depends_on:
openzaak:
condition: service_healthy
networks: [cg]
# ── ACL ──────────────────────────────────────────────────────────────────
acl:
build:
context: ../services/acl
dockerfile: Dockerfile
image: register-referentie/acl:dev
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
environment:
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
Acl__Objecten__BaseUrl: http://objecten.local:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
volumes:
- seed-env:/seed:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
@@ -298,6 +360,8 @@ services:
depends_on:
openzaak:
condition: service_healthy
local-seed:
condition: service_completed_successfully
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
@@ -400,6 +464,31 @@ services:
condition: service_healthy
networks: [cg]
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
# for the event-subscriber without creating a cycle with the ACL bootstrap.
nrc-subscribe:
image: docker.io/library/python:3-slim
restart: "no"
volumes:
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
environment:
NRC_BASE: http://nrc-web:8000
SINK_HOST: event-subscriber
SINK_PORT: "8080"
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
command: ["python", "/work/register-abonnement.py"]
depends_on:
nrc-web:
condition: service_healthy
event-subscriber:
condition: service_started
networks: [cg]
projection-api:
build:
context: ..
@@ -421,7 +510,7 @@ services:
networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# Caddy serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
@@ -433,7 +522,7 @@ services:
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
@@ -473,7 +562,7 @@ services:
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
@@ -487,11 +576,190 @@ services:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — bind-mounted config (local variant) ──────────
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
volumes:
- ./objecttypen/setup_configuration:/app/setup_configuration:ro,z
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env-local
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot (local variant) ─
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- ./objecttypen-registerrecord:/config:ro,z
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — bind-mounted config (local variant) ─────────────
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
CELERY_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
# off until all four pieces existed.
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
volumes:
- ./objecten/setup_configuration:/app/setup_configuration:ro,z
depends_on:
objecten-db:
condition: service_healthy
objecten-redis:
condition: service_started
objecttypen:
condition: service_healthy
networks: [cg]
objecten:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env-local
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks:
cg:
# Objecten reflects the *request* Host into the `url` it returns, and
# notifications_api_common publishes that url as the notification's hoofdObject /
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
# Reads are unaffected and still use the plain service name.
aliases:
- objecten.local
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
# notifications_api_common only schedules the send on transaction commit; without a worker the
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
objecten-celery:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env-local
command: /celery_worker.sh
depends_on:
objecten-init:
condition: service_completed_successfully
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
networks:
cg:
+329 -17
View File
@@ -15,12 +15,12 @@
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
#
# After first boot, seed the BIG catalogus and note the zaaktype URL:
# python infra/openzaak/seed_catalogus.py
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
# service:
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
# docker compose -f infra/docker-compose.yml up -d acl
# After first boot, seed + publish the BIG catalogus:
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
# single-label host is rejected):
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
services:
@@ -51,6 +51,12 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147, same lever as #145): OpenZaak serves
# single-request smoke checks here and is not load-tested, so 4 idle Django workers just pin
# ~800 MB and pressure the shared runner. The -init (setup_configuration) and -celery containers
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -135,6 +141,9 @@ services:
# needs no baked config.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -296,6 +305,10 @@ services:
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: acl
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
@@ -304,11 +317,21 @@ services:
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# Override with the real zaaktype URL after running seed_catalogus.py.
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
# so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
Acl__Objecten__BaseUrl: http://objecten.local:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
healthcheck:
@@ -333,6 +356,10 @@ services:
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: domain
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
@@ -359,6 +386,10 @@ services:
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: bff
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
@@ -367,6 +398,8 @@ services:
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
Downstream__Acl__BaseUrl: http://acl:8080/
ports:
- "8080:8080"
healthcheck:
@@ -411,6 +444,10 @@ services:
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: event-subscriber
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
@@ -440,6 +477,10 @@ services:
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: projection-api
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
@@ -455,7 +496,7 @@ services:
networks: [cg]
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
# nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# Caddy serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
self-service:
@@ -466,7 +507,7 @@ services:
ports:
- "8140:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
@@ -479,7 +520,7 @@ services:
condition: service_started
networks: [cg]
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
# The openbaar (public) register portal: Caddy serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
@@ -489,7 +530,7 @@ services:
ports:
- "8141:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
@@ -500,7 +541,7 @@ services:
condition: service_healthy
networks: [cg]
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
# The behandel portal: Caddy serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
@@ -510,7 +551,7 @@ services:
ports:
- "8142:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
@@ -523,11 +564,273 @@ services:
condition: service_started
networks: [cg]
# The beheer portal: Caddy serves the Angular app and reverse-proxies /beheer to the BFF.
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
beheer:
build:
context: ..
dockerfile: apps/beheer/Dockerfile
image: register-referentie/beheer:dev
ports:
- "8143:80"
healthcheck:
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — upstream Maykin image, verbatim ──────────────
# The register's objecttype catalogue. Same shape as the other CG modules: own DB + redis, an
# `-init` that runs setup_configuration (RUN_SETUP_CONFIG → migrate + provision a static API token)
# from the external config volume streamed in by infra/seed-config.sh, and a health-checked web
# service that depends on init completing.
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env
# 1 uWSGI worker, not the image default of 4×4: this API only serves single-request smoke
# checks and sits idle during the e2e step — 4 idle Django workers each pin ~200 MB and starve
# the shared CI runner (#144). Init ignores this (it runs setup_configuration, not uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecttypen-config:/app/setup_configuration:ro
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot ────────────────
# The Objecttypen setup_configuration (3.4.2) can only provision tokens — no declarative objecttype
# step — so this one-shot creates the RegisterRecord objecttype + a published version over the API
# once Objecttypen is healthy (idempotent; ADR-0020 self-seed, ADR-0027 schema). The schema + script
# are streamed into the external config volume by infra/seed-config.sh, like the *-init volumes.
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- registerrecord-config:/config:ro
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — upstream Maykin image, verbatim ─────────────────
# The authoritative object store. Same shape as Objecttypen (own DB + redis, an `-init` that runs
# setup_configuration from the external config volume, a health-checked web). Two differences: the
# DB is PostGIS (objects carry geometry), and setup_configuration registers the Objecttypen API
# (S-18a) as a trusted service so an object can reference its objecttype.
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env
# 1 uWSGI worker, not the image default of 4×4 — see the objecttypen note above (#144).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
CELERY_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
# off until all four pieces existed.
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecten-config:/app/setup_configuration:ro
depends_on:
objecten-db:
condition: service_healthy
objecten-redis:
condition: service_started
# Objecten's setup_configuration registers the Objecttypen service; that service only needs to
# exist as config, but wait for Objecttypen to be up so the register is meaningful end to end.
objecttypen:
condition: service_healthy
networks: [cg]
objecten:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks:
cg:
# Objecten reflects the *request* Host into the `url` it returns, and
# notifications_api_common publishes that url as the notification's hoofdObject /
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
# Reads are unaffected and still use the plain service name.
aliases:
- objecten.local
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
# notifications_api_common only schedules the send on transaction commit; without a worker the
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
objecten-celery:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env
command: /celery_worker.sh
depends_on:
objecten-init:
condition: service_completed_successfully
networks: [cg]
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
# Config is baked into small built images (COPY) rather than streamed into
# external config volumes like the upstream CG modules — these aren't verbatim
# peer images, so a built image is the simpler path that still reaches sibling
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
# polls Grafana itself, so no in-image healthcheck tool is needed.
tempo:
build:
context: ./observability/tempo
image: register-referentie/tempo:dev
command: ["-config.file=/etc/tempo.yaml"]
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
mem_limit: 400m
networks: [cg]
prometheus:
build:
context: ./observability/prometheus
image: register-referentie/prometheus:dev
mem_limit: 400m
ports:
- "9090:9090"
networks: [cg]
grafana:
build:
context: ./observability/grafana
image: register-referentie/grafana:dev
mem_limit: 512m
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
ports:
- "3000:3000"
depends_on:
- tempo
- prometheus
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
@@ -543,6 +846,15 @@ volumes:
fl-bpmn:
external: true
name: rr-fl-bpmn
objecttypen-config:
external: true
name: rr-objecttypen-config
registerrecord-config:
external: true
name: rr-registerrecord-config
objecten-config:
external: true
name: rr-objecten-config
networks:
cg:
+8
View File
@@ -0,0 +1,8 @@
apiVersion: v2
name: big-reference
description: >-
The BIG reference stack (Common Ground) on Kubernetes — a port of
infra/docker-compose.yml, aimed at a single-node Talos cluster.
type: application
version: 0.1.0
appVersion: dev
@@ -0,0 +1,25 @@
{{ .Chart.Name }} {{ .Chart.Version }} deployed to namespace {{ .Release.Namespace }}.
Watch it converge (the upstream Django services migrate on first boot, so the
first bring-up takes a few minutes):
kubectl -n {{ .Release.Namespace }} get pods -w
kubectl -n {{ .Release.Namespace }} get jobs
Every bootstrap Job must reach Completions 1/1:
{{- range $name, $w := .Values.workloads }}
{{- if and (ne $w.enabled false) $w.job }}
- {{ $name }}
{{- end }}
{{- end }}
Open in a browser (add {{ .Values.host }} to /etc/hosts if you use a name):
{{- range $name, $port := .Values.nodePorts }}
{{- $w := index $.Values.workloads $name }}
{{- if ne $w.enabled false }}
{{ printf "%-16s http://%s:%v" $name $.Values.host $port }}
{{- end }}
{{- end }}
Test users are in docs/synthetic-data.md. If a pod is stuck in
ContainerCreating on a missing ConfigMap, run: make k8s-seed
@@ -0,0 +1,142 @@
{{/*
One pod spec for every workload, Deployment and Job alike. The chart is
values-driven on purpose: `.Values.workloads` is a near-literal transcription of
infra/docker-compose.yml, so the two stacks can be diffed by eye instead of by
archaeology. Adding a service is a values edit, not a template edit.
Called as: include "big.podspec" (dict "root" $ "name" $name "w" $w)
*/}}
{{- define "big.podspec" -}}
{{- $root := .root -}}
{{- $name := .name -}}
{{- $w := .w -}}
{{- with $root.Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- with $w.waitFor }}
initContainers:
- name: wait-for-deps
image: {{ $root.Values.images.busybox }}
command:
- sh
- -c
- |
for t in {{ join " " . }}; do
echo "waiting for $t"
until nc -z "${t%:*}" "${t#*:}"; do sleep 2; done
done
{{- end }}
containers:
- name: {{ $name }}
image: {{ include "big.image" (dict "root" $root "name" $name "w" $w) }}
# Only this repo's images get the configured policy: their `dev` tag is mutable.
# Upstream tags are pinned, so IfNotPresent keeps them out of pod-template diffs —
# which matters because a changed template makes a Job unpatchable (immutable).
imagePullPolicy: {{ if $w.own }}{{ $root.Values.images.pullPolicy }}{{ else }}IfNotPresent{{ end }}
{{- if $w.command }}
{{- fail (printf "workload %s: use `args`, not `command` — compose's `command:` replaces CMD, but Kubernetes' `command:` replaces the image ENTRYPOINT (postgres would run as root, keycloak would exec `start-dev`)" $name) }}
{{- end }}
{{- with $w.args }}
args:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with $w.envFrom }}
envFrom:
{{- range . }}
- configMapRef:
# optional: an env group whose feature is disabled (e.g. otel) simply
# isn't rendered, and the pod must still start.
name: {{ printf "%s-env" . }}
optional: true
{{- end }}
{{- end }}
{{- with $w.env }}
env:
{{- include "big.env" (list $root .) | nindent 6 }}
{{- end }}
{{- with $w.ports }}
ports:
{{- range . }}
- name: {{ .name }}
containerPort: {{ .targetPort | default .port }}
{{- end }}
{{- end }}
{{- with $w.probe }}
readinessProbe:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- if or $w.files $w.data }}
volumeMounts:
{{- range $w.files }}
- name: {{ .configMap }}
mountPath: {{ .mountPath }}
{{- with .subPath }}
subPath: {{ . }}
{{- end }}
readOnly: true
{{- end }}
{{- with $w.data }}
- name: data
mountPath: {{ .mountPath }}
{{- end }}
{{- end }}
{{- if or $w.files $w.data }}
volumes:
{{- range $w.files }}
- name: {{ .configMap }}
configMap:
name: {{ .configMap }}
{{- with .defaultMode }}
defaultMode: {{ . }}
{{- end }}
{{- end }}
{{- with $w.data }}
- name: data
{{- if $root.Values.persistence.storageClass }}
persistentVolumeClaim:
claimName: {{ $name }}-data
{{- else }}
# No StorageClass configured: the databases are emptyDir, so the stack needs
# no CSI driver to come up. Data then lives as long as the pod does — see
# docs/runbooks/kubernetes-talos.md for switching on local-path.
emptyDir: {}
{{- end }}
{{- end }}
{{- end }}
{{- end -}}
{{/* Image ref: `own: true` workloads are built from this repo, everything else is upstream. */}}
{{- define "big.image" -}}
{{- $root := .root -}}
{{- $w := .w -}}
{{- if $w.own -}}
{{- $ref := printf "%s/%s:%s" $root.Values.images.repositoryPrefix .name $root.Values.images.tag -}}
{{- with $root.Values.images.registry }}{{ printf "%s/%s" . $ref }}{{ else }}{{ $ref }}{{ end }}
{{- else -}}
{{- $w.image -}}
{{- end -}}
{{- end -}}
{{/*
Env list from a map. Every value is run through `tpl`, so values.yaml can name
cluster-internal hosts ({{ .Release.Namespace }}) and the node address
({{ .Values.host }}) without the chart hard-coding either.
*/}}
{{- define "big.env" -}}
{{- $root := index . 0 -}}
{{- range $k, $v := index . 1 }}
- name: {{ $k }}
value: {{ tpl (toString $v) $root | quote }}
{{- end }}
{{- end -}}
{{- define "big.labels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
app.kubernetes.io/managed-by: Helm
{{- end -}}
@@ -0,0 +1,44 @@
{{- /*
Shared env blocks — the Kubernetes equivalent of the YAML anchors in
infra/docker-compose.yml (&oz-env, &nrc-env, &objecttypen-env, &objecten-env).
A workload picks them up with `envFrom`, so the web/celery/init variants of an
upstream image stay guaranteed-identical, and `kubectl get cm oz-env -o yaml`
shows what a pod actually got.
The *file* inputs (setup_configuration data.yaml, Keycloak realms, BPMN/DMN, the
seed scripts) are NOT here: they live in the repo and are turned into ConfigMaps
by infra/helm/seed-configmaps.sh, exactly as infra/seed-config.sh streams them
into the compose config volumes. Copying them into the chart would fork them.
*/ -}}
{{- range $group, $env := .Values.envGroups }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ $group }}-env
labels:
{{- include "big.labels" (dict "root" $ "name" (printf "%s-env" $group)) | nindent 4 }}
data:
{{- range $k, $v := $env }}
{{ $k }}: {{ tpl (toString $v) $ | quote }}
{{- end }}
{{- end }}
{{- /*
Portal OIDC config. The images bake config.json with the compose authority
(keycloak:8080), which a browser outside the cluster cannot resolve; these
ConfigMaps mount over it with the node address Keycloak's issuer is pinned to
(KC_HOSTNAME below), so the token the browser gets and the issuer the BFF
discovers are the same string. Same mechanism as infra/host-browser.yml.
*/ -}}
{{- range $realm := list "digid" "medewerker" }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: portal-config-{{ $realm }}
labels:
{{- include "big.labels" (dict "root" $ "name" (printf "portal-config-%s" $realm)) | nindent 4 }}
data:
config.json: |
{ "authority": "{{ printf "http://%s:%v" $.Values.host (index $.Values.nodePorts "keycloak") }}/realms/{{ $realm }}" }
{{- end }}
@@ -0,0 +1,39 @@
{{- range $name, $w := .Values.workloads }}
{{- if and (ne $w.enabled false) (not $w.job) }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $name }}
labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
spec:
replicas: 1
# Recreate, not RollingUpdate: single node, ReadWriteOnce volumes, and nothing
# here is HA — a second pod would just fight the first for the disk.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: {{ $name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
template:
metadata:
{{- /*
A ConfigMap mounted with subPath never picks up updates, so a portal whose
config.json content changed has to be rolled. Hashing only the values that
render it keeps the churn off the databases — an emptyDir database that is
recreated for no reason loses its data (see the runbook §6).
*/}}
{{- range $w.files }}
{{- if hasPrefix "portal-config-" .configMap }}
annotations:
checksum/portal-config: {{ printf "%s|%v" $.Values.host (index $.Values.nodePorts "keycloak") | sha256sum }}
{{- end }}
{{- end }}
labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }}
spec:
{{- include "big.podspec" (dict "root" $ "name" $name "w" $w) | nindent 6 }}
{{- end }}
{{- end }}
@@ -0,0 +1,29 @@
{{- /*
The one-shot bootstrap containers from compose (oz-init, nrc-init, flowable-init,
the *-init setup_configuration runs, the zaaktype seed and the NRC abonnement)
become Jobs. All of them are idempotent, so ordering is not enforced with hooks:
each waits for the ports it needs (waitFor) and Kubernetes retries the rest.
A wiped database is re-seeded by `make k8s-reseed`.
*/ -}}
{{- range $name, $w := .Values.workloads }}
{{- if and (ne $w.enabled false) $w.job }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ $name }}
labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
app.kubernetes.io/component: init
spec:
backoffLimit: 20
template:
metadata:
labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }}
app.kubernetes.io/component: init
spec:
restartPolicy: OnFailure
{{- include "big.podspec" (dict "root" $ "name" $name "w" $w) | nindent 6 }}
{{- end }}
{{- end }}
@@ -0,0 +1,22 @@
{{- if .Values.persistence.storageClass }}
{{- range $name, $w := .Values.workloads }}
{{- if and (ne $w.enabled false) $w.data }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ $name }}-data
labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
# Keep the databases when the release is uninstalled; `make k8s-purge` drops them.
annotations:
helm.sh/resource-policy: keep
spec:
accessModes: [ReadWriteOnce]
storageClassName: {{ $.Values.persistence.storageClass }}
resources:
requests:
storage: {{ $w.data.size | default "2Gi" }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,35 @@
{{- /*
Service names are the compose service names, verbatim: the portals' Caddy
proxies to http://bff:8080 and the upstream setup_configuration files name
http://openzaak:8000 / http://nrc-web:8000, so in-cluster DNS has to answer to
exactly those names. Do not rename a workload without checking both.
.Values.nodePorts is the single place a port is published outside the cluster;
a workload listed there gets a NodePort on its first (only) port.
*/ -}}
{{- range $name, $w := .Values.workloads }}
{{- if and (ne $w.enabled false) $w.ports }}
{{- $nodePort := index $.Values.nodePorts $name }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
spec:
type: {{ if $nodePort }}NodePort{{ else }}ClusterIP{{ end }}
selector:
app.kubernetes.io/name: {{ $name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
ports:
{{- range $i, $p := $w.ports }}
- name: {{ $p.name }}
port: {{ $p.port }}
targetPort: {{ $p.targetPort | default $p.port }}
{{- if and $nodePort (eq $i 0) }}
nodePort: {{ $nodePort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+608
View File
@@ -0,0 +1,608 @@
# Values for the BIG reference stack on Kubernetes.
#
# `workloads` is a near-literal transcription of infra/docker-compose.yml — same
# service names, same images, same env, same one-shots — so the two stacks can be
# diffed by eye. Read that file's comments for the *why* behind each setting; only
# the deviations forced by Kubernetes are re-explained here.
#
# Every env value is rendered with Helm's `tpl`, so it may use:
# {{ .Release.Namespace }} — for a cluster-internal FQDN
# {{ .Values.host }} — the node address a browser reaches the cluster on
#
# Deviations from compose, all of them consequences of the platform:
# * The compose stack hands the ACL and the seeds OpenZaak's *container IP*,
# because OpenZaak and NRC validate URLs with Django's URLValidator and a
# single-label host ("openzaak") is rejected. In Kubernetes the service FQDN
# (openzaak.<ns>.svc.cluster.local) is already multi-label, so the IP dance and
# the `objecten.local` network alias both disappear.
# * `depends_on: service_healthy` becomes a `waitFor` init container (TCP wait)
# plus readiness probes. Ordering is otherwise not enforced: every bootstrap
# job is idempotent and Kubernetes retries.
# * The published ports are NodePorts (see `nodePorts`), not host ports.
# The address a browser outside the cluster uses to reach the node: your Talos
# VM's IP. It pins Keycloak's issuer and the portals' OIDC authority to one
# string, so browser tokens and the BFF's discovered issuer agree.
host: 192.168.122.100
# Set when pulling from a private registry (e.g. the Gitea Container Registry).
imagePullSecrets: []
images:
# Where the images built from THIS repo live. Empty = the bare
# `register-referentie/<svc>:dev` names, which only works if the node already
# has them. On Talos it never does — point this at a registry the node can
# reach (see docs/runbooks/kubernetes-talos.md).
registry: ""
repositoryPrefix: register-referentie
tag: dev
# Applies to this repo's images only (see _helpers.tpl). Always, because `dev`
# is a mutable tag: with IfNotPresent the node keeps the first image it pulled
# and `make k8s-images` would appear to do nothing. The registry is in-cluster,
# so a re-pull is local and cheap — but the pods do depend on it being up.
pullPolicy: Always
busybox: docker.io/library/busybox:stable
persistence:
# Empty = every database is an emptyDir, so the stack comes up on a bare
# cluster with no CSI driver. Set to a StorageClass (e.g. `local-path`) to keep
# the data across pod restarts.
storageClass: ""
# The only place a port is published outside the cluster. A workload listed here
# gets a NodePort on its single port; everything else stays ClusterIP.
nodePorts:
openzaak: 30000
nrc-web: 30001
objecttypen: 30020
objecten: 30021
bff: 30080
flowable-rest: 30090
self-service: 30140
openbaar: 30141
behandel: 30142
beheer: 30143
keycloak: 30180
grafana: 30300
# ── Shared env blocks (the compose YAML anchors) ────────────────────────────────
envGroups:
oz:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: oz-db
DB_NAME: openzaak
DB_USER: openzaak
DB_PASSWORD: openzaak
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: oz-redis:6379/0
CACHE_AXES: oz-redis:6379/0
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
nrc:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: nrc-db
DB_NAME: opennotificaties
DB_USER: opennotificaties
DB_PASSWORD: opennotificaties
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: nrc-redis:6379/0
CACHE_AXES: nrc-redis:6379/0
CELERY_BROKER_URL: redis://nrc-redis:6379/1
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
DISABLE_2FA: "true"
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
NOTIFICATION_SEC_INTERVAL: "5"
objecttypen:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
objecten:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
CELERY_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
# Traces for the .NET services. Always set, like compose: the exporter fails
# harmlessly when Tempo is absent (services/*/Program.cs).
otel:
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
# ── Workloads ──────────────────────────────────────────────────────────────────
# Per entry: image | own (built here) · args · envFrom (env groups) · env
# ports · probe (a literal readinessProbe) · files (ConfigMap mounts) · data
# (a database volume) · waitFor (host:port to wait for) · job · enabled
#
# `args` (never `command`) is the compose `command:` equivalent: compose replaces
# the image's CMD, and so does Kubernetes' `args` — Kubernetes' `command` would
# replace the ENTRYPOINT instead. The chart fails to render if you use `command`.
workloads:
# ── OpenZaak (S-01) ─────────────────────────────────────────────────────────
oz-db:
image: docker.io/postgis/postgis:17-3.5
args: [postgres, -c, max_connections=300]
env:
POSTGRES_USER: openzaak
POSTGRES_PASSWORD: openzaak
POSTGRES_DB: openzaak
ports: [{ name: postgres, port: 5432 }]
data: { mountPath: /var/lib/postgresql/data, size: 4Gi }
probe:
exec:
command: [sh, -c, "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q"]
periodSeconds: 5
oz-redis:
image: docker.io/library/redis:7
ports: [{ name: redis, port: 6379 }]
probe: { tcpSocket: { port: 6379 } }
openzaak:
image: docker.io/openzaak/open-zaak:1.28.2
# setup_configuration first, then the server — in ONE container, on purpose.
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
# separate init Job (as compose has, ordered by depends_on) races this pod for
# the same database and Django fails with "relation already exists".
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
envFrom: [oz]
ports: [{ name: http, port: 8000 }]
# /admin/ answers 302 when Django is up — a redirect counts as ready.
probe:
httpGet: { path: /admin/, port: 8000 }
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
files: [{ configMap: rr-oz-config, mountPath: /app/setup_configuration }]
waitFor: [oz-db:5432, oz-redis:6379]
oz-celery:
image: docker.io/openzaak/open-zaak:1.28.2
args: [/celery_worker.sh]
envFrom: [oz]
waitFor: [oz-db:5432, oz-redis:6379]
# ── Open Notificaties / NRC (S-01-c) ────────────────────────────────────────
nrc-db:
image: docker.io/postgis/postgis:17-3.5
args: [postgres, -c, max_connections=300]
env:
POSTGRES_USER: opennotificaties
POSTGRES_PASSWORD: opennotificaties
POSTGRES_DB: opennotificaties
ports: [{ name: postgres, port: 5432 }]
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
probe:
exec: { command: [pg_isready, -U, opennotificaties, -d, opennotificaties] }
periodSeconds: 5
nrc-redis:
image: docker.io/library/redis:7
ports: [{ name: redis, port: 6379 }]
probe: { tcpSocket: { port: 6379 } }
nrc-web:
image: docker.io/openzaak/open-notificaties:1.16.1
# setup_configuration first, then the server — in ONE container, on purpose.
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
# separate init Job (as compose has, ordered by depends_on) races this pod for
# the same database and Django fails with "relation already exists".
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
envFrom: [nrc]
ports: [{ name: http, port: 8000 }]
probe:
httpGet: { path: /admin/, port: 8000 }
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
files: [{ configMap: rr-nrc-config, mountPath: /app/setup_configuration }]
waitFor: [nrc-db:5432, nrc-redis:6379, openzaak:8000]
nrc-celery:
image: docker.io/openzaak/open-notificaties:1.16.1
args: [/celery_worker.sh]
envFrom: [nrc]
waitFor: [nrc-db:5432, nrc-redis:6379]
# Without beat, notifications are accepted but never delivered (ADR-0007).
nrc-beat:
image: docker.io/openzaak/open-notificaties:1.16.1
args: [/celery_beat.sh]
envFrom: [nrc]
waitFor: [nrc-db:5432, nrc-redis:6379]
# ── Keycloak (S-02) ─────────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
args: [start-dev, --import-realm]
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
# Pin the issuer to the address the browser uses, and let backchannel calls
# keep using keycloak:8080 — the BFF discovers metadata in-cluster and gets
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
KC_HOSTNAME: "http://{{ .Values.host }}:{{ index .Values.nodePorts \"keycloak\" }}"
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports: [{ name: http, port: 8080 }]
# TCP, not /health/ready on the management port: nothing here gates on realm
# import, and a wrong health path would leave the Service with no endpoints.
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }]
# ── Flowable (S-03) ─────────────────────────────────────────────────────────
flowable-db:
image: docker.io/library/postgres:16
env:
POSTGRES_USER: flowable
POSTGRES_PASSWORD: flowable
POSTGRES_DB: flowable
ports: [{ name: postgres, port: 5432 }]
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
probe:
exec: { command: [pg_isready, -U, flowable, -d, flowable] }
periodSeconds: 5
flowable-rest:
image: docker.io/flowable/flowable-rest:latest
env:
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
SPRING_DATASOURCE_USERNAME: flowable
SPRING_DATASOURCE_PASSWORD: flowable
ports: [{ name: http, port: 8080 }]
# Every REST path needs basic auth, so an httpGet probe would read 401 as
# not-ready. TCP is the honest signal here.
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 20 }
waitFor: [flowable-db:5432]
# Deploys the BPMN to the process engine and the DMN to the DMN engine as two
# separate deployments — flowable-rest does not cascade one into the other
# (S-13, ADR-0016). Idempotent.
flowable-init:
job: true
image: docker.io/curlimages/curl:latest
args:
- sh
- -c
- |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
until curl -sf -u rest-admin:test "$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
if curl -s -u rest-admin:test "$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
fi
if curl -s -u rest-admin:test "$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$svc" >/dev/null && echo "deployed registratie BPMN"
fi
files: [{ configMap: rr-fl-bpmn, mountPath: /work }]
waitFor: [flowable-rest:8080]
# ── ACL ─────────────────────────────────────────────────────────────────────
acl:
own: true
envFrom: [otel]
env:
OTEL_SERVICE_NAME: acl
# The FQDN, not `openzaak`: OpenZaak rejects a single-label host on
# zaak-create. It must be the same host the zaaktype was seeded through
# (see the seed-zaaktype job) so the URLs stay host-consistent (ADR-0009).
Acl__OpenZaak__BaseUrl: "http://openzaak.{{ .Release.Namespace }}.svc.cluster.local:8000/"
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten reflects the request Host into the object url it returns, and
# publishes that url to NRC — which rejects a single-label host. The FQDN
# replaces compose's `objecten.local` alias (ADR-0029).
Acl__Objecten__BaseUrl: "http://objecten.{{ .Release.Namespace }}.svc.cluster.local:8000/"
Acl__Objecten__Token: 1234567890abcdef1234567890abcdef12345678
# Short name on purpose: Objecten only accepts an objecttype URL that
# matches the one it was configured with (infra/objecten/setup_configuration
# /data.yaml → http://objecttypen:8000/api/v2/).
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: 0123456789abcdef0123456789abcdef01234567
Acl__Objecten__ObjecttypeName: RegisterRecord
ports: [{ name: http, port: 8080 }]
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
# ── BIG Domain Service (S-05) ───────────────────────────────────────────────
domain:
own: true
envFrom: [otel]
env:
OTEL_SERVICE_NAME: domain
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports: [{ name: http, port: 8080 }]
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
# ── BFF ─────────────────────────────────────────────────────────────────────
bff:
own: true
envFrom: [otel]
env:
OTEL_SERVICE_NAME: bff
# In-cluster authority: Keycloak's discovery document returns the pinned
# KC_HOSTNAME issuer, which is what browser tokens carry (ADR-0010).
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
Downstream__Acl__BaseUrl: http://acl:8080/
ports: [{ name: http, port: 8080 }]
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
# ── Read projection (S-06) ──────────────────────────────────────────────────
projection-db:
image: docker.io/library/postgres:16
env:
POSTGRES_USER: projection
POSTGRES_PASSWORD: projection
POSTGRES_DB: projection
ports: [{ name: postgres, port: 5432 }]
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
probe:
exec: { command: [pg_isready, -U, projection, -d, projection] }
periodSeconds: 5
event-subscriber:
own: true
envFrom: [otel]
env:
OTEL_SERVICE_NAME: event-subscriber
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
Acl__BaseUrl: http://acl:8080/
EventSubscriber__Webhook__AuthToken: Bearer big-reference-notifications
ports: [{ name: http, port: 8080 }]
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
# It migrates the projection schema on start and throws if the DB is absent.
waitFor: [projection-db:5432]
projection-api:
own: true
envFrom: [otel]
env:
OTEL_SERVICE_NAME: projection-api
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports: [{ name: http, port: 8080 }]
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
waitFor: [projection-db:5432]
# ── Portals (S-08/S-09/S-12/S-15) ───────────────────────────────────────────
# Caddy serves the Angular app and reverse-proxies its endpoint group to
# http://bff:8080 — hence the Service must stay named `bff`. Caddy resolves that
# name through the system resolver, so the DNS search domains apply and no
# upstream rewriting is needed here (ADR-0034).
self-service:
own: true
ports: [{ name: http, port: 80 }]
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
files:
- configMap: portal-config-digid
mountPath: /usr/share/caddy/config.json
subPath: config.json
openbaar:
own: true
ports: [{ name: http, port: 80 }]
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
behandel:
own: true
ports: [{ name: http, port: 80 }]
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
files:
- configMap: portal-config-medewerker
mountPath: /usr/share/caddy/config.json
subPath: config.json
beheer:
own: true
ports: [{ name: http, port: 80 }]
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
files:
- configMap: portal-config-medewerker
mountPath: /usr/share/caddy/config.json
subPath: config.json
# ── Objecttypen API (S-18a) ─────────────────────────────────────────────────
objecttypen-db:
image: docker.io/library/postgres:17-alpine
env:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
ports: [{ name: postgres, port: 5432 }]
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
probe:
exec: { command: [pg_isready, -U, objecttypes] }
periodSeconds: 5
objecttypen-redis:
image: docker.io/library/redis:7
ports: [{ name: redis, port: 6379 }]
probe: { tcpSocket: { port: 6379 } }
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:3.4.2
# setup_configuration first, then the server — in ONE container, on purpose.
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
# separate init Job (as compose has, ordered by depends_on) races this pod for
# the same database and Django fails with "relation already exists".
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
envFrom: [objecttypen]
ports: [{ name: http, port: 8000 }]
probe:
httpGet: { path: /admin/, port: 8000 }
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
files: [{ configMap: rr-objecttypen-config, mountPath: /app/setup_configuration }]
waitFor: [objecttypen-db:5432, objecttypen-redis:6379]
# The RegisterRecord objecttype + published version, over the API (S-18c,
# ADR-0020/ADR-0027). The uuid is pinned — Objecten identifies it by uuid.
registerrecord-init:
job: true
image: docker.io/library/python:3-slim
args: [python, /config/register.py]
env:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: 0123456789abcdef0123456789abcdef01234567
SCHEMA: /config/registerrecord.schema.json
files: [{ configMap: rr-registerrecord-config, mountPath: /config }]
waitFor: [objecttypen:8000]
# ── Objecten API (S-18b) ────────────────────────────────────────────────────
objecten-db:
image: docker.io/postgis/postgis:17-3.5
env:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
ports: [{ name: postgres, port: 5432 }]
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
probe:
exec: { command: [pg_isready, -U, objects] }
periodSeconds: 5
objecten-redis:
image: docker.io/library/redis:7
ports: [{ name: redis, port: 6379 }]
probe: { tcpSocket: { port: 6379 } }
objecten:
image: docker.io/maykinmedia/objects-api:3.4.0
# setup_configuration first, then the server — in ONE container, on purpose.
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
# separate init Job (as compose has, ordered by depends_on) races this pod for
# the same database and Django fails with "relation already exists".
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
envFrom: [objecten]
ports: [{ name: http, port: 8000 }]
probe:
httpGet: { path: /admin/, port: 8000 }
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
files: [{ configMap: rr-objecten-config, mountPath: /app/setup_configuration }]
waitFor: [objecten-db:5432, objecten-redis:6379, objecttypen:8000]
# Delivers Objecten's notifications to NRC; without it every register write is
# silently undelivered (ADR-0029).
objecten-celery:
image: docker.io/maykinmedia/objects-api:3.4.0
args: [/celery_worker.sh]
envFrom: [objecten]
waitFor: [objecten-db:5432, objecten-redis:6379]
# ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ──
# Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the
# server-assigned URLs are host-consistent. The ACL then resolves them by
# identificatie (S-27, ADR-0021) — nothing is injected back.
# Publishing validates the resultaattype against the external Selectielijst
# API, so the node needs outbound internet for this one job (ADR-0006).
seed-zaaktype:
job: true
image: docker.io/library/python:3-slim
args: [python, /seed/seed_catalogus.py]
env:
OZ_BASE: "http://openzaak.{{ .Release.Namespace }}.svc.cluster.local:8000"
OZ_PUBLISH: "1"
files: [{ configMap: rr-seed-scripts, mountPath: /seed }]
waitFor: [openzaak:8000]
# Registers the NRC abonnement on the `objecten` kanaal pointing at the
# event-subscriber, so register writes reach the projection (ADR-0030).
# Without it the openbaar register stays empty. Restart-safe and idempotent.
nrc-subscribe:
job: true
image: docker.io/library/python:3-slim
args: [python, /seed/register-abonnement.py]
env:
NRC_BASE: http://nrc-web:8000
# The script resolves this to an address for the callback URL; the FQDN
# resolves to the Service's (stable) ClusterIP, which NRC's URLValidator
# accepts — the compose stack uses the container IP for the same reason.
SINK_HOST: "event-subscriber.{{ .Release.Namespace }}.svc.cluster.local"
SINK_PORT: "8080"
SINK_AUTH: Bearer big-reference-notifications
files: [{ configMap: rr-seed-scripts, mountPath: /seed }]
waitFor: [nrc-web:8000, event-subscriber:8080]
# ── Observability backplane (S-16a, ADR-0023) ───────────────────────────────
# Off by default: these are built images too (config baked in), so switching
# them on also means pushing three more images. Enable all three together.
tempo:
enabled: false
own: true
args: ["-config.file=/etc/tempo.yaml"]
ports: [{ name: otlp, port: 4317 }, { name: http, port: 3200 }]
prometheus:
enabled: false
own: true
ports: [{ name: http, port: 9090 }]
grafana:
enabled: false
own: true
env:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
ports: [{ name: http, port: 3000 }]
+60
View File
@@ -0,0 +1,60 @@
# Throwaway in-cluster OCI registry, published on NodePort 30500.
#
# Talos has no Docker daemon and no way to side-load an image, so the images built
# from this repo must come from a registry. This one lives *inside* the cluster on
# purpose: a registry on the laptop needs an inbound port opened on firewalld's
# libvirt zone (root), while pushing from the laptop to the node is outbound and
# always allowed. The node then pulls from its own NodePort.
#
# Talos must be told it speaks plain HTTP — see the machine.registries.mirrors
# patch in docs/runbooks/kubernetes-talos.md. Storage is emptyDir: if this pod is
# replaced, re-run `make k8s-images`.
apiVersion: v1
kind: Namespace
metadata:
name: registry
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: registry
spec:
replicas: 1
strategy: { type: Recreate }
selector:
matchLabels: { app: registry }
template:
metadata:
labels: { app: registry }
spec:
containers:
- name: registry
image: docker.io/library/registry:2
env:
- name: REGISTRY_STORAGE_DELETE_ENABLED
value: "true"
ports:
- containerPort: 5000
readinessProbe:
httpGet: { path: /v2/, port: 5000 }
volumeMounts:
- name: data
mountPath: /var/lib/registry
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: registry
namespace: registry
spec:
type: NodePort
selector: { app: registry }
ports:
- name: http
port: 5000
targetPort: 5000
nodePort: 30500
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# Turn the repo's config inputs into the ConfigMaps the Helm chart mounts.
#
# This is the Kubernetes sibling of infra/seed-config.sh: the upstream Common
# Ground images are used verbatim and read their config from a mounted directory,
# so the config has to be handed to the platform out-of-band. Compose gets it via
# `docker cp` into external volumes; Kubernetes gets it as ConfigMaps created from
# the files that already live in this repo. Copying those files into the chart
# would fork them from the compose stack, so we don't.
#
# Idempotent: re-run after editing any data.yaml, then `make k8s-reseed`.
#
# Usage: seed-configmaps.sh [namespace] (default: big)
set -euo pipefail
ns="${1:-big}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
kubectl get namespace "$ns" >/dev/null 2>&1 || kubectl create namespace "$ns"
seed() { # name <kubectl --from-file args...>
local name="$1"; shift
kubectl create configmap "$name" -n "$ns" "$@" \
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
echo " seeded configmap/$name"
}
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
# kubectl only takes regular files from a --from-file directory).
seed rr-registerrecord-config --from-file="$repo/infra/objecttypen-registerrecord/"
# The BPMN and the DMN are two separate Flowable deployments (S-13, ADR-0016).
seed rr-fl-bpmn \
--from-file="$repo/workflows/registratie.bpmn" \
--from-file="$repo/workflows/diploma-eligibility.dmn"
# The two bootstrap scripts the compose local stack runs as init containers
# (S-B04, ADR-0020). Stdlib-only, so a plain python image can run them.
seed rr-seed-scripts \
--from-file="$repo/infra/openzaak/seed_catalogus.py" \
--from-file="$repo/infra/local/register-abonnement.py"
+20
View File
@@ -0,0 +1,20 @@
# Overlay: make the CI compose stack usable from a HOST browser.
# Same two mechanisms infra/docker-compose.local.yml already uses — pin Keycloak's issuer to the
# host-published address, and point each portal's runtime config.json at it. The BFF needs no
# change: it discovers metadata over keycloak:8080 and the discovered issuer is the pinned
# localhost:8180, which is what browser tokens carry.
services:
keycloak:
environment:
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
self-service:
volumes:
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
behandel:
volumes:
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
# beheer is the same medewerker realm as behandel, so it reuses behandel's config verbatim.
beheer:
volumes:
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
+46 -12
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env python3
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
and returns its expected identifying claim. Stdlib only. Exits non-zero on failure.
and returns its expected identifying claim. The medewerker realm additionally enforces
MFA (S-15c), so its login must be refused without a TOTP code. Stdlib only.
Exits non-zero on failure.
"""
import base64, json, sys, urllib.error, urllib.parse, urllib.request
import base64, hashlib, hmac, json, struct, sys, time, urllib.error, urllib.parse, urllib.request
BASE = "http://localhost:8180"
CLIENT = "big-portal"
PWD = "test123"
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains
# Fixture TOTP secret seeded into every medewerker in infra/keycloak/realms/medewerker-realm.json.
# Keycloak HMACs the raw secret bytes, so no base32 decoding is involved.
OTP_SECRET = b"BIGMEDEWERKEROTPSEED"
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains, mfa-enforced
CHECKS = [
("digid", "jan-burger", "bsn", "123456782"),
("eherkenning", "acme-ondernemer", "kvk", "12345678"),
("eidas", "pierre-dupont", "eidas_id", "FR/NL"),
("medewerker", "merel-behandelaar", "__roles__", "behandelaar"),
("digid", "jan-burger", "bsn", "123456782", False),
("eherkenning", "acme-ondernemer", "kvk", "12345678", False),
("eidas", "pierre-dupont", "eidas_id", "FR/NL", False),
("medewerker", "merel-behandelaar", "__roles__", "behandelaar", True),
]
@@ -23,10 +29,17 @@ def decode(jwt):
return json.loads(base64.urlsafe_b64decode(p))
def grant(realm, user):
def totp(secret=OTP_SECRET, period=30, digits=6):
"""RFC 6238 code: HMAC-SHA1 over the 30-second counter, dynamically truncated."""
mac = hmac.new(secret, struct.pack(">Q", int(time.time()) // period), hashlib.sha1).digest()
o = mac[-1] & 0x0F
return str((struct.unpack(">I", mac[o:o + 4])[0] & 0x7FFFFFFF) % 10 ** digits).zfill(digits)
def grant(realm, user, **extra):
data = urllib.parse.urlencode({
"grant_type": "password", "client_id": CLIENT,
"username": user, "password": PWD, "scope": "openid",
"username": user, "password": PWD, "scope": "openid", **extra,
}).encode()
req = urllib.request.Request(
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
@@ -35,11 +48,27 @@ def grant(realm, user):
return json.loads(r.read())
def second_factor_refused(realm, user):
"""The password alone must not yield a token on an MFA-enforced realm."""
try:
grant(realm, user)
except urllib.error.HTTPError as e:
return e.code in (400, 401)
return False
def main():
ok = True
for realm, user, claim, expect in CHECKS:
for realm, user, claim, expect, mfa in CHECKS:
extra = {}
if mfa:
refused = second_factor_refused(realm, user)
ok = ok and refused
print(f"{realm:12} {user:18} password-only login refused "
f"[{'OK' if refused else 'MFA NOT ENFORCED'}]")
extra = {"totp": totp()}
try:
at = decode(grant(realm, user)["access_token"])
at = decode(grant(realm, user, **extra)["access_token"])
if claim == "__roles__":
val = at.get("realm_access", {}).get("roles", [])
good = expect in val
@@ -57,4 +86,9 @@ def main():
if __name__ == "__main__":
main()
# `check_realms.py otp` prints a current code for the fixture secret — what a human demoing
# the medewerker portals types at Keycloak's OTP prompt (docs/runbooks/keycloak.md).
if len(sys.argv) > 1 and sys.argv[1] == "otp":
print(totp())
else:
main()
+30
View File
@@ -38,6 +38,36 @@
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["123456782"] }
},
{
"username": "sanne-burger",
"enabled": true,
"firstName": "Sanne",
"lastName": "Burger",
"email": "sanne.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477813"] }
},
{
"username": "emma-burger",
"enabled": true,
"firstName": "Emma",
"lastName": "Burger",
"email": "emma.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477805"] }
},
{
"username": "lars-burger",
"enabled": true,
"firstName": "Lars",
"lastName": "Burger",
"email": "lars.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477821"] }
}
]
}
+48 -3
View File
@@ -2,10 +2,21 @@
"realm": "medewerker",
"enabled": true,
"displayName": "Medewerkers",
"requiredActions": [
{
"alias": "CONFIGURE_TOTP",
"name": "Configure OTP",
"providerId": "CONFIGURE_TOTP",
"enabled": true,
"defaultAction": true,
"priority": 10
}
],
"roles": {
"realm": [
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
{ "name": "teamlead", "description": "Teamleider behandeling" }
{ "name": "teamlead", "description": "Teamleider behandeling" },
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
]
},
"clients": [
@@ -42,7 +53,15 @@
"lastName": "Behandelaar",
"email": "merel@big.example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"credentials": [
{ "type": "password", "value": "test123", "temporary": false },
{
"type": "otp",
"userLabel": "seeded TOTP (fixture)",
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
}
],
"realmRoles": ["behandelaar"]
},
{
@@ -52,8 +71,34 @@
"lastName": "Teamlead",
"email": "tom@big.example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"credentials": [
{ "type": "password", "value": "test123", "temporary": false },
{
"type": "otp",
"userLabel": "seeded TOTP (fixture)",
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
}
],
"realmRoles": ["behandelaar", "teamlead"]
},
{
"username": "bram-beheerder",
"enabled": true,
"firstName": "Bram",
"lastName": "Beheerder",
"email": "bram@big.example.nl",
"emailVerified": true,
"credentials": [
{ "type": "password", "value": "test123", "temporary": false },
{
"type": "otp",
"userLabel": "seeded TOTP (fixture)",
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
}
],
"realmRoles": ["beheerder"]
}
]
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
abonnement on the `objecten` kanaal pointing at the event-subscriber's /notifications callback, so
the register writes the ACL makes (INGEDIEND on submit, INGESCHREVEN on approval) reach the
projection without this the openbaar (public) register stays empty. Since S-19b-2 the projection
is sourced from the register in Objecten, not from ZGW zaak events (ADR-0030).
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
abonnement first, then registers one for the current IP. Stdlib only.
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
"""
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
SINK_PORT = os.environ.get("SINK_PORT", "8080")
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
# The projection is sourced from the register in Objecten, not from ZGW zaak events (S-19b-2).
KANAAL = "objecten"
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
seg = (
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
+ b"."
+ b64(json.dumps(
{"iss": CID, "iat": int(time.time()), "client_id": CID,
"user_id": "local-seed", "user_representation": "local-seed"},
separators=(",", ":")).encode())
)
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
def call(method, url, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": "Bearer " + token(),
"Content-Type": "application/json", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
return e.code, (json.loads(raw) if raw else None)
def main():
ip = socket.gethostbyname(SINK_HOST)
callback = f"http://{ip}:{SINK_PORT}/notifications"
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
# fresh one for the current event-subscriber IP.
status, body = call("GET", f"{NRC}/api/v1/abonnement")
for ab in (body or []) if status == 200 else []:
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
# The kanaal is part of "current": an abonnement left over from before S-19b-2 points at
# the right callback but listens on `zaken`, and would never be replaced on IP alone.
kanalen = [k.get("naam") for k in ab.get("kanalen", [])]
if ab.get("callbackUrl") == callback and kanalen == [KANAAL]:
print(f"abonnement already current: {ab['url']}")
return
call("DELETE", ab["url"])
print(f"removed stale abonnement {ab['url']}")
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
"callbackUrl": callback, "auth": SINK_AUTH,
"kanalen": [{"naam": KANAAL, "filters": {}}]})
if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement registered: {ab['url']} -> {callback}")
if __name__ == "__main__":
main()

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