Red — BeoordeelRegistratie has no user-task dependency and doesn't complete the task.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What & why
First half of **S-12c** (behandel-portal backend), per **ADR-0013** (decisions recorded in #84):
- **BFF multi-realm auth.** A second JWT bearer scheme (`medewerker`) alongside the default `digid` scheme. On validation it lifts Keycloak's `realm_access.roles` onto the principal, and a `behandelaar` policy (medewerker scheme + `behandelaar` role) gates `/behandel/*`. Self-service keeps the digid scheme.
- **Werkbak = Flowable tasks.** The domain `Werkbak` query reads the open `Beoordelen` tasks (§8.2, S-12b's `IUserTaskClient`) and enriches each with its aggregate's bsn + status; `GET /behandel/werkbak` (domain) is proxied by the BFF `GET /behandel/werkbak` behind the behandelaar policy. The read projection stays the anonymous openbaar model (no premature `IN_BEHANDELING`/personal-data plumbing — deferred in ADR-0008).
Behavior: `/behandel/werkbak` is **401** without a token, **403** for a medewerker lacking the role, **200 + werkbak** for a behandelaar.
**S-12c-2** (next): `POST /behandel/registrations/{id}/decide` → domain decision + complete the Flowable task.
## Definition of Done
- [x] Linked issue: #13 (umbrella, `refs`); closes the adr-proposal #84
- [x] Tests first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 78, bff 23, acceptance 9 (+ acl/event-subscriber unaffected)
- [x] api-client `test` green; openapi.json regenerated (drift guard passes)
- [x] Mutation ≥ break(90): **domain 100%, bff 100%**
- [x] ADR-0013 added; `Keycloak__MedewerkerAuthority` wired into compose
- [ ] CI green (pending)
Part of #13. closes#84
Reviewed-on: #85
## What & why
Second sub-slice of **S-12 (#13)** — the **Workflow Client gains behandelaar user-task operations**, and the process model gains the beoordeling step.
- **BPMN:** `registratie.bpmn` now parks at a `Beoordelen` **userTask** (candidate group `behandelaar`) after `OpenZaakAanmaken`; `registrationId` rides along as a process variable so the werkbak can correlate each task to its aggregate.
- **Workflow Client** (`IUserTaskClient`, the only code that talks to Flowable §8.2):
- `GetOpenBeoordelingenAsync()` — the werkbak (open `Beoordelen` tasks + their `registrationId`)
- `ClaimAsync(taskId, behandelaar)`
- `CompleteBeoordelingAsync(taskId, besluit)` — carries the decision into the process as the `besluit` variable
- **Live integration:** `verify-domain` now drives the full user-task lifecycle against a real Flowable — after the worker opens the zaak, it polls for the task, claims it as `merel-behandelaar`, completes it (`goedkeuren`), and asserts the process finishes. This proves the exact REST contract (`service/runtime/tasks/query` + `…/{id}` claim/complete) the client depends on.
The walking skeleton is unaffected: the temporary `/approve` path still sets the zaak status directly; wiring the domain decision to *complete this task* (and driving the werkbak from the BFF) lands in **S-12c**.
## Definition of Done
- [x] Linked issue: #13 (umbrella; `refs`, does not close)
- [x] Tests first; red → green
- [x] Unit + acceptance green (`make unit`): domain 76, acceptance 9 (acl/event-subscriber/bff unaffected)
- [x] Mutation ≥ break(90): **domain 100%** (killed the new survivors *and* the pre-existing `FlowableWorkflowClient` baseline)
- [x] Live Flowable user-task lifecycle asserted in `verify-domain`
- [ ] CI green (pending)
Part of #13.
Reviewed-on: #83
## What & why
First sub-slice of **S-12 (#13)** — the **beoordeling decision model** in the Domain Service. Foundation for the behandel-portal: it gives the domain a proper decision lifecycle before any UI/Flowable/BFF work.
- **Statuses:** add `InBehandeling` and `Afgewezen` to `RegistrationStatus`.
- **Aggregate:** `TakeIntoBehandeling()` (`Ingediend → InBehandeling`, idempotent, guards terminal states); generalise the behandelaar decision — `Approve()` (requires a zaak) and new `Reject()` both act on an `Ingediend`/`InBehandeling` registration → `Ingeschreven`/`Afgewezen`.
- **Use-case:** `BeoordeelRegistratie` (`goedkeuren` sets the zaak's final status via the ACL §8.1 → `Ingeschreven`; `afwijzen` → `Afgewezen`, domain-only for now). Idempotent.
- **Endpoint:** `POST /registrations/{id}/decide` (`{ "besluit": "goedkeuren" | "afwijzen" }`), superseding the temporary `/approve` (retired when the portal lands, S-12d).
- **BDD:** `EenRegistratieBeoordelen.feature` — goedkeuren + afwijzen scenarios (feature-scoped bindings).
**Scoped out** to later S-12 sub-slices: Flowable user-task claim/complete + BPMN `userTask` (S-12b), BFF `/behandel/*` + medewerker authz (S-12c), the Angular behandel-portal + e2e (S-12d), and propagating a *rejection* to the zaak/projection via the ACL.
## Definition of Done
- [x] Linked issue: #13 (umbrella; this PR `refs`, does not close)
- [x] Tests first; red → green per behaviour
- [x] Unit + acceptance green (`make unit`): domain 65, acceptance 9
- [x] Mutation ≥ break(90): domain 98.77%, no survivors in new code (the one unkilled mutant is the pre-existing `FlowableWorkflowClient` baseline)
- [ ] CI green (pending)
Part of #13.
Reviewed-on: #82
## What & why
Before this change the self-service confirmation and the openbaar register showed **different** identifiers, so a citizen could not look their registration back up (#78). Now both surface the same **reference**:
- **domain → ACL (write):** the domain `registrationId` is set as the zaak's `identificatie` on `POST /zaken`.
- **event-subscriber → ACL (read):** the subscriber reads the zaak's `identificatie` back through the ACL (§8.1 — only the ACL talks to ZGW) via a new `POST /zaken/reference`, and stores it on the projection row **and** the `processed_notifications` replay log.
- **BFF + openbaar:** the public view exposes `id/status/reference` (never bsn/naam) and searches by id or reference; the register's "Referentie" column shows the reference.
Storing the reference in the replay log keeps ADR-0008's **rebuild-is-log-only** invariant intact — `/admin/rebuild` reproduces the reference without re-reading the ACL.
Decision recorded in **ADR-0012**.
## Definition of Done
- [x] Linked issue: #78
- [x] Tests written first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 49, acl 27, bff 20, event-subscriber 19, acceptance 7
- [x] Frontend lint + test green (`nx run-many -t lint test`)
- [x] Mutation ≥ break(90): acl 100%, event-subscriber 100%, bff 100%, domain 98.41% (pre-existing FlowableWorkflowClient baseline, untouched)
- [x] e2e extended: confirmation reference == register reference
- [x] openapi.json + api-client regenerated (drift guard green)
- [x] ADR-0012 added; demo-script note appended
- [x] `Acl__BaseUrl` wired for the subscriber in compose
closes#78
Reviewed-on: #79
## What & why
S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar.
## Changes (bottom-up, each red→green TDD)
- **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`.
- **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test.
- **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status.
- **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN.
- **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note.
## Key decisions (see ADR-0011)
- **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant.
- **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+).
## Verification
- All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean.
- No new compose config (the eindstatus-discovery approach avoided it).
- The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally).
closes#75
Reviewed-on: #77
Wire the bff service in compose (Keycloak authority + downstream domain/projection
URLs, depends_on domain/projection healthy + keycloak started). run-bff-check.sh
verifies the BFF end-to-end against the up stack: 401 without a token, 202 with a
real digid token minted via direct grant against keycloak:8080 (host-consistent
issuer, ADR-0010), and an anonymous public-safe openbaar register (never a bsn).
Wired as verify-bff (Makefile + verify chain + CI step). Stryker baseline for the
BFF's pure logic (OpenbaarProjection) at 100% (break 90); Program/HTTP adapters are
covered by the endpoint tests + verify-bff. CI uploads the bff mutation report.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document typed responses (202 SubmitAccepted / 400 / 401 on self-service; 200
OpenbaarEntry[] on openbaar) so the generated spec carries real schemas for S-08's
client. A document transformer clears the auto-populated servers block so the spec
is host-independent and deterministic. Commit services/bff/openapi.json and add a
test asserting it matches the served /openapi/v1.json (fails on drift).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /self-service/registrations requires a valid digid JWT, reads the bsn claim
and forwards it to the domain, returning 202. GET /openbaar/register is anonymous
and returns OpenbaarProjection.PublicView — rows filtered by q and mapped to the
public-safe id+status only (bsn/naam never exposed). JwtBearer validates
signature/issuer/expiry against the Keycloak digid authority (§8.3, ADR-0010).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Failing tests for the BFF walking-skeleton endpoints:
- POST /self-service/registrations rejects missing/malformed/wrong-key/expired
tokens (401) and, with a valid digid token, forwards the bsn to the domain and
returns 202 (WebApplicationFactory + a local test signing key, ADR-0010).
- GET /openbaar/register serves public-safe rows anonymously (never the bsn) and
filters by q.
- OpenbaarProjection.PublicView (pure) filters by id and maps to id+status only.
Endpoints and PublicView are stubs so the tests compile and fail on their
assertions; the green commit implements them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dockerfile (multi-stage, .NET 10) + .dockerignore for the BIG Domain Service; a
'domain' service in infra/docker-compose.yml (health-checked, depends on acl healthy
and flowable-init completed). run-domain-check.sh drives the full path against the up
stack — seed a published zaaktype, recreate the acl pointed at it (host-consistent),
POST /registrations, and assert the worker opens a zaak and records it. Wired as the
verify-domain Makefile target + a verify-stack CI step; domain added to WAIT_SVCS and
the log dump. seed_catalogus.py now emits a machine-readable ZAAKTYPE_URL line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stryker.NET config for the domain service (break 90, the repo's ratchet floor),
excluding the OpenZaakJobPump hosted-shell from mutation. Hardened the unit tests
to kill survivors — Basic-credential value, variable types, null/failure response
paths, option defaults, guard clauses, save counts and log output — leaving only
two documented equivalent mutants (Stryker-disabled). make mutation runs the domain
ratchet and CI uploads its report alongside the others.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The BIG Domain Service Api wires the use cases and the hosted job worker:
POST /registrations creates the aggregate and starts the registratie process,
returning 202 with a location; GET /registrations/{id} reads the aggregate so
the eventually-opened zaak URL can be observed (ADR-0009). The Workflow Client
is registered once behind both Flowable ports; the ACL client and in-memory
store complete the wiring. Verified against a live flowable-rest: submit starts
a parked process and the worker polls it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FlowableWorkflowClient speaks flowable-rest's REST API (Basic auth): start a
registratie process with the registrationId variable, acquire OpenZaakAanmaken
external-worker jobs and parse their registrationId, complete a job with the
zaakUrl variable — the contract verified against a live engine. AclHttpClient
POSTs the bsn to the ACL and returns the zaak URL. InMemoryRegistrationStore is
a concurrent-dictionary upsert. OpenZaakJobProcessor drains parked jobs, opening
a zaak per job and completing it, leaving failures for redelivery; OpenZaakJobPump
is the hosted polling shell that drives it on an interval (ADR-0009).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Failing infrastructure unit tests (stub HttpMessageHandler, fakes):
- FlowableWorkflowClient starts a process with the registrationId variable and
returns the instance id; acquires OpenZaakAanmaken jobs (topic/workerId/lock)
and parses their registrationId; completes a job with the zaakUrl variable —
request URIs match flowable-rest's service/ and external-job-api/ paths.
- AclHttpClient POSTs the bsn to the ACL and returns the zaak URL.
- InMemoryRegistrationStore saves/reads/upserts by id.
- OpenZaakJobProcessor acquires, opens a zaak, completes the job; leaves a failing
job uncompleted for redelivery; polls harmlessly when idle.
Adapters are stubs so the tests compile and fail on their assertions; the green
commit implements them against the REST contract verified on a live Flowable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SubmitRegistration creates the aggregate, persists it, starts the registratie
process via the Workflow Client, records the instance id and upserts. OpenZaakWorker
loads the correlated registration, opens a zaak via the ACL, attaches it and saves;
an unknown registration throws (job redelivered), and an already-opened zaak short-
circuits without opening a second one (§8.6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Failing application-layer tests over fake ports (IWorkflowClient, IAclClient,
IRegistrationStore):
- Submit persists an INGEDIEND registration and starts the registratie process,
recording the instance id — and persists *before* starting, so the worker can
correlate the OpenZaakAanmaken job back to its aggregate (ADR-0009).
- The worker opens a zaak via the ACL and attaches it; an unknown registration
throws (job left for redelivery); a redelivered job is idempotent and opens no
second zaak (§8.6).
Handlers are stubs (no persistence / no ACL call) so the tests compile and fail
on their assertions; the green commit implements them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Submit requires a bsn and starts the aggregate in INGEDIEND; the started
process-instance id is recorded; AttachZaak stores the ACL's zaak URL,
tolerating a duplicate (at-least-once worker delivery) and rejecting a
conflicting URL, without advancing the status.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Failing unit tests for the Registration aggregate root: a submission starts
in INGEDIEND carrying its bsn, an empty/whitespace/null bsn is rejected, the
started process-instance id is remembered, and attaching the zaak the ACL
opened records its URL idempotently (a conflicting URL is rejected) while the
status stays INGEDIEND.
The aggregate is a stub (no-op mutators, empty bsn) so the tests compile and
fail on their assertions; the green commit implements the invariants.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add projection-db + the two services to both compose files (host ports 8110/8120), their
Dockerfiles (repo-root context — they share Projection.ReadModel), and a runner-safe
verify-projection check (infra/run-projection-check.sh) that registers the abonnement at the
real subscriber, creates a zaak and asserts projection-api serves an INGEDIEND row. Wire it
into make (verify-projection, verify, WAIT_SVCS) and the CI verify-stack job, and run the
event-subscriber Stryker ratchet in `make mutation` + upload its report.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Check the Authorization header before deserializing the notification, and read/parse the
body manually. NRC probes a new abonnement's callback with a request that has neither the
configured auth nor a valid notification body, and refuses to register unless it gets a 401
(not a 400) — ADR-0007. Mirrors the verify harness's sink contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>