Sharpen the projector tests so Stryker has no survivors (was 75%): assert a replayed
delivery never reaches the store (upsert count, not just row count), that two distinct
zaken get distinct rows (pins the idempotency key), that rebuild clears stale rows, and
a Theory over wrong kanaal/resource/actie combinations (pins the zaken/zaak/create guard).
Add the per-service Stryker config + solution; break threshold 90 (CLAUDE.md §5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the projection persistence and the two services around it:
- Projection.ReadModel: a shared EF Core (Npgsql) read model owning the projection
schema — register_projection + the subscriber's processed_notifications log — plus
EfProjectionStore / EfNotificationLog (atomic record-or-skip on the PK for idempotency)
and the initial migration. One rebuildable store, written by the subscriber and read
by projection-api (ADR-0008).
- EventSubscriber.Api: POST /notifications NRC callback (enforces the abonnement bearer,
401 without it per ADR-0007), POST /admin/rebuild, /health. Migrates on start.
- ProjectionApi.Api: GET /register, GET /register/{id}, /health — the read side.
dotnet-ef pinned as a local tool for migrations; NuGetAuditMode=direct so EF's
design-time-only tooling transitive doesn't flag the shipped build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement NotificationProjector: a zaken/zaak/create notification records the delivery
in the notification log (atomic record-or-skip for idempotency, §8.6) and upserts an
INGEDIEND projection row keyed by zaak id; other channels/actions are ignored. Rebuild
clears the projection and replays the log — no OpenZaak access needed (§8.1). bsn/naam
are deferred (ADR-0008).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Failing unit + acceptance tests for the Event Subscriber's NotificationProjector:
a zaken/zaak/create notification yields one INGEDIEND projection row, duplicate
deliveries collapse to one row, non-zaak/non-create notifications are ignored, and
a rebuild repopulates the projection from the durable notification log (PRD §8.4).
The projector is a no-op stub so the tests compile and fail on the assertions; the
implementation follows in the green commit. The notification log doubles as the
idempotency guard and rebuild source so a rebuild needs no OpenZaak access (§8.1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the single self-hosted runner CI jobs run sequentially, so booting OpenZaak once
beats once-per-job. Replace the integration + notifications + compose-smoke jobs with
one verify-stack job that brings the full stack up once and runs, as clearly-named
steps: health (make verify-up, the DoD smoke) → ACL ↔ OpenZaak (verify-acl) →
OpenZaak → NRC delivery (verify-nrc) → teardown (always) + log dump on failure.
The check logic moves into stack-agnostic runners (run-acl-integration.sh,
run-notification-check.sh) that operate on whatever stack is already up, reaching
services by container IP. The local single-concern wrappers (make integration oz-only,
make verify-notifications oz+nrc) keep working by delegating to the same runners, so
nothing is duplicated. make ci now runs the consolidated 'verify' stage.
Verified locally: make verify boots the full stack once, ACL integration passes and
the NRC notification is delivered, then tears down.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the wiring decision (AC-delegated auth, required celery-beat) and the two
non-obvious gotchas: single-label hosts aren't URL-valid (reach services by IP) and
abonnement callbacks must enforce auth. Documents the new notifications CI job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
make verify-notifications brings the stack up, seeds a published BIG zaaktype, and
asserts a zaak-create notification is delivered to a webhook-sink abonnement. The
sink + driver run as containers inside the compose network and reach OpenZaak/NRC by
container IP (the runner can't reach published ports, and a single-label host isn't
URL-valid). The sink enforces a bearer token because NRC refuses an unauthenticated
callback. New 'notifications' Gitea Actions job runs it (Docker-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the S-01-c wiring so a zaak created in OpenZaak is published to NRC:
- OpenZaak: a zgw_consumers 'nrc' service + notifications_config (setup_configuration),
publishing as big-reference-seed. NOTIFICATIONS_DISABLED stays true for OpenZaak-only
bring-ups (OZ_NOTIFICATIONS_DISABLED) so the ACL integration test doesn't 500; the
full/local stacks and stack-up set it false.
- NRC: the JWT credential, an 'ac' service + autorisaties_api delegation to OpenZaak's
Autorisaties API, and the 'zaken' kanaal. nrc-init now runs setup_configuration; its
data.yaml is delivered via the rr-nrc-config volume (seed-config.sh nrc), mirroring oz.
- nrc-beat added to every stack: NRC accepts a notification then drains it via a
scheduled execute_notifications task — without beat, nothing is delivered. Interval 5s.
Applied across the standalone, full, and local-bind-mount composes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hosted runner can't reach the stack's published ports (sibling containers),
so run the seed and the test as containers joined to the OpenZaak network,
reaching it by container IP — a single-label host like 'openzaak' isn't URL-valid
for OpenZaak's own URLValidator, but an IPv4 literal is. Code is delivered via
image build / docker cp (bind mounts don't reach the daemon either).
- infra/run-integration.sh: up -> wait healthy (docker inspect) -> seed published
zaaktype (python container on the net) -> build + run the test image on the net
-> always tear down. Plain docker primitives only (portable docker/podman).
- services/acl/Dockerfile.integration: builds + runs Acl.IntegrationTests; dotnet
lives in the image, so the CI job needs only Docker (no setup-dotnet).
- make integration now delegates to the script; re-added the Gitea Actions job.
Supersedes the local-only gap documented earlier; #55 is no longer needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hosted Gitea runner starts the OpenZaak stack as sibling containers via the
host daemon, so a process on the runner can't reach the published ports — the seed
and dotnet test get Connection refused on localhost:8000. Drop the (non-working)
integration CI job; make integration stays the local / host-runner gate. Document
the limitation in gitea-actions-gotchas.md §5 and the CI runbook, and track running
it inside the compose network in #55. ADR-0006 updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New integration job: setup-dotnet + make integration (stack up, OZ_PUBLISH=1 seed,
Integration-category tests, tear down), with on-failure log dump + teardown like
compose-smoke. Documents the job and the new make target in the CI runbook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records why the integration test targets the running compose stack rather than a
Testcontainers graph (no .NET compose support; not hermetic anyway due to the
Selectielijst dependency), the opt-in publish seed, and the chunked-body bug the
test caught. Proposed in #53.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
JsonContent streams without a Content-Length (Transfer-Encoding: chunked), so
buffer it first. Only a real OpenZaak surfaces this — the integration test from
the previous commit now passes. A unit test asserts a Content-Length is sent
(captured before the stub reads/buffers the body), guarding the fix in the fast
lane and killing the Stryker mutant that would otherwise survive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S-04a: the deferred S-04 acceptance criterion. A gated Acl.IntegrationTests
project (Category=Integration) drives the real OpenZaakGateway against the
running compose stack — real ZGW JWT auth and the real POST /zaken contract a
stubbed HttpMessageHandler cannot exercise. The lane is kept out of the fast
checks: make unit filters Category!=Integration, Stryker is pinned to Acl.Tests,
and a new make integration target brings the stack up, seeds a published zaaktype
and tears down.
Red: against real OpenZaak the gateway POST fails 400 — JsonContent streams the
body chunked and OpenZaak's uwsgi rejects it. Fixed in the next commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OpenZaak rejects a zaak against a concept zaaktype (not-published). Add an
opt-in OZ_PUBLISH path that creates the relations publish requires — two
statustypen, a roltype, and a resultaattype whose Selectielijst procestype is
matched onto the zaaktype — then publishes. Default stays concept (ADR-0002);
only the ACL integration test flips it on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The artifact step failed the mutation job: upload-artifact@v4 bundles
@actions/artifact v2, which hard-aborts on any non-github.com server ("not
supported on GHES"), even though Gitea 1.25 stores artifacts fine. @v3 uses the
older protocol Gitea speaks and has no GHES guard — a drop-in swap (same inputs).
Document it as gotcha §4 and correct the CI runbook note.
Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an upload-artifact step to the mutation job so the ACL mutation report is
downloadable from the run summary. `if: always()` uploads it even when the
ratchet fails — exactly when the survivors matter. A glob handles Stryker's
timestamped output directory. First use of actions/upload-artifact (@v4, pinned);
Gitea 1.25.x supports it. Document it in the CI runbook.
Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the decision to adopt Stryker.NET (pinned local tool, solution mode on
Acl.slnx) and to set the first repo-wide mutation baseline on the ACL: observed
95%, enforced break threshold 90%. Document the ratchet, local run, and report
location in the CI runbook; add the ADR to the docs nav.
Proposed in #51 (adr-proposal). Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `mutation` job mirroring the unit job (checkout + pinned setup-dotnet,
then `make mutation`). It runs in parallel with lint/build/unit/compose-smoke
and gates merges on the ACL mutation baseline (CLAUDE.md §5/§15). The job calls
the same make target developers run, so the pipeline stays a mirror of `make ci`.
Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Configure Stryker.NET for the ACL in solution mode (Acl.slnx), so both
Acl.Application and Acl.Infrastructure — the two projects under test — are
mutated while Acl.Api (untested) is skipped. Record the repo-wide mutation
baseline as the ratchet (CLAUDE.md §5): observed score 95%, enforced break
threshold 90% (one-mutant headroom over the ~20-mutant surface). The ACL is the
first service with branching logic, so it sets the baseline; later slices
ratchet it up deliberately, never down.
Add a `mutation` make target (`dotnet tool restore` + `dotnet stryker`) and wire
it into the `make ci` aggregate, keeping `make ci` an exact mirror of the
pipeline.
Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stryker exposed thin ACL tests (35% mutation score): the suite never asserted
the geo CRS headers, the ArgumentNullException guards, the non-success and
empty-body error paths, or the structure of the minted ZGW JWT — so mutating
any of those survived.
Strengthen the unit tests to kill those mutants:
- assert Accept-Crs / Content-Crs are EPSG:4326,
- assert OpenZaakAsync rejects a null request/registration without calling out,
- assert a non-2xx response throws and an empty body throws InvalidOperationException,
- decode the Bearer token and assert the HS256 header + acl identity claims.
Raises the ACL mutation score to 95%. The one remaining survivor mutates only
the exception *message* text (an equivalent mutant — message strings are not
worth a brittle assertion).
Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a tool manifest pinning dotnet-stryker 4.15.0 so `make mutation` runs
the same mutation tester locally and in CI from a fresh clone (`dotnet tool
restore`), with no global install. Ignore the generated StrykerOutput/ report
directory.
Refs #47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure for scannability: a shared root-cause intro, a quick-reference
table (gotcha → fix → where), and consistent Symptom/Why/Fix sections with
tighter prose. Documents infra/docker-compose.local.yml as the no-make/Windows
path and drops the now-stale "no bind mounts remain" line (the local compose
uses them, which is fine locally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds infra/docker-compose.local.yml: the same full stack as the canonical
infra/docker-compose.yml, but the three config inputs (OpenZaak data.yaml,
Keycloak realms, Flowable BPMN) are bind-mounted from the repo instead of
streamed into external volumes by seed-config.sh.
Bind mounts are valid here because a local daemon (Docker Desktop on Windows/
macOS, or rootless Podman on Linux) can see the working directory — the seed
dance only exists for the containerized CI runner, where it can't. So this file
runs with a plain `docker compose up`: no make, no seed step, no bash.
docker compose -f infra/docker-compose.local.yml up -d --build
docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
Linux/macOS convenience wrappers `make local` / `make local-down` added too.
Verified on podman: Keycloak boots from this file and imports the bind-mounted
realms (digid realm returns 200). docs/runbooks/ci.md documents the Windows path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`make smoke` errored locally because podman-compose doesn't implement
`docker compose up --wait` (`unrecognized arguments: --wait`).
Replace the `--wait` step with infra/wait-healthy.sh, which polls each durable
health-checked service ($(WAIT_SVCS)) via `docker ps` + `docker inspect
'{{.State.Health.Status}}'`. This:
- works on both docker compose (CI) and podman-compose (local) — only plain
docker primitives, no `--wait`;
- reads the in-container healthcheck, so it needs no host port access (the CI
runner can't reach published ports);
- ignores the one-shot init jobs, sidestepping the "--wait fails when a
consumer-less one-shot exits 0" issue (flowable-init).
Verified on podman-compose: wait-healthy.sh reports bff healthy (rc=0); podman
exposes .State.Health.Status (starting -> healthy) and the name filter matches
both `_` and `-` container naming.
Docs: gitea-actions-gotchas.md updated (the two `--wait` sections folded into one
"portable health poll" section).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops the inline-build images for the upstream services. The compose now
references the published images directly (openzaak/open-zaak,
openzaak/open-notificaties, keycloak, curl, flowable-rest) with no build for
them, and the config they need is streamed into external named volumes by
infra/seed-config.sh:
rr-oz-config -> oz-init /app/setup_configuration (data.yaml)
rr-kc-realms -> keycloak /opt/keycloak/data/import (realm exports)
rr-fl-bpmn -> flowable-init /work (registratie.bpmn)
How: the seeder creates each volume, `docker create`s a throwaway helper that
mounts it, `docker cp`s the files in, and removes it. docker cp streams over the
Docker API, so it works in Docker-in-Docker (the CI runner) where bind mounts
mount empty. It uses plain `docker create`/`cp` — NOT `docker compose create`,
which podman-compose (local dev) lacks. `external: true` fixed names keep the
volumes identical across docker compose and podman-compose.
Consequence: bare `docker compose up` no longer self-seeds, so use `make up`
(seeds then starts). Every `*-up` target seeds first; `*-down` removes the
external volume. acl/bff are still built (they're our apps, not upstream images).
Verified end-to-end on podman-compose: `make keycloak-up` seeds rr-kc-realms,
the upstream Keycloak mounts it, and --import-realm imports all four realms
(digid realm returns 200). Seeder runs in ~2s.
Docs updated: gitea-actions-gotchas.md, ci.md, openzaak.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the three standalone Dockerfiles (openzaak, opennotificaties,
keycloak) with `build.dockerfile_inline` recipes in the compose files, so the
config bake has no separate Dockerfile artifacts to maintain. Behaviour is
identical: each derived image still COPYies its config in.
- oz-init / keycloak / flowable-init: 2-line inline Dockerfiles.
- Open Notificaties needs no bake at all now — nrc-init runs migrations only,
so all NRC services use the plain base image (removes a whole derived image).
Why dockerfile_inline and not `docker cp` into named volumes: docker cp avoids
images entirely but needs `docker compose create`, which podman-compose (the
local dev runtime) does not implement — it would break `make openzaak-up` etc.
locally. dockerfile_inline works on both podman-compose and the CI runner
(verified both: oz-init + keycloak inline builds locally; flowable-init inline
has been green on CI since run 27).
Docs updated: gitea-actions-gotchas.md and openzaak.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run 28 got the full stack healthy but `compose-smoke` still failed. The last
compose line before the error was:
container infra-flowable-init-1 exited (0)
`docker compose up --wait` treats a service that exits as a failure of the
"stay running" condition unless something depends on it via
`service_completed_successfully`. oz-init/nrc-init are fine (openzaak/nrc-web
depend on them), but flowable-init deploys the BPMN and exits 0 with no
dependant, so whole-project `--wait` failed the instant it finished — even
though everything else was healthy and nrc-init now exits 0.
Smoke now:
1. `up -d` starts the full stack (one-shots run + deploy as before), then
2. `up -d --wait <WAIT_SVCS>` waits only for the durable health-checked
services (openzaak nrc-web acl bff).
Also drops the external `curl localhost:8080/health`: the containerized CI
runner can't reach published host ports at localhost, and each service's
healthcheck already runs inside its container — so `--wait` succeeding IS the
smoke. Documented in docs/runbooks/gitea-actions-gotchas.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With OpenZaak now coming up, nrc-init ran for the first time and failed:
nrc-init-1 | CommandError: No steps enabled, aborting.
NRC's setup_configuration/data.yaml is intentionally empty ({}) — the
OZ<->NRC wiring is deferred to S-06 — but /setup_configuration.sh runs
`manage.py setup_configuration` regardless, and NRC 1.16.1 aborts when no
steps are enabled. (This was masked until now: oz-init failed first, so
openzaak never became healthy and nrc-init, which waits on it, never ran.)
The documented intent is "init runs migrations only", so nrc-init now runs
`manage.py migrate` directly instead of /setup_configuration.sh, and the
dead RUN_SETUP_CONFIG env is dropped from the NRC services. nrc-web still
migrates + creates the superuser itself via /start.sh.
Also:
- Makefile: bump compose `--wait-timeout` 300 -> 420. The serial
oz-db -> oz-init -> openzaak(healthy) -> nrc-init -> nrc-web(healthy)
chain runs ~260 s on the runner; 420 s gives comfortable headroom.
- ci.yaml: widen the on-failure log dump to oz-init, openzaak, nrc-init,
nrc-web, flowable-init, keycloak, acl, bff for full diagnosability.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the compose-smoke failure (found in the runner logs):
oz-init-1 | CommandError: Yaml file
`/app/setup_configuration/data.yaml` does not exist.
The ubuntu-latest runner runs the job inside a container, so
`docker compose up` starts the stack as SIBLING containers via the host
daemon. A relative bind mount (./openzaak/setup_configuration) resolves to
a path inside the job container that the daemon can't see, so Docker mounts
an empty dir and the init container can't find data.yaml. The same trap hit
nrc-init (data.yaml), flowable-init (the BPMN) and keycloak (realm import).
Fix: bake the assets into small derived images instead of bind-mounting:
- infra/openzaak/Dockerfile -> register-referentie/openzaak:dev
- infra/opennotificaties/Dockerfile-> register-referentie/opennotificaties:dev
- infra/keycloak/Dockerfile -> register-referentie/keycloak:dev
- flowable-init: build.dockerfile_inline bakes workflows/registratie.bpmn
Base versions stay build args (OPENZAAK_TAG / OPENNOTIFICATIES_TAG), so the
pinning is unchanged. Applied to both the consolidated compose and the
per-service composes, so local Podman and CI use one mechanism — no bind
mounts, no SELinux `:z`, no world-readable requirement.
Verified locally: `podman build` of the OpenZaak and BPMN images produces
the file at the expected in-container path.
Docs: docs/runbooks/gitea-actions-gotchas.md explains the DinD bind-mount
trap and the bake fix; openzaak.md and ci.md point at it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three root-cause fixes for the oz-init CI failure:
1. Smoke timeout: add --wait-timeout 300 to `docker compose up --wait`
so CI has 5 minutes instead of the 60-second default in older Compose
v2 releases (migrations alone take 50 s locally).
2. PostGIS race: the old healthcheck used pg_isready which only checks
TCP connectivity — it passes before the postgis/postgis init scripts
have run SELECT PostGIS_Version(). The new check adds a psql probe so
oz-init does not start until PostGIS is actually available.
3. Remove :z from volume mounts: the SELinux re-label flag is
Podman/Fedora-specific and a no-op (or unexpected) under Docker on
ubuntu-latest; plain :ro is correct for both runtimes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
latest bumped to OpenZaak 1.29.0 (2026-06-18) and open-notificaties
updated (2026-06-22), breaking oz-init in compose-smoke. Pin all four
compose files to stable patch releases:
open-zaak: 1.28.2 (was :latest -> 1.29.0)
open-notificaties: 1.16.1 (was :latest)
Tags are still overridable via OPENZAAK_TAG / OPENNOTIFICATIES_TAG env vars.
Also adds two if: failure() steps to the compose-smoke CI job: one that
dumps the last 100 lines of oz-init / nrc-init / acl / bff logs, and one
that tears the stack down cleanly, so future failures are self-diagnosing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Self-hosted respellion-linux runner not required — Gitea's hosted
ubuntu-latest runner has Docker + Compose v2 out of the box, so
make smoke works without any manual registration step.
Updates docs/runbooks/ci.md to reflect the new runner label and
removes the act_runner self-hosted setup as the primary path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the ACL multi-stage Dockerfile and .dockerignore, and expands
infra/docker-compose.yml from the BFF-only stub to the full development
stack (OpenZaak, NRC, Keycloak, Flowable, ACL, BFF). Without these
files a fresh checkout cannot satisfy `make smoke`'s `docker compose
up --build --wait` step, so `make ci` could never go green.
`make lint && make build && make unit` verified green locally.
`make smoke` requires Docker Compose v2 (`--wait` flag); on this dev box
only podman-compose is available — smoke will be verified on the
respellion-linux CI runner once it is registered (see docs/runbooks/ci.md).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>