feat(backend): expand stamdata + OpenZaak-ready cases seam (WP-49)
CI / frontend (push) Successful in 2m59s
CI / backend (push) Successful in 1m27s
CI / semgrep (push) Successful in 58s
CI / e2e (push) Successful in 2m30s
CI / api-client-drift (push) Canceled after 1m14s
CI / storybook-a11y (push) Canceled after 29m8s

Stamdata: add beroepen, opleidingen (temporal), and specialismen tables to the
schema-driven catalog (zero UI code). opleidingen.beroep and specialismen.beroep
both reference beroepen.code — the first stamdata->stamdata references, enforced by
two new StamdataRef entries in the CI gate.

OpenZaak/ZGW (WP-49, slice 1 — read-only zaken): introduce IZaakSource as the cases
read seam. Default LocalZaakSource reads the local SQLite store (offline); an
OpenZaakZaakSource (Zgw/ client: HS256 per-call JWT, ZGW->existing-DTO mapper,
paginating HTTP source) is selected behind Zgw:Enabled (default false). The FE never
changes — same ApplicationSummaryDto, no api-client drift. Unit-tested with fixtures
+ a stub HttpMessageHandler; no live OpenZaak needed.

Docs: ADR-0005, reference/openzaak-integration.md, WP-49..52 roadmap, stamdata.md
update, README index rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-24 15:01:06 +02:00
co-authored by Claude Opus 4.8
parent cff711504f
commit 1c3c195d32
28 changed files with 974 additions and 8 deletions
@@ -0,0 +1,69 @@
# ADR-0005 — OpenZaak (ZGW APIs) behind the BFF
Status: Accepted · Date: 2026-07-24
## Context
The POC serves cases (aanvragen) from a local SQLite store. To grow toward production it must
be able to source cases from a real Dutch **Zaakgericht Werken (ZGW)** backend — **OpenZaak**,
the VNG reference implementation. ZGW is not one API but five separate services (Zaken/ZRC,
Documenten/DRC, Catalogi/ZTC, Besluiten/BRC, Notificaties/NRC), each on its own base URL, with
traits that make raw responses unfit to hand to a browser:
- resources are identified by **full URLs**, not bare ids;
- references between resources are **URLs into other services** (a zaak's `zaaktype` lives in
Catalogi), so a single screen means joining across services;
- lists use a uniform `{count,next,previous,results}` pagination envelope;
- auth is a short-lived **HS256 JWT** signed with a client secret (no OAuth refresh), which
OpenZaak rejects an hour past `iat`.
Two constraints shaped the decision: the **frontend must not change** (BFF-lite, ADR-0001 —
the FE renders decision DTOs and never recomputes rules), and the POC must **still run fully
offline** (no OpenZaak needed for local dev/CI).
The backend, however, had **no data-access abstraction** — endpoints called concrete static
stores directly — and no outbound HTTP or JWT machinery. So there was no injection point to
swap a data source behind.
## Options
1. **FE talks to OpenZaak directly.** Rejected: leaks ZGW shapes + the client secret to the
browser, and contradicts BFF-lite.
2. **Rewrite the static stores in place to call OpenZaak.** Rejected: no seam, no offline mode,
all-or-nothing, untestable without a live server.
3. **Introduce a data-source interface behind the existing DTO contract, select the
implementation by config.** Chosen.
## Decision
Put the OpenZaak anti-corruption layer **in the .NET BFF**, never in the browser. Introduce a
per-domain source interface (starting with `IZaakSource` for the cases read path) whose default
implementation reads the local SQLite store and whose alternate implementation calls OpenZaak —
selected by a config flag (`Zgw:Enabled`, default false). Each implementation maps into the
**existing** wire DTO (`ApplicationSummaryDto`), so the `/api/v1` contract and the FE are
untouched. The BFF holds the client secret and **mints a fresh JWT per outbound call**.
This is deliberately a **thin vertical slice** (read-only zaken, WP-49); create/documents/
notifications follow the same seam in later slices (WP-50/51/52) rather than being scaffolded
up front — the migration stance ADR-0001 already prescribes.
## Consequences
- **+** The FE is production-ready as-is: swapping to OpenZaak is backend-only, behind one
config flag, with zero DTO/api-client drift. The POC still runs offline (default = local).
- **+** The seam is unit-testable without a live server: the JWT minter, the ZGW→DTO mapper,
and the paginating source are all covered with fixtures + a stub `HttpMessageHandler`.
- **+** URL-as-identity and cross-service joins are contained in one mapper; nothing downstream
sees a ZGW shape.
- **** Only the cases **read** path has a source interface today; other endpoints still call
static stores directly. Each future slice introduces its own seam as needed (not a big-bang
repository refactor).
- **** `IZaakSource` is synchronous (matching the existing sync endpoint + local store), so
`OpenZaakZaakSource` does sync-over-async; fine under ASP.NET Core (no sync-context), to be
made async if OpenZaak becomes the default. Marked with a `ponytail:` note at the call site.
- **Shipped with this ADR (WP-49):** `IZaakSource` + `LocalZaakSource` (default) +
`OpenZaakZaakSource` (config-gated), the `Zgw/` client (`ZgwOptions`, `ZgwTokenProvider`,
`ZgwZaakMapper`), and the reference guide [openzaak-integration.md](../openzaak-integration.md).
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), create-zaak (WP-50),
Documenten/DRC upload + link (WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to
docker-compose.