Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 abe51b5d12 fix(infra): local event-subscriber needs Acl:BaseUrl (parity, crashes without it)
CI / lint (pull_request) Successful in 1m18s
CI / build (pull_request) Successful in 1m15s
CI / unit (pull_request) Successful in 1m11s
CI / frontend (pull_request) Successful in 2m49s
CI / mutation (pull_request) Successful in 5m21s
CI / verify-stack (pull_request) Successful in 7m1s
The local compose's event-subscriber lacked Acl__BaseUrl (and the acl dependency), so it threw

'Missing configuration Acl:BaseUrl' at startup — a parity gap vs the canonical compose (#78).

Its non-zero exit also destabilised podman-compose's bring-up of the rest of the stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:35:22 +02:00
194 changed files with 251 additions and 10817 deletions
+5 -133
View File
@@ -9,12 +9,6 @@ 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).
@@ -70,12 +64,6 @@ 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:
@@ -90,12 +78,6 @@ 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
@@ -111,29 +93,6 @@ 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
@@ -170,122 +129,35 @@ jobs:
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# 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
# One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
# image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
#
# `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: 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 }}
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 "| 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 beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 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 2>&1 || true
- name: Tear down
if: always()
run: make down
-4
View File
@@ -57,7 +57,3 @@ vitest.config.*.timestamp*
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
__pycache__/
TestResults/
test-output/
tests/e2e/playwright-report.json
+8 -53
View File
@@ -199,25 +199,9 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
### S-11 · Withdrawal (Flow 3)
@@ -239,65 +223,36 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
### S-26 · Self-service — resume an existing registration after refresh — #111
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
---
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
### S-15 · Beheer-portal — catalogus & default-fill rules
**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.
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)*
### S-16 · OpenTelemetry traces + Grafana dashboard
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
Split into independently deployable sub-slices (CLAUDE.md §13):
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
- **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.
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
---
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
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)*
### S-19 · ACL extension: write register-record to Objecten on approval
**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. Depends on S-19a.
---
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
+8 -44
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 beheer objecttypen objecten
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
@@ -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 rr-objecttypen-config rr-objecten-config rr-registerrecord-config
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -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-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.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
## 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,9 +70,8 @@ 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.
unit:
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
dotnet test $(SLN) -c Release --filter "Category!=Integration"
## 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`
@@ -94,14 +93,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 objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## 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 objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
@@ -115,11 +114,6 @@ 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
@@ -139,7 +133,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 objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
@@ -171,41 +165,11 @@ 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: 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 objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
-4
View File
@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
-27
View File
@@ -1,27 +0,0 @@
# Multi-stage build for the beheer portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/beheer apps/beheer
COPY libs libs
RUN pnpm nx build beheer
FROM nginx:1.27-alpine AS runtime
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/beheer/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-24
View File
@@ -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 beheer 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 /beheer/ {
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;
}
}
-82
View File
@@ -1,82 +0,0 @@
{
"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
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-65
View File
@@ -1,65 +0,0 @@
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
@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/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
@@ -1,5 +0,0 @@
<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
@@ -1,9 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,12 +0,0 @@
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';
}
@@ -1,40 +0,0 @@
<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>
@@ -1,75 +0,0 @@
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([]);
});
});
@@ -1,45 +0,0 @@
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);
},
});
}
}
@@ -1,60 +0,0 @@
<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>
@@ -1,90 +0,0 @@
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([]);
});
});
@@ -1,72 +0,0 @@
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
@@ -1,13 +0,0 @@
<!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
@@ -1,10 +0,0 @@
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
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"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
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
-4
View File
@@ -17,9 +17,5 @@ FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
-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
-4
View File
@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
@@ -11,33 +11,6 @@
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
<label utrecht-form-label for="diploma">Diploma</label>
<input
id="diploma"
type="file"
accept="application/pdf"
[disabled]="providingDocuments()"
(change)="onFileSelected($event)"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="providingDocuments() || !selectedFile()"
(click)="provideDocuments()"
>
Documenten aanleveren
</button>
}
@if (withdrawFailed()) {
<p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
@@ -20,24 +20,17 @@ class FakeAuth extends AuthService {
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,
},
},
],
@@ -60,21 +53,6 @@ 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 });
@@ -102,46 +80,6 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
const { provideDocuments, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
// Choose the file, then upload it.
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
expect(provideDocuments).toHaveBeenCalledWith(
'reg-9',
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
);
});
it('surfaces a diploma-upload failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
@@ -1,5 +1,5 @@
import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
@@ -8,16 +8,13 @@ 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 implements OnInit {
export class RegistrationPage {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
@@ -29,27 +26,6 @@ export class RegistrationPage implements OnInit {
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
* current open registration, or 204 (empty body) when there is none — in which case we show the
* submit form as before. Failures are non-fatal for the same reason. */
ngOnInit(): void {
this.bff.getSelfServiceRegistrations().subscribe({
next: (current: CurrentRegistration | void) => {
if (current && current.registrationId) {
this.reference.set(current.registrationId);
this.submitted.set(true);
}
},
error: () => {
// No resumable registration (or the lookup failed) — fall back to the submit form.
},
});
}
submit(): void {
this.submitting.set(true);
@@ -68,46 +44,6 @@ export class RegistrationPage implements OnInit {
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.selectedFile.set(input.files?.[0] ?? undefined);
}
async provideDocuments(): Promise<void> {
const reference = this.reference();
const file = this.selectedFile();
if (!reference || !file) {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
let contentBase64: string;
try {
contentBase64 = await readAsBase64(file);
} catch {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
return;
}
this.bff
.postSelfServiceRegistrationsIdDocuments(reference, {
contentBase64,
fileName: file.name,
contentType: file.type || 'application/pdf',
})
.subscribe({
next: () => {
this.documentsProvided.set(true);
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
}
withdraw(): void {
const reference = this.reference();
if (!reference) {
@@ -128,13 +64,3 @@ export class RegistrationPage implements OnInit {
});
}
}
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
function readAsBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
reader.readAsDataURL(file);
});
}
+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~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
- Production-grade Helm chart (sketch only).
- Multi-tenancy.
- Real outbound notifications (email/SMS) — logged to console in v1.
@@ -1,77 +0,0 @@
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
pattern on `Beoordelen`).
## Context
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
Two forces shape this.
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
escalation happens alongside it.
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
(§8.2)" posture since ADR-0009.
## Decision
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
behandelaar group to teamlead.**
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
belongs to the teamlead; its history and variables are untouched.
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
`OpenZaakAanmaken` worker.
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
## Consequences
**Positive**
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
timer.
**Negative / costs**
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
on redelivery.
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
## Alternatives considered
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
surface to the engine for no capability the external-worker route lacks.
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
@@ -1,77 +0,0 @@
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
## Context
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
governing the registration workflow" (Flowable as a peer orchestration module).
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
back. That is one option; it is not the only one, and it is not the cleanest.
## Decision
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
only new job is to carry the diploma origin and pass it into the process as a start variable.**
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
`route` (`Buitenlands``CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
never evaluates the DMN and never learns the route — that is the process's concern.
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
— the resource is stored but no decision is created, so the service task fails at runtime with
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
With the decision present in the DMN repository, the process's DMN service task resolves it across
deployments by key (verified live), so no shared parent deployment id is needed.
## Consequences
**Positive**
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
framing); business users can read/adjust the decision table without touching domain code.
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
- The domain change is minimal and additive: one value on the aggregate, one start variable.
**Negative / costs**
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
cover only that the origin is carried into the process.
## Alternatives considered
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
the domain to Flowable for a decision and splits the routing across two places (domain computes,
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
@@ -1,90 +0,0 @@
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
## Context
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
aggregate stays in sync.
## Decision
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
between the gateway and Beoordelen.
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
without persisting again; an unknown registration throws so the job is redelivered.
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
behind the same action.
## Consequences
**Positive**
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
worker trio (worker + processor + pump), one Workflow Client method.
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
introduced in S-10a.
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
**Negative / costs**
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
through the process.
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
## Alternatives considered
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
status — the case would still look open.
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
@@ -1,74 +0,0 @@
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
expiry to #106 (S-10c).
## Context
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
## Decision
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
zaaktype.**
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers**
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
ZGW `inhoud`.
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
(a zeros-uuid placeholder otherwise, so the ACL still boots).
## Consequences
**Positive**
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
default, one seed block).
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
Playwright journey uploads a real PDF).
**Negative / costs**
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
diploma, but not a pattern to reuse for large documents without streaming/multipart.
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
## Alternatives considered
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
client, for no benefit at diploma size.
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
talks to ZGW).
@@ -1,81 +0,0 @@
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
- **Status:** Accepted
- **Date:** 2026-07-21
- **Deciders:** Respellion engineering
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
## Context
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
(§8.1, the only code that talks to ZGW).
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
## Decision
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
(name) rather than by position or the eindstatus flag alone.**
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
zaaktype's single `selectielijstProcestype` so they validate on publish.
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
registration that lapsed before its zaak was opened has nothing to cancel.
## Consequences
**Positive**
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
the change is additive and §8 stays clean (only the ACL talks to ZGW).
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
cancelled end-to-end.
**Negative / costs**
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
`Vervallen` resultaat) is a possible follow-up.
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
is documented in the gateway.
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
## Alternatives considered
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
the approval eindstatus resolution.
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
@@ -1,92 +0,0 @@
# 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`.
@@ -1,67 +0,0 @@
# 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.
@@ -1,79 +0,0 @@
# 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.
@@ -1,74 +0,0 @@
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-16a (#122), first of the S-16 (#17) split
## Context
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
backplane must stand up first: a local, CI-friendly place for traces and metrics to
land, viewable in one UI, reaching green health within the 3-minute compose budget.
Two shape decisions are non-obvious enough to record.
## Decision
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
and ship the config baked into small built images.**
### Trace backend: Tempo (not Jaeger)
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
add a second UI and a second mental model for no benefit at this scale.
### No OTLP collector
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
If we later need fan-out, tail sampling, or log processing, a collector is an
additive change — the services already speak OTLP.
### Config baked into built images, not config volumes
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
**verbatim** images and get their config streamed into external named volumes by
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
are **not** peer modules we must run verbatim, so we take the simpler path: a
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
and no Makefile sprawl.
### Verified, not assumed
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
method) — so the check proves the datasources are actually wired, not merely that
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
itself, so no in-image healthcheck tool is required.
## Consequences
**Positive**
- One UI for traces + metrics + (future) logs. Config is versioned in
`infra/observability/` and self-contained in the images.
- Backplane is independent of app instrumentation — #123 and #124 build on it.
**Negative / costs**
- Three more images built each CI run (kept small; not on the health-gate list).
- Storage is ephemeral container fs — a demo backplane, not a retention target.
Object storage for Tempo / remote-write for Prometheus is a later concern.
## Coupling rules touched (CLAUDE.md §8)
None. The stack is passive infrastructure: services *push* OTLP and *expose*
`/metrics`; nothing in the stack calls into a service or a peer module.
@@ -1,53 +0,0 @@
# 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.
@@ -1,58 +0,0 @@
# 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.
@@ -1,61 +0,0 @@
# 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.
@@ -1,81 +0,0 @@
# 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.
@@ -1,174 +0,0 @@
# 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.
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
## 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
(S-19b, #150).
**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.
## 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.
-514
View File
@@ -5,336 +5,6 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## 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 → "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 → 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); 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
@@ -628,187 +298,3 @@ interrupting boundary event ends it → the werkbak drops the case.
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
> `Een registratie intrekken` acceptance scenarios and the domain live check.
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
its identity throughout — only who may claim it changes.
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
verify-domain check automates):
```bash
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
# but NOT claimed.
#
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
FL=http://localhost:8090/flowable-rest/service
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
-H 'Content-Type: application/json' -d '{"action":"move"}'
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
-H 'Content-Type: application/json' -d '{"action":"execute"}'
#
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
```
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen``BeoordelingEscaleren`
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
candidate group behandelaar → teamlead (§8.2).
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
> and reassignment are asserted live by the verify-domain check.
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
A registration's diploma origin decides its route. A DMN service task in the registratie
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
DMN, not in code — a beheerder can read and adjust the decision table directly.
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
the domain directly, so the demo drives it through the domain endpoint:
```bash
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
DOM=http://localhost:8080 # domain service
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
#
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
FL=http://localhost:8090/flowable-rest/service
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
#
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
-H 'Content-Type: application/json' -d '{"action":"complete"}'
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
```
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
domestic: `Beoordelen` directly (§8.2, ADR-0016).
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
> foreign→CBGV routing are asserted live by the verify-domain check.
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
for the citizen's documents (their diploma). Two things can happen:
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
routing (S-13) → beoordeling.
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
**VERLOPEN** (the case is cancelled).
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
demonstrated by firing the 30-day timer early via the management API.
```bash
DOM=http://localhost:8080 # domain service
FL=http://localhost:8090/flowable-rest/service # flowable-rest
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
-H 'Content-Type: application/json' -d '{"action":"complete"}'
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
-H 'Content-Type: application/json' -d '{"action":"move"}'
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
```
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
→ domain worker expires the aggregate to `Verlopen``endVerlopen` (§8.2, ADR-0017).
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
> verify-domain check.
## S-10b — Diploma upload stored in the ZGW Documenten API (#103, ADR-0018)
The self-service "Documenten aanleveren" action (S-10a) is now a **real file upload**: after submitting,
the citizen picks a PDF and uploads it. The portal base64-encodes the file client-side and posts it to
the BFF; the BFF forwards it to the domain, which stores it via the **ACL** as a ZGW
`enkelvoudiginformatieobject` in the **Documenten (DRC) API** and relates it to the zaak — then completes
the `WachtOpDocumenten` wait so beoordeling can proceed. Per §8.1 only the ACL talks to ZGW.
```bash
make up
# 1. Log in as jan-burger / test123, submit, then — once the openbaar register shows the row —
# choose a PDF under "Documenten aanleveren" and upload it. The page confirms "aangeleverd".
open http://localhost:8140
#
# 2. Automated: the walking-skeleton e2e now uploads a real PDF before the behandelaar approves.
make verify-e2e
#
# 3. The ACL integration test proves the document is really created in the Documenten API and
# related to the zaak (against a live OpenZaak):
make verify-acl # → "Storing a diploma creates a real informatieobject related to the zaak"
```
**The path:** portal (base64) → BFF `POST /self-service/registrations/{id}/documents` → domain
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen`
it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set
to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the
approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019).
```bash
# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat
# against a live OpenZaak:
make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat"
#
# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts
# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd):
make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
```
**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
job for redelivery rather than diverging the two (ADR-0019).
-49
View File
@@ -196,52 +196,3 @@ 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.
-1
View File
@@ -14,7 +14,6 @@ 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` |
+8 -251
View File
@@ -1,12 +1,7 @@
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# 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
@@ -56,10 +51,6 @@ 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
@@ -142,9 +133,6 @@ 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
@@ -269,87 +257,38 @@ 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
- |
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"
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"
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"
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
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/ # placeholder; seed-env/acl.env supplies the IP base
Acl__OpenZaak__BaseUrl: http://openzaak: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 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.
Acl__Objecten__BaseUrl: http://objecten:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
ports:
- "8100:8080"
volumes:
- seed-env:/seed:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
@@ -359,8 +298,6 @@ services:
depends_on:
openzaak:
condition: service_healthy
local-seed:
condition: service_completed_successfully
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
@@ -463,31 +400,6 @@ 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: ..
@@ -575,166 +487,11 @@ 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"
# S-19a: Objecten refuses every write while its Notificaties config is absent
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
# notifications off rather than fake a delivery path that silently drops every message.
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
NOTIFICATIONS_DISABLED: "true"
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]
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:
+14 -315
View File
@@ -15,12 +15,12 @@
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
#
# 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
# 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
services:
@@ -51,12 +51,6 @@ 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
@@ -141,9 +135,6 @@ 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
@@ -268,30 +259,19 @@ services:
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
# infra/seed-config.sh.
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
- 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
# 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 process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
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"
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"
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
depends_on:
flowable-rest:
@@ -305,10 +285,6 @@ 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/}
@@ -317,20 +293,8 @@ services:
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# 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.
Acl__Objecten__BaseUrl: http://objecten:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
# 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}
ports:
- "8100:8080"
healthcheck:
@@ -355,10 +319,6 @@ 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
@@ -385,10 +345,6 @@ 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.
@@ -397,8 +353,6 @@ 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:
@@ -443,10 +397,6 @@ 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).
@@ -476,10 +426,6 @@ 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"
@@ -563,249 +509,11 @@ services:
condition: service_started
networks: [cg]
# The beheer portal: nginx 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: nginx listens on IPv4 only, but localhost resolves to ::1 first.
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"
# S-19a: Objecten refuses every write while its Notificaties config is absent
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
# notifications off rather than fake a delivery path that silently drops every message.
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
NOTIFICATIONS_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:
- 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]
# ── 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.
@@ -821,15 +529,6 @@ 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 -19
View File
@@ -35,35 +35,24 @@ services:
condition: service_healthy
networks: [cg]
# Deploys registratie.bpmn (process engine) and diploma-eligibility.dmn (DMN engine) via the REST
# API once flowable-rest is up. Idempotent: skips each if already deployed.
# Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
# Idempotent: skips if a deployment named "registratie" already exists.
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
# infra/seed-config.sh.
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
- 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
# 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 process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
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"
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"
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
depends_on:
flowable-rest:
-30
View File
@@ -38,36 +38,6 @@
"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"] }
}
]
}
+1 -12
View File
@@ -5,8 +5,7 @@
"roles": {
"realm": [
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
{ "name": "teamlead", "description": "Teamleider behandeling" },
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
{ "name": "teamlead", "description": "Teamleider behandeling" }
]
},
"clients": [
@@ -55,16 +54,6 @@
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"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 }],
"realmRoles": ["beheerder"]
}
]
}
-78
View File
@@ -1,78 +0,0 @@
#!/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 `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
the test zaak it also creates).
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")
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"):
if ab.get("callbackUrl") == callback:
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": "zaken", "filters": {}}]})
if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement registered: {ab['url']} -> {callback}")
if __name__ == "__main__":
main()
-33
View File
@@ -1,33 +0,0 @@
#!/bin/sh
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
#
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
#
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
set -eu
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
OZ_BASE="http://${oz_ip}:8000"
export OZ_BASE OZ_PUBLISH=1
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
out="$(python3 /work/seed_catalogus.py)"
echo "$out"
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
cat > /out/acl.env <<EOF
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
EOF
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""S-16c (#124): prove the golden-signal metrics pipeline works end to end.
Generate anonymous BFF traffic (GET /openbaar/register — no auth, no OpenZaak egress),
then query Prometheus and assert (1) every .NET service's scrape target is UP, and (2)
the http.server.request.duration histogram is actually being scraped — i.e. the services
expose /metrics AND Prometheus collects it, which is exactly what the golden-signal
dashboard reads.
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BFF = os.environ["BFF"] # http://<bff-ip>:8080
PROM = os.environ["PROMETHEUS"] # http://<prometheus-ip>:9090
TIMEOUT = int(os.environ.get("METRICS_TIMEOUT", "90"))
SERVICES = {"acl", "domain", "bff", "event-subscriber", "projection-api"}
def _get(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read()
def generate_traffic():
for _ in range(3):
try:
_get(f"{BFF}/openbaar/register")
except urllib.error.HTTPError:
pass # a non-2xx still records an http.server metric
def query(promql):
q = urllib.parse.quote(promql)
try:
data = json.loads(_get(f"{PROM}/api/v1/query?query={q}"))
except Exception:
return []
return data.get("data", {}).get("result", [])
def jobs_up():
return {r["metric"].get("job") for r in query("up == 1")}
def jobs_with_request_metric():
return {r["metric"].get("job")
for r in query("http_server_request_duration_seconds_count")}
def main():
deadline = time.time() + TIMEOUT
while time.time() < deadline:
generate_traffic()
up = jobs_up()
scraped = jobs_with_request_metric()
if SERVICES.issubset(up) and SERVICES.issubset(scraped):
print(f"OK — targets up: {sorted(up & SERVICES)}; "
f"request metric scraped from: {sorted(scraped & SERVICES)}")
return 0
time.sleep(3)
print(f"FAIL — up: {sorted(jobs_up() & SERVICES)}; "
f"request metric from: {sorted(jobs_with_request_metric() & SERVICES)}; "
f"expected all of {sorted(SERVICES)}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""S-18b (#140): prove the Objecten API is up and its static token authenticates.
Assert an unauthenticated call to /api/v2/objects is 401 and an authenticated one (the seeded dev
token) is 200 — i.e. the service migrated, booted, and setup_configuration provisioned the token
and the Objecttypen service it trusts. Stdlib only so it runs in a bare python:3-slim container on
the compose network.
"""
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTEN_TOKEN"]
TIMEOUT = int(os.environ.get("OBJECTEN_TIMEOUT", "60"))
def status(url, token=None):
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Token {token}")
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def main():
url = f"{BASE}/api/v2/objects"
deadline = time.time() + TIMEOUT
while time.time() < deadline:
unauth = status(url)
authed = status(url, TOKEN)
if unauth == 401 and authed == 200:
print(f"OK — {url}: no-auth {unauth}, token {authed}")
return 0
time.sleep(3)
print(f"FAIL — {url}: expected no-auth 401 + token 200, got {status(url)} / {status(url, TOKEN)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -1,42 +0,0 @@
# Objecten API setup_configuration (S-18b). Streamed into the external rr-objecten-config volume by
# infra/seed-config.sh and applied by objecten-init (RUN_SETUP_CONFIG). Declarative + idempotent.
#
# Two things: (1) register the Objecttypen API (S-18a) as a trusted service so an object can
# reference its objecttype — authenticating with the dev static token Objecttypen provisioned; and
# (2) a dev static token so peers (the ACL, S-19) can write objects here. Dev-only, not for prod.
# (1) Trust the Objecttypen API. `orc` = overige RESTful component (how zgw_consumers classifies the
# Objecttypen API). The RegisterRecord objecttype (S-18c) will reference an objecttype under this
# service by uuid.
zgw_consumers_config_enable: true
zgw_consumers:
services:
- identifier: objecttypen
label: Objecttypen API
api_type: orc
api_root: http://objecttypen:8000/api/v2/
auth_type: api_key
header_key: Authorization
header_value: Token 0123456789abcdef0123456789abcdef01234567
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid
# instead of letting Objecttypen assign one. Keep the two in step.
objecttypes_config_enable: true
objecttypes:
items:
- uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01
name: RegisterRecord
service_identifier: objecttypen
# (3) Static API token peers use to write/read objects.
tokenauth_config_enable: true
tokenauth:
items:
- identifier: register-referentie
token: 1234567890abcdef1234567890abcdef12345678
contact_person: Register Referentie
email: admin@localhost
organization: Respellion
is_superuser: true
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
"""S-18a (#139): prove the Objecttypen API is up and its static token authenticates.
Assert an unauthenticated call to /api/v2/objecttypes is 401 and an authenticated one (the seeded
dev token) is 200 — i.e. the service migrated, booted, and setup_configuration provisioned the token.
Stdlib only so it runs in a bare python:3-slim container on the compose network.
"""
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
TIMEOUT = int(os.environ.get("OBJECTTYPEN_TIMEOUT", "60"))
def status(url, token=None):
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Token {token}")
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def main():
url = f"{BASE}/api/v2/objecttypes"
deadline = time.time() + TIMEOUT
while time.time() < deadline:
unauth = status(url)
authed = status(url, TOKEN)
if unauth == 401 and authed == 200:
print(f"OK — {url}: no-auth {unauth}, token {authed}")
return 0
time.sleep(3)
print(f"FAIL — {url}: expected no-auth 401 + token 200, got {status(url)} / {status(url, TOKEN)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -1,77 +0,0 @@
#!/usr/bin/env python3
"""S-18c (#141): register the RegisterRecord objecttype + a published version in the Objecttypen API.
Run by the `registerrecord-init` compose one-shot once Objecttypen is healthy. The Objecttypen API's
setup_configuration (3.4.2) can only provision tokens — it has no declarative objecttype step — so
the objecttype is created over the API here (the ADR-0020 self-seed pattern), idempotently: if a
"RegisterRecord" objecttype with a published version already exists, it's a no-op. Stdlib only.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/")
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json")
NAME = "RegisterRecord"
# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose
# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static
# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead
# of threading a seed-time value between two containers. See infra/objecten/setup_configuration.
UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01"
def api(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}{path}", data=data, method=method,
headers={"Authorization": f"Token {TOKEN}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r) if r.length != 0 else {}
def wait_ready():
"""Objecttypen depends_on health already, but tolerate a slow first request."""
for _ in range(20):
try:
api("GET", "/api/v2/objecttypes")
return
except (urllib.error.URLError, ConnectionError, TimeoutError):
time.sleep(3)
api("GET", "/api/v2/objecttypes") # last try, let it raise
def main():
schema = json.load(open(SCHEMA_PATH))
wait_ready()
existing = next(
(o for o in api("GET", "/api/v2/objecttypes").get("results", []) if o.get("name") == NAME),
None,
)
if existing and existing.get("versions"):
print(f"RegisterRecord already registered ({len(existing['versions'])} version(s)) — no-op")
return 0
ot = existing or api("POST", "/api/v2/objecttypes", {
"uuid": UUID,
"name": NAME,
"namePlural": "RegisterRecords",
"description": schema.get("description", ""),
"dataClassification": "open", # public-safe: the openbaar register may show it
})
uuid = ot["uuid"]
ver = api("POST", f"/api/v2/objecttypes/{uuid}/versions", {
"status": "published",
"jsonSchema": schema,
})
print(f"registered RegisterRecord {uuid} v{ver.get('version')} ({ver.get('status')})")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,23 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RegisterRecord",
"description": "Public-safe register entry shown in the openbaar (public) register. Mirrors the BFF's OpenbaarEntry (services/bff/Bff.Api/DownstreamClients.cs) — deliberately NO bsn or naam. S-19 (#20) writes records against this schema in the Objecten API on approval. See ADR-0027.",
"type": "object",
"additionalProperties": false,
"required": ["id", "status"],
"properties": {
"id": {
"type": "string",
"description": "Zaak id — the register entry's stable primary key (the projection key)."
},
"status": {
"type": "string",
"enum": ["INGEDIEND", "INGESCHREVEN"],
"description": "Registration lifecycle status (RegistrationStatus)."
},
"reference": {
"type": ["string", "null"],
"description": "Citizen-facing zaak identificatie shown publicly (ADR-0012)."
}
}
}
@@ -1,11 +0,0 @@
# Objecttypen API setup_configuration (S-18a). Streamed into the external rr-objecttypen-config
# volume by infra/seed-config.sh and applied by objecttypen-init (RUN_SETUP_CONFIG). Declarative +
# idempotent. Dev-only static token so peers (Objecten S-18b, the ACL) can authenticate.
tokenauth_config_enable: true
tokenauth:
items:
- identifier: register-referentie
token: 0123456789abcdef0123456789abcdef01234567
contact_person: Register Referentie
email: admin@localhost
organization: Respellion
-4
View File
@@ -1,4 +0,0 @@
# Grafana with datasources + the golden-signals dashboard baked in via provisioning
# (S-16a/S-16c, ADR-0023). Everything under provisioning/ is copied in below.
FROM grafana/grafana:11.3.0
COPY provisioning/ /etc/grafana/provisioning/
@@ -1,13 +0,0 @@
# Dashboard provider (S-16c, ADR-0023): Grafana loads every *.json in this folder as a
# read-only, code-owned dashboard. The golden-signals board is versioned here, not
# clicked together in the UI.
apiVersion: 1
providers:
- name: register-referentie
type: file
disableDeletion: true
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false
@@ -1,87 +0,0 @@
{
"uid": "golden-signals",
"title": "Request path — golden signals",
"tags": ["s-16c", "golden-signals"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"editable": true,
"refresh": "10s",
"time": { "from": "now-15m", "to": "now" },
"templating": {
"list": [
{
"name": "job",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(http_server_request_duration_seconds_count, job)",
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" },
"refresh": 2
}
]
},
"panels": [
{
"id": 1,
"title": "Traffic — requests/sec",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
},
{
"id": 2,
"title": "Errors — 5xx responses/sec",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 }, "color": { "mode": "fixed", "fixedColor": "red" } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\",http_response_status_code=~\"5..\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
},
{
"id": 3,
"title": "Latency — p95 request duration",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (job, le) (rate(http_server_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))",
"legendFormat": "{{job}} p95"
}
]
},
{
"id": 4,
"title": "Saturation — CPU cores in use",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"fieldConfig": { "defaults": { "unit": "none", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(dotnet_process_cpu_time_seconds_total{job=~\"$job\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
}
]
}
@@ -1,17 +0,0 @@
# Auto-provisioned datasources (S-16a, ADR-0023). Fixed uids so dashboards (S-16c)
# and the verify-observability check can reference them by a stable id.
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
@@ -1,2 +0,0 @@
FROM prom/prometheus:v2.55.1
COPY prometheus.yml /etc/prometheus/prometheus.yml
@@ -1,27 +0,0 @@
# Prometheus scrape config (S-16c, ADR-0023). Each .NET service exposes OTel metrics
# at /metrics (Prometheus text format); one scrape job per service, so the service is
# identified by the `job` label in the golden-signal dashboard. Targets are reached by
# compose service name on the shared `cg` network (internal port 8080).
global:
scrape_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
- job_name: acl
static_configs:
- targets: ['acl:8080']
- job_name: domain
static_configs:
- targets: ['domain:8080']
- job_name: bff
static_configs:
- targets: ['bff:8080']
- job_name: event-subscriber
static_configs:
- targets: ['event-subscriber:8080']
- job_name: projection-api
static_configs:
- targets: ['projection-api:8080']
-4
View File
@@ -1,4 +0,0 @@
# Tempo with our config baked in — so it reaches sibling containers on the CI
# runner without the external-config-volume dance the upstream CG images need.
FROM grafana/tempo:2.6.1
COPY tempo.yaml /etc/tempo.yaml
-27
View File
@@ -1,27 +0,0 @@
# Grafana Tempo — single-binary, all-in-one, local storage (S-16a, ADR-0023).
# Ingests OTLP directly (services export straight to Tempo; no collector hop).
# Storage is ephemeral container fs — this is a local/CI demo backplane, not a
# retention target. ponytail: local backend, swap for object storage if traces
# must outlive the stack.
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
storage:
trace:
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
+17 -100
View File
@@ -10,7 +10,7 @@ Creates (if absent):
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
"""
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
@@ -77,12 +77,8 @@ def publish_zaaktype(zt):
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
"""
# Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between
# them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld
# eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a
# stale local stack must reset its OpenZaak volumes for the renumbering to take effect.
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]:
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
if volgnummer not in have_st:
st, body = api("POST", "/statustypen", {
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
@@ -99,42 +95,25 @@ def publish_zaaktype(zt):
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
print("create roltype Aanvrager")
# Two resultaattypen, keyed by omschrijving so each is created independently (idempotent):
# "Geregistreerd" — the approval outcome (S-09b)
# "Vervallen" — the document-timeout cancellation outcome (S-10c)
# Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two
# Selectielijst resultaten from a single procestype and set that procestype on the zaaktype.
have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")}
wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")]
if all(naam in have_rt for naam, _ in wanted):
print("skip resultaattypen Geregistreerd + Vervallen")
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
print("skip resultaattype Geregistreerd")
else:
# Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so
# both klassen validate against the zaaktype's selectielijstProcestype.
procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"]
resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"]
if len(resultaten) < len(wanted):
sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen")
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"]
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype})
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
# The selectielijstklasse and the zaaktype must share a procestype.
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
if st != 200:
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
for i, (naam, archiefnominatie) in enumerate(wanted):
if naam in have_rt:
print(f"skip resultaattype {naam}")
continue
st, body = api("POST", "/resultaattypen", {
"zaaktype": zt["url"], "omschrijving": naam,
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
"archiefnominatie": archiefnominatie,
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
})
if st != 201:
sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
print(f"create resultaattype {naam}")
st, body = api("POST", "/resultaattypen", {
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
"archiefnominatie": "blijvend_bewaren",
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
})
if st != 201:
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
print("create resultaattype Geregistreerd")
if zt.get("concept", True):
st, body = api("POST", f"{zt['url']}/publish")
@@ -145,58 +124,6 @@ def publish_zaaktype(zt):
print("skip publish (already published)")
def seed_informatieobjecttype(cat, zt):
"""Create the "Diploma" informatieobjecttype and relate it to the zaaktype (both idempotent).
A diploma uploaded in S-10b is filed under this informatieobjecttype; OpenZaak only accepts a
document (and its zaak relation) once the informatieobjecttype is published AND allowed for the
zaak's zaaktype (a zaaktype-informatieobjecttype relation). Both the relation and this call must run
while the zaaktype is still a concept, so seed this *before* publishing the zaaktype. Returns the
informatieobjecttype dict.
"""
iots = [i for i in find(f"/informatieobjecttypen?catalogus={cat['url']}&status=alles")
if i.get("omschrijving") == "Diploma"]
if iots:
iot = iots[0]
print(f"skip informatieobjecttype Diploma ({iot['url']}) concept={iot.get('concept')}")
else:
st, iot = api("POST", "/informatieobjecttypen", {
"catalogus": cat["url"],
"omschrijving": "Diploma",
"vertrouwelijkheidaanduiding": "openbaar",
"informatieobjectcategorie": "diploma",
"beginGeldigheid": "2026-01-01",
})
if st != 201:
sys.exit(f"create informatieobjecttype -> {st}: {json.dumps(iot, indent=2)}")
print(f"create informatieobjecttype Diploma ({iot['url']})")
# Relate it to the zaaktype (must be done while both are concept).
relations = find(f"/zaaktype-informatieobjecttypen?zaaktype={zt['url']}&status=alles")
if any(r.get("informatieobjecttype") == iot["url"] for r in relations):
print("skip zaaktype-informatieobjecttype Diploma")
else:
st, body = api("POST", "/zaaktype-informatieobjecttypen", {
"zaaktype": zt["url"], "informatieobjecttype": iot["url"],
"volgnummer": 1, "richting": "inkomend"})
if st != 201:
sys.exit(f"relate zaaktype-informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
print("create zaaktype-informatieobjecttype Diploma")
return iot
def publish_informatieobjecttype(iot):
"""Publish the informatieobjecttype (idempotent) so documents may reference it."""
if iot.get("concept", True):
st, body = api("POST", f"{iot['url']}/publish")
if st != 200:
sys.exit(f"publish informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
print(f"publish informatieobjecttype Diploma ({iot['url']})")
else:
print("skip publish informatieobjecttype (already published)")
def main():
# 1. Catalogus
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
@@ -271,16 +198,10 @@ def main():
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
# those relations and publish — needed so a real zaak POST is accepted, which
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
iot = None
if PUBLISH:
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
if z.get("identificatie") == "BIG-REGISTRATIE")
# Seed + relate the Diploma informatieobjecttype (S-10b) while the zaaktype is still concept,
# then publish both. Publish the informatieobjecttype before the zaaktype so the zaaktype's
# relations reference a published type.
iot = seed_informatieobjecttype(cat, zt)
publish_informatieobjecttype(iot)
publish_zaaktype(zt)
# 5. Verify the JWT client can list the zaaktype (concepts included).
@@ -293,10 +214,6 @@ def main():
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
print(f"ZAAKTYPE_URL {zt_url}")
# Machine-readable informatieobjecttype URL (S-10b) so callers can configure the ACL's document
# default-fill. Only emitted when publishing — a concept informatieobjecttype can't back a document.
if iot is not None:
print(f"INFORMATIEOBJECTTYPE_URL {iot['url']}")
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env python3
"""Render a per-spec table from a Playwright JSON report for a Gitea job summary (#136).
Reads the JSON report (default: tests/e2e/playwright-report.json) that run-e2e-check.sh copies out
of the e2e container, and prints a markdown table (one row per spec) to stdout. The CI step
redirects it into $GITHUB_STEP_SUMMARY. Stdlib only.
"""
import json
import os
import sys
STATUS_ICON = {"expected": "", "unexpected": "", "skipped": "⏭️", "flaky": "⚠️"}
def walk(suite, out):
for spec in suite.get("specs", []):
# A spec's status is carried on its test(s): expected/unexpected/skipped/flaky.
statuses = [t.get("status") for t in spec.get("tests", [])]
status = ("unexpected" if "unexpected" in statuses
else "flaky" if "flaky" in statuses
else "skipped" if statuses and all(s == "skipped" for s in statuses)
else "expected" if spec.get("ok", False)
else "unexpected")
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
"title": spec.get("title", ""), "status": status})
for child in suite.get("suites", []):
walk(child, out)
def main(path):
if not os.path.exists(path):
print("## 🎭 e2e (Playwright)\n\n_No e2e report — the run did not reach the e2e step._")
return 0
with open(path) as fh:
report = json.load(fh)
specs = []
for suite in report.get("suites", []):
walk(suite, specs)
print("## 🎭 e2e (Playwright)\n")
stats = report.get("stats", {})
if stats:
print(f"**{stats.get('expected', 0)} passed · {stats.get('unexpected', 0)} failed · "
f"{stats.get('flaky', 0)} flaky · {stats.get('skipped', 0)} skipped** "
f"({round(stats.get('duration', 0) / 1000)}s)\n")
if not specs:
print("_No specs ran._")
return 0
print("| Spec | Result |")
print("| ---- | :----: |")
for s in specs:
print(f"| {s['file']} {s['title']} | {STATUS_ICON.get(s['status'], '')} |")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tests/e2e/playwright-report.json"))
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""S-18c (#141): prove the RegisterRecord objecttype is registered + published in the Objecttypen API.
Assert the objecttype named "RegisterRecord" exists, has a **published** version, and that version's
jsonSchema carries the public-safe fields (id, status, reference) i.e. registerrecord-init ran and
seeded the schema S-19 will write records against. Stdlib only so it runs in a bare python:3-slim
container on the compose network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
TIMEOUT = int(os.environ.get("REGISTERRECORD_TIMEOUT", "60"))
NAME = "RegisterRecord"
EXPECTED_FIELDS = {"id", "status", "reference"}
def get(path):
req = urllib.request.Request(f"{BASE}{path}", headers={"Authorization": f"Token {TOKEN}"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
def check():
"""Return (ok, detail). Raises on transport errors so the caller can retry."""
ots = get("/api/v2/objecttypes").get("results", [])
match = next((o for o in ots if o.get("name") == NAME), None)
if not match:
return False, f"no objecttype named {NAME!r} (have: {[o.get('name') for o in ots]})"
if not match.get("versions"):
return False, f"{NAME} exists but has no versions"
# The versions list holds URLs; fetch each to find a published one.
for ver_url in match["versions"]:
ver = get(ver_url[len(BASE):] if ver_url.startswith(BASE) else ver_url)
if ver.get("status") != "published":
continue
props = set((ver.get("jsonSchema") or {}).get("properties", {}))
if not EXPECTED_FIELDS <= props:
return False, f"published version missing fields: {EXPECTED_FIELDS - props}"
return True, f"{NAME} v{ver.get('version')} published, fields={sorted(props)}"
return False, f"{NAME} has versions but none are published"
def main():
deadline = time.time() + TIMEOUT
detail = "no attempt"
while time.time() < deadline:
try:
ok, detail = check()
if ok:
print(f"OK — {detail}")
return 0
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
detail = f"transport: {e}"
time.sleep(3)
print(f"FAIL — {detail}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+6 -245
View File
@@ -30,18 +30,16 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
oz_base="http://$oz_ip:8000"
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
seed_out="$(docker start -a "$sid")"
zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
docker rm -f "$sid" >/dev/null
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
echo ">> zaaktype: $zt_url"
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
# (OpenZaak rejects a single-label host on zaak-create).
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)"
ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
echo ">> submitting a registration to the domain"
@@ -95,27 +93,6 @@ print(next((t['id'] for t in (d.get('data') or [])
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
# is exercised separately at the end.
complete_wacht() { # reg_id
local rid="$1" wid="" r
for _ in $(seq 1 30); do
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
[ -n "$wid" ] && break
sleep 2
done
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> completed WachtOpDocumenten for $rid (documents received)"
}
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
complete_wacht "$reg_id"
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id=""
@@ -142,7 +119,6 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
@@ -154,7 +130,6 @@ loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
complete_wacht "$reg_id2"
echo ">> polling Flowable for its Beoordelen task"
task_id2=""
@@ -182,218 +157,4 @@ for _ in $(seq 1 15); do
done
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the
# extra CBGVAdvies user task before Beoordelen (the DMN service task sets route=CBGV_ADVIES and the
# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration
# above, which parked straight at Beoordelen. ──────────────────────────────────────────────────────
cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}'
echo ">> submitting a registration with a foreign diploma"
locf="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
reg_idf="${locf##*/}"
echo ">> foreign registration $reg_idf"
complete_wacht "$reg_idf"
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
cbgv_task=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)"
cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -n "$cbgv_task" ] && break
sleep 2
done
[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> CBGVAdvies task $cbgv_task is waiting"
echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; }
echo ">> completing the CBGV-advies task"
flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> asserting it now advances to Beoordelen"
onward=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
[ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; }
sleep 2
done
[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; }
echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)"
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
echo ">> submitting a third registration to escalate"
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
reg_id3="${loc3##*/}"
echo ">> third registration $reg_id3"
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
t=next((t for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
candidate_groups() { python3 -c "import sys,json
try:
links=json.load(sys.stdin)
except Exception:
links=[]
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
# The first job id in a management jobs/timer-jobs response on stdin.
first_job_id() { python3 -c "import sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
print(((d.get('data') or [{}])[0]).get('id',''))"; }
complete_wacht "$reg_id3"
echo ">> polling Flowable for its Beoordelen task"
task_id3=""; pid3=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
[ -n "$task_id3" ] && break
sleep 2
done
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
echo ">> asserting the task starts out claimable by the behandelaar group"
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
echo ">> firing the 14-day boundary timer early via the management API"
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
if [ -n "$async_id" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
escalated=""
for _ in $(seq 1 30); do
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
[ "$groups" = "teamlead" ] && { escalated=1; break; }
sleep 2
done
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
# registration to VERLOPEN (ADR-0017). ─────────────────────────────────────────────────────────────
echo ">> submitting a registration to let its document term lapse"
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
reg_idv="${locv##*/}"
echo ">> timeout registration $reg_idv"
echo ">> polling Flowable for its WachtOpDocumenten task"
wacht_id=""; pidv=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
[ -n "$wacht_id" ] && break
sleep 2
done
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
echo ">> firing the 30-day document timer early via the management API"
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
if [ -n "$async_idv" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
verlopen=""
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
sleep 2
done
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN
# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with
# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack).
zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)"
[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; }
echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak"
read_zaak_status() {
# -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty).
docker run --rm -i --network "$net" \
-e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \
-e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \
python:3-slim python - "$1" <<'PY'
import base64, hashlib, hmac, json, os, sys, time, urllib.request
cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"]
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
def token():
hdr = {"alg": "HS256", "typ": "JWT"}
pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"}
seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode())
return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode()
def get(url):
req = urllib.request.Request(url, headers={
"Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
zaak = get(sys.argv[1])
status_url = zaak.get("status")
if not status_url:
print(""); sys.exit(0)
print(get(get(status_url)["statustype"]).get("omschrijving", ""))
PY
}
geannuleerd=""
for _ in $(seq 1 15); do
oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)"
[ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; }
sleep 2
done
[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
exit 0
+1 -5
View File
@@ -26,8 +26,4 @@ cid="$(docker create --network "$net" -w /e2e --ipc=host \
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
rc=0
docker start -a "$cid" || rc=$?
# Copy the Playwright JSON report out — regardless of pass/fail — for the CI job summary (#136).
docker cp "$cid:/e2e/playwright-report.json" "$root/tests/e2e/playwright-report.json" 2>/dev/null || true
exit $rc
docker start -a "$cid"
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
#
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
# fixes:
#
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
#
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
set -euo pipefail
DOM=${DOM:-http://localhost:8130} # domain
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
BSN=${BSN:-123456782}
# A minimal, valid PDF, base64-encoded (the diploma upload).
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
echo ">> 1. submit a registration (no manual seeding expected)"
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
id="${loc##*/}"
echo " accepted: $id"
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
zaak=""
for _ in $(seq 1 30); do
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
[ -n "$zaak" ] && break
sleep 3
done
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
echo " zaak opened: $zaak"
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
-H 'Content-Type: application/json' \
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
echo " documents accepted (204)"
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
in_werkbak=""
for _ in $(seq 1 20); do
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
[ "$in_werkbak" = "True" ] && break
sleep 3
done
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
echo " in the werkbak"
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
public=""
for _ in $(seq 1 30); do
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
[ "$public" = "True" ] && break
sleep 3
done
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
echo " visible in the openbaar register"
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
#
# S-16c (#124): assert the golden-signal metrics pipeline works — the .NET services expose
# /metrics and Prometheus scrapes them — against an ALREADY-RUNNING full stack. Runs the
# driver in a python:3-slim container on the stack network (services reached by container IP;
# the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Does NOT manage
# the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
prom="$(docker ps -q --filter 'name=[-_]prometheus[-_]' | head -1)"
[ -n "$bff" ] && [ -n "$prom" ] || { echo "ERROR: bff and/or prometheus not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"; prom_ip="$(ip "$prom")"
echo ">> network=$net bff=$bff_ip prometheus=$prom_ip"
cid="$(docker create --network "$net" \
-e "BFF=http://$bff_ip:8080" -e "PROMETHEUS=http://$prom_ip:9090" \
-e "METRICS_TIMEOUT=${METRICS_TIMEOUT:-90}" \
python:3-slim python /metrics-check.py)"
docker cp "$here/metrics-check.py" "$cid:/metrics-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
#
# S-18b (#140): assert the Objecten API is healthy + its static token authenticates, against an
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
# service is reached by container IP; the runner can't reach published ports — gitea-actions-gotchas.md
# §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecten/setup_configuration/data.yaml.
TOKEN="${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}"
ot="$(docker ps -q --filter 'name=objecten' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecten[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecten=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTEN=http://$ip:8000" -e "OBJECTEN_TOKEN=$TOKEN" \
-e "OBJECTEN_TIMEOUT=${OBJECTEN_TIMEOUT:-60}" \
python:3-slim python /objecten-check.py)"
docker cp "$here/objecten-check.py" "$cid:/objecten-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
#
# S-18a (#139): assert the Objecttypen API is healthy + its static token authenticates, against an
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
# service is reached by container IP; the runner can't reach published ports — gitea-actions-gotchas.md
# §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecttypen=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
-e "OBJECTTYPEN_TIMEOUT=${OBJECTTYPEN_TIMEOUT:-60}" \
python:3-slim python /objecttypen-check.py)"
docker cp "$here/objecttypen-check.py" "$cid:/objecttypen-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
#
# S-16a (#122): assert the observability backplane is live against an ALREADY-RUNNING
# stack. Runs curl INSIDE the compose network (like the other verify checks) because
# the stack's published ports aren't on the CI runner's localhost — the stack is a set
# of sibling containers on the host daemon. It asks Grafana to reach its provisioned
# datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's
# Grafana plugin implements no health method) — so it proves the datasources are wired,
# not merely that the containers started. Polls, so it tolerates a cold Grafana.
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown).
set -euo pipefail
TIMEOUT="${OBS_TIMEOUT:-60}"
AUTH="${GRAFANA_AUTH:-admin:admin}"
gf="$(docker ps -q --filter 'name=[-_]grafana[-_]' | head -1)"
[ -n "$gf" ] || { echo "ERROR: no running grafana container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$gf" | head -1)"
gf_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$gf")"
base="http://$gf_ip:3000"
echo ">> grafana=$gf_ip network=$net"
# Run curl inside a throwaway container on the stack network (reaches services by IP).
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
# poll <description> <grep -E pattern> <curl args...>
poll() {
local desc="$1" pat="$2"; shift 2
local deadline=$(( $(date +%s) + TIMEOUT ))
while :; do
if net_curl -fsS "$@" 2>/dev/null | grep -Eq "$pat"; then echo "$desc"; return 0; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "$desc ($*)" >&2; return 1; fi
sleep 3
done
}
echo "Checking observability backplane at $base ..."
poll "Grafana is healthy" \
'"database":[[:space:]]*"ok"' "$base/api/health"
poll "Prometheus datasource reachable" \
'"status":[[:space:]]*"OK"' -u "$AUTH" "$base/api/datasources/uid/prometheus/health"
poll "Tempo datasource reachable (via Grafana proxy)" \
'"version"' -u "$AUTH" "$base/api/datasources/proxy/uid/tempo/api/status/buildinfo"
echo "Observability backplane OK."
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
#
# S-18c (#141): assert the RegisterRecord objecttype is registered + published in the Objecttypen
# API, against an ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack
# network (the service is reached by container IP; the runner can't reach published ports —
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecttypen=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
-e "REGISTERRECORD_TIMEOUT=${REGISTERRECORD_TIMEOUT:-60}" \
python:3-slim python /registerrecord-check.py)"
docker cp "$here/registerrecord-check.py" "$cid:/registerrecord-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
#
# S-16b (#123): assert one connected distributed trace spans the .NET services in Tempo,
# against an ALREADY-RUNNING full stack. Runs the driver in a python:3-slim container on the
# stack network (services reached by container IP; the runner can't reach published ports —
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
tempo="$(docker ps -q --filter 'name=[-_]tempo[-_]' | head -1)"
[ -n "$bff" ] && [ -n "$tempo" ] || { echo "ERROR: bff and/or tempo not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"; tempo_ip="$(ip "$tempo")"
echo ">> network=$net bff=$bff_ip tempo=$tempo_ip"
cid="$(docker create --network "$net" \
-e "BFF=http://$bff_ip:8080" -e "TEMPO=http://$tempo_ip:3200" \
-e "TRACING_TIMEOUT=${TRACING_TIMEOUT:-90}" \
python:3-slim python /tracing-check.py)"
docker cp "$here/tracing-check.py" "$cid:/tracing-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+3 -15
View File
@@ -13,7 +13,7 @@
# subcommand. Fixed-name `external` volumes keep the names deterministic across
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
#
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten, registerrecord }
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, kc, fl }
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -33,26 +33,14 @@ populate() { # volume source(file or dir/.)
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|objecttypen|objecten|registerrecord> ..." >&2; exit 2; }
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
# does not cascade a .dmn bundled in a process .bar into the DMN engine, so we seed both raw files and
# let flowable-init deploy each via its own REST app. We stage them in a temp dir and copy its contents.
stage_flowable_workflows() {
local dir="$1"
cp "$here/../workflows/registratie.bpmn" "$here/../workflows/diploma-eligibility.dmn" "$dir/"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
for key in "$@"; do
case "$key" in
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
objecttypen) populate rr-objecttypen-config "$here/objecttypen/setup_configuration/." ;;
objecten) populate rr-objecten-config "$here/objecten/setup_configuration/." ;;
registerrecord) populate rr-registerrecord-config "$here/objecttypen-registerrecord/." ;;
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;
esac
done
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""S-16b (#123): prove distributed tracing works end to end.
Generate anonymous BFF traffic (GET /openbaar/register, which the BFF serves by
calling projection-api no auth, no OpenZaak egress), then query Tempo and assert
that ONE trace contains spans from both `bff` and `projection-api`. That proves the
services export OTLP to Tempo AND that the W3C traceparent propagates across the
HttpClient hop, stitching the request into a single connected trace.
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BFF = os.environ["BFF"] # http://<bff-ip>:8080
TEMPO = os.environ["TEMPO"] # http://<tempo-ip>:3200
TIMEOUT = int(os.environ.get("TRACING_TIMEOUT", "90"))
WANT = {"bff", "projection-api"} # the two services that must share one trace
def _get(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read()
def generate_traffic():
# A non-2xx still produces spans; only total unreachability of the BFF is fatal.
for _ in range(3):
try:
_get(f"{BFF}/openbaar/register")
except urllib.error.HTTPError:
pass
def search_trace_ids():
q = urllib.parse.quote('{ resource.service.name = "bff" }')
try:
data = json.loads(_get(f"{TEMPO}/api/search?q={q}&limit=50"))
except Exception:
return []
return [t["traceID"] for t in data.get("traces", [])]
def services_in_trace(trace_id):
try:
data = json.loads(_get(f"{TEMPO}/api/traces/{trace_id}"))
except Exception:
return set()
names = set()
for batch in data.get("batches", []):
for attr in batch.get("resource", {}).get("attributes", []):
if attr.get("key") == "service.name":
names.add(attr.get("value", {}).get("stringValue"))
return names
def main():
deadline = time.time() + TIMEOUT
generate_traffic()
seen = set()
while time.time() < deadline:
for tid in search_trace_ids():
names = services_in_trace(tid)
seen |= names
if WANT.issubset(names):
print(f"OK — trace {tid} spans {sorted(names)}")
return 0
time.sleep(3)
generate_traffic()
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Render a per-test-project table from .trx files for a Gitea job summary (#136).
Reads every *.trx in the given directory (default: TestResults), pulls each project's
counters + assembly name, and prints a GitHub/Gitea-flavoured markdown table to stdout.
The CI step redirects that into $GITHUB_STEP_SUMMARY. Stdlib only.
"""
import glob
import os
import sys
import xml.etree.ElementTree as ET
NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
def project_name(root):
# The test assembly path, e.g. …/services/domain/Big.Tests/bin/…/big.tests.dll. Prefer the
# owning service folder (services/<name>) so "domain" shows rather than the opaque "big.tests";
# fall back to the assembly basename for projects outside services/ (e.g. tests/acceptance).
ut = root.find(".//t:TestDefinitions/t:UnitTest", NS)
storage = ut.get("storage") if ut is not None else None
if not storage:
return None
parts = storage.replace("\\", "/").split("/")
if "services" in parts:
return parts[parts.index("services") + 1]
base = os.path.basename(parts[-1])
return base[:-4] if base.lower().endswith(".dll") else base
def parse(path):
root = ET.parse(path).getroot()
c = root.find(".//t:ResultSummary/t:Counters", NS)
if c is None:
return None
total = int(c.get("total", 0))
if total == 0: # e.g. the Integration project, filtered out of the unit run
return None
executed = int(c.get("executed", 0))
passed = int(c.get("passed", 0))
failed = int(c.get("failed", 0)) + int(c.get("error", 0))
skipped = total - executed
return {
"name": project_name(root) or os.path.basename(path),
"passed": passed, "failed": failed, "skipped": skipped, "total": total,
}
def main(results_dir):
rows = [r for r in (parse(p) for p in sorted(glob.glob(os.path.join(results_dir, "*.trx")))) if r]
if not rows:
print("_No test results found._")
return 0
rows.sort(key=lambda r: r["name"])
print("## ✅ Unit tests\n")
print("| Project | Result | Passed | Failed | Skipped | Total |")
print("| ------- | :----: | -----: | -----: | ------: | ----: |")
for r in rows:
status = "" if r["failed"] else ""
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "TestResults"))
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""Render a per-frontend test table from vitest JSON reports for a Gitea job summary (#136).
Reads every *.json in the given directory (default: test-output), each written by an app's
`test` target (reporters: json, outputFile: {workspaceRoot}/test-output/{projectName}.json), and
prints a markdown table to stdout one row per frontend app. The CI step redirects it into
$GITHUB_STEP_SUMMARY. Stdlib only.
"""
import glob
import json
import os
import sys
def main(results_dir):
rows = []
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
try:
with open(path) as fh:
d = json.load(fh)
except (OSError, ValueError):
continue
rows.append({
"name": os.path.splitext(os.path.basename(path))[0],
"passed": d.get("numPassedTests", 0),
"failed": d.get("numFailedTests", 0),
"skipped": d.get("numPendingTests", 0) + d.get("numTodoTests", 0),
"total": d.get("numTotalTests", 0),
"ok": d.get("success", False),
})
if not rows:
print("_No frontend test results found._")
return 0
print("## 🅰️ Frontend tests\n")
print("| Frontend | Result | Passed | Failed | Skipped | Total |")
print("| -------- | :----: | -----: | -----: | ------: | ----: |")
for r in rows:
status = "" if r["ok"] and not r["failed"] else ""
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "test-output"))
@@ -24,22 +24,6 @@ import {
Observable
} from 'rxjs';
export interface BeheerDefaultFill {
bronorganisatie: string;
verantwoordelijkeOrganisatie: string;
vertrouwelijkheidaanduiding: string;
}
export interface BeheerZaaktype {
identificatie: string;
omschrijving: string;
}
export interface CurrentRegistration {
registrationId: string;
status: string;
}
export interface DecideRequest {
besluit: string;
}
@@ -51,14 +35,6 @@ export interface OpenbaarEntry {
reference: string | null;
}
export interface ProvideDocumentsRequest {
contentBase64: string;
/** @nullable */
fileName?: string | null;
/** @nullable */
contentType?: string | null;
}
export interface SubmitAccepted {
registrationId: string;
status: string;
@@ -216,37 +192,6 @@ export class BffApiV1Service {
);
}
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
@@ -281,44 +226,6 @@ export class BffApiV1Service {
);
}
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(
id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
@@ -421,100 +328,4 @@ export class BffApiV1Service {
);
}
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientBodyOptions): Observable<TData>;
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBeheerDefaultFill<TData = BeheerDefaultFill>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/beheer/default-fill`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/beheer/default-fill`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/beheer/default-fill`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientBodyOptions): Observable<TData>;
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
putBeheerDefaultFill<TData = void>(
beheerDefaultFill: BeheerDefaultFill, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.put<TData>(
`/beheer/default-fill`,
beheerDefaultFill,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.put<TData>(
`/beheer/default-fill`,
beheerDefaultFill,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.put<TData>(
`/beheer/default-fill`,
beheerDefaultFill,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
};
-8
View File
@@ -5,14 +5,6 @@
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
-85
View File
@@ -1,31 +1,8 @@
using Acl.Application;
using Acl.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls (the ACL → OpenZaak hop), exported over OTLP to Tempo. Service name +
// OTLP endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
.AddHttpClientInstrumentation()
.AddOtlpExporter())
// OpenTelemetry metrics (S-16c, ADR-0023): golden signals for the request path —
// http.server.request.duration (traffic/errors/latency) + http.client.* for downstream hops, plus
// the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes these
// from /metrics (mapped below); metrics aren't pushed over OTLP, so no collector hop (ADR-0023).
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime")
.AddPrometheusExporter());
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Defaults").Get<AclDefaults>()
@@ -33,32 +10,13 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the
// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27
// resolution keys stay on AclDefaults (static) — see DefaultFillSettings.
builder.Services.AddSingleton<IDefaultFillStore>(sp =>
{
var d = sp.GetRequiredService<AclDefaults>();
return new InMemoryDefaultFillStore(
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
});
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
builder.Services.AddScoped<AclService>();
var app = builder.Build();
app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{
@@ -74,14 +32,6 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
return Results.NoContent();
});
// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype
// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1).
app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) =>
{
await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct);
return Results.NoContent();
});
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
@@ -90,47 +40,12 @@ app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl
return Results.Ok(new { reference });
});
// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL
// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL.
app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) =>
{
var url = await acl.StoreDiplomaAsync(
new Uri(body.ZaakUrl), Convert.FromBase64String(body.ContentBase64), body.FileName, body.ContentType, ct);
return Results.Ok(new { informatieobjectUrl = url.ToString() });
});
// List the published zaaktypen — the read-only catalogus the beheer portal shows (S-15a). The BFF
// proxies this behind medewerker-realm + beheerder authorization; the ACL trusts its callers (§8.3)
// and is the only code allowed to read the ZGW Catalogi API (§8.1).
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
Results.Ok(await acl.ListZaaktypenAsync(ct)));
// Read the current default-fill settings (beheer config viewer, S-15b).
app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill()));
// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at
// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields).
app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) =>
{
if (string.IsNullOrWhiteSpace(body.Bronorganisatie) ||
string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) ||
string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding))
return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." });
acl.UpdateDefaultFill(body);
return Results.NoContent();
});
app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record SetStatusRequest(string ZaakUrl);
public sealed record CancelZaakRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
public partial class Program;
+1 -8
View File
@@ -6,12 +6,5 @@ public sealed class AclDefaults
public required string Bronorganisatie { get; init; }
public required string VerantwoordelijkeOrganisatie { get; init; }
public required string Vertrouwelijkheidaanduiding { get; init; }
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
public required string ZaaktypeIdentificatie { get; init; }
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
public required string InformatieobjecttypeOmschrijving { get; init; }
public required Uri ZaaktypeUrl { get; init; }
}

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