53 Commits
Author SHA1 Message Date
not 94742a261f feat: read projection sourced from the register in Objecten (closes #153) (#155)
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m26s
CI / unit (push) Successful in 1m37s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 6m42s
CI / verify-stack (push) Failing after 11m26s
## What & why

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

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

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

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

Closes #153

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #152

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

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

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

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

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

### What landed

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

### Three things only a live stack found

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

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

All three are recorded in ADR-0028.

### Verification

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

## Definition of Done

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

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

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

Closes #131

### The vertical

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

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

### Scope decision → ADR-0026

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

## Verified locally

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

## Definition of Done

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

## Note on CI

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

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

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

Closes #136

### Items

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

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

### Notes

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

## Verified locally

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

## Definition of Done

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

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

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

Closes #130

### The vertical

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

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

### New boundary → ADR-0025

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

## Definition of Done

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

## Verified locally

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

## Notes for reviewers

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

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

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

Closes #124

### How

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

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

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

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

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

Closes #123

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

Closes #18
Closes #120

## Definition of Done

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

## Notes for reviewers

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

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

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

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

Closes #111

## Definition of Done

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

## Verified locally

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

## Notes for reviewers

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

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

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

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

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

Closes #113
Closes #117

## Consequences (the payoff)

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

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

## Definition of Done

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

## Verification done locally

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

## Notes for reviewers

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

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

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

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

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

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

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

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

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

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

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

Closes #103

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

## Definition of Done

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

## Notes for reviewers

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

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

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

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

Closes #102

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

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

Closes #14

## Definition of Done

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

## How it was built (TDD)

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

## Notes for reviewers

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

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

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

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

Closes #15

## Definition of Done

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

## How it was built (TDD)

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

## Notes for reviewers

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

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

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

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

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

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

Refs #12 (not closing — see below).

## Scope / follow-ups

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

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

## Definition of Done

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

## Notes for reviewers

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

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

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

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

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

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

## Definition of Done

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

Part of #13.

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

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

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

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

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

## Definition of Done

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

Part of #13. closes #84

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

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

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

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

## Definition of Done

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

Part of #13.

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

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

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

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

## Definition of Done

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

Part of #13.

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

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

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

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

Decision recorded in **ADR-0012**.

## Definition of Done

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

closes #78

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

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

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

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

## Key decisions (see ADR-0011)

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

## Verification

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

closes #75

Reviewed-on: #77
2026-07-14 09:04:57 +00:00
notandClaude Opus 4.8 a07d8277d6 ci(bff): compose wiring, verify-bff live check, mutation baseline (refs #8)
Wire the bff service in compose (Keycloak authority + downstream domain/projection
URLs, depends_on domain/projection healthy + keycloak started). run-bff-check.sh
verifies the BFF end-to-end against the up stack: 401 without a token, 202 with a
real digid token minted via direct grant against keycloak:8080 (host-consistent
issuer, ADR-0010), and an anonymous public-safe openbaar register (never a bsn).
Wired as verify-bff (Makefile + verify chain + CI step). Stryker baseline for the
BFF's pure logic (OpenbaarProjection) at 100% (break 90); Program/HTTP adapters are
covered by the endpoint tests + verify-bff. CI uploads the bff mutation report.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:59:55 +02:00
notandClaude Opus 4.8 5a3f28ac6d ci(domain): containerize, wire into compose, and verify end-to-end (refs #6)
Dockerfile (multi-stage, .NET 10) + .dockerignore for the BIG Domain Service; a
'domain' service in infra/docker-compose.yml (health-checked, depends on acl healthy
and flowable-init completed). run-domain-check.sh drives the full path against the up
stack — seed a published zaaktype, recreate the acl pointed at it (host-consistent),
POST /registrations, and assert the worker opens a zaak and records it. Wired as the
verify-domain Makefile target + a verify-stack CI step; domain added to WAIT_SVCS and
the log dump. seed_catalogus.py now emits a machine-readable ZAAKTYPE_URL line.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #47.

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

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

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

Refs #47.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:25:53 +02:00