# OpenZaak / ZGW integration — how the BFF connects (& how to extend) How the BFF sources (and now creates) cases, and uploads/links documents, against a real **OpenZaak** (ZGW APIs) while the frontend stays unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page is _how the seam is built and how to add the next slice_. Built in [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken), [WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (create-zaak), and [WP-51](../project/backlog/WP-51-openzaak-documenten.md) (Documenten/DRC upload + zaak link). ## The one rule: OpenZaak sits behind the BFF, never in the browser The Angular app only ever sees the BFF's decision DTOs (BFF-lite, ADR-0001). All ZGW awkwardness — URL-as-identity, cross-service joins, JWT auth, pagination — is absorbed by the .NET BFF. Flipping the data source from local SQLite to OpenZaak is a **backend config change** with **zero frontend change and no api-client drift**. ## The seam (data source by config) - `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and `CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping. `CreateZaak` also returns the zaak's URL (`ZaakUrl`, null under the local source) so WP-51 can later link documents to it. - `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore` (offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit endpoint already computed locally — no external call. - `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`. `CreateZaak` posts a Zaak, then a Status, then a Rol (see below). - `Data/IDocumentSource.cs` — the documents seam (WP-51), sibling of `IZaakSource`: `Upload` and `LinkToZaak`. `Data/LocalDocumentSource.cs` is the same `DocumentStore.Add`/`Link` calls the upload/submit endpoints used to make inline; `Zgw/OpenZaakDocumentSource.cs` also registers each upload as a DRC document and links it to a zaak once one exists. - Wiring (`Program.cs`): `if (Zgw:Enabled)` registers `OpenZaakZaakSource` + `OpenZaakDocumentSource`, else `LocalZaakSource` + `LocalDocumentSource`. The `/admin/cases` GET, the `/uploads` POST, and the `/applications/{id}/submit` POST all resolve their seam from DI — routes + DTOs untouched either way. ## Create-zaak (WP-50) — the first write `POST /applications/{id}/submit` already persists the aanvraag locally (`ApplicationStore.Submit` — unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint just asks the seam for `(Referentie, Status, ZaakUrl)` and returns the first two, unchanged, in `SubmitApplicationResponse` (`ZaakUrl` is persisted via `ApplicationStore.SetZaakUrl` for WP-51's document link, not returned to the FE). Under the default (local) source this returns precisely what was just computed; under OpenZaak, three calls happen in order: 1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]` (OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie` (RSIN) from config, `identificatie` set to the **same** reference `ApplicationStore.Submit` already generated — so the human-readable reference matches in both places, not two independently-generated ones. 2. **POST status** (`{ZrcBaseUrl}/statussen`) — `statustype` resolved via a Catalogi GET (`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened. 3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET (`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn` set to the aanvraag's owner (BSN) — the acting citizen resolved by the identity seam (WP-53). The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak (`ZgwZaakMapper.ToCreatedStatusDto`). ponytail shortcut still standing: "first statustype/roltype Catalogi returns" rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial status and initiator role. The "no compensating transaction" gap this section used to flag here is closed by WP-60 — see "Write resilience" below. ## Write resilience (WP-60) The local write (`ApplicationStore.Submit`, `DocumentStore.Add`/`Link`) and its paired ZGW write aren't transactional — this section covers what happens when the ZGW half fails after the local half already committed, closing the one gap the sections above used to flag as needing "retry/reconciliation or an outbox" before this integration could be called production-ready. Deliberately **not** an outbox: three write paths, each triggered by exactly one interactive request, don't justify a persisted queue (which would also need to carry the acting citizen's BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the full reasoning. - **Bounded retry, in `ZgwHttpClient`.** Every ZGW call gets up to 3 attempts (200ms, doubling) on transport-shaped failures — 429/502/503/504/408, connection errors, timeouts — with a fresh request and JWT per attempt (a sent request/content can't be resent). **500 is deliberately not retried**: it can follow a partial commit on the two non-idempotent POSTs (`/statussen`, `/rollen`), so retrying risks a duplicate write. The create-zaak/document POSTs are additionally safe to retry because OpenZaak enforces uniqueness on (`bronorganisatie`, `identificatie`) — and WP-50/51 already set `identificatie` to the locally-generated reference/document id, so a retry after a lost response 400s instead of duplicating. - **The local write is never rolled back.** Un-submitting a local aanvraag after a partial ZGW failure (e.g. the zaak POST succeeded but `/statussen` didn't) would let the citizen resubmit under a _new_ reference, orphaning the first zaak — worse than leaving it flagged. - **A caught ZGW failure is flagged, not silent.** `Program.cs`'s submit endpoint wraps `CreateZaak` and `LinkToZaak` in separate try/catches (separate so a create-zaak failure doesn't also skip the still-local document link) and, on catch, logs the error, sets `Aanvraag.ZgwError` (non-null = "the ZGW side of this submit didn't complete"), and records a `zgw:divergence` audit row (same `AuthzAuditStore` trail every other decision uses, visible at `/beheer/audit`) — see `RecordZgwDivergence`. The endpoint still returns 200 with the local reference/status: that's truthful (the reference _is_ what would become the zaak's `identificatie`) and never branches on `Zgw:Enabled` (an offline `LocalZaakSource` never throws, so the catch is dead code there). The besluit endpoint (WP-66, see below) wraps `IZaakSource.RecordBesluit` in the same try/catch → `RecordZgwDivergence` shape — the second write pair this section's "Repair" bullet used to anticipate. - **The document upload path flags differently.** `OpenZaakDocumentSource.Upload` catches its own ZGW failure (config gap or transport) and logs it, but doesn't set a separate flag column — `DocumentStore.Get(id).DrcUrl == null` is already the meaningful "not registered in ZGW yet" detector `LinkToZaak` skips on, so no second mechanism is needed for that half. - **Repair.** No automated reconcile job exists yet — a flagged zaak is repairable on demand because its (would-be) `identificatie` always equals the aanvraag's `Referentie`, so a future admin action can `GET /zaken?identificatie=...` and either adopt the existing zaak or retry `CreateZaak`/`RecordBesluit`. WP-66 landed as the second write pair without needing an outbox — a real deployment is still the trigger to re-ask that question, not slice count on its own. ## Documenten / DRC upload + zaak link (WP-51) `POST /uploads` and `POST /applications/{id}/submit` route through `IDocumentSource` the same way submit routes through `IZaakSource`: the local write (`DocumentStore.Add`/`Link`) always happens first — it stays the record of truth for preview/download/audit regardless of `Zgw:Enabled` — and `OpenZaakDocumentSource` additionally does the DRC side-effect: 1. **Upload** — POST `enkelvoudiginformatieobjecten` (`{DrcBaseUrl}`) with the file's base64 content, `informatieobjecttype` resolved from `Zgw:InformatieobjecttypeUrls[categoryId]` (the document analogue of `ZaaktypeUrls`), `identificatie` set to the local document id. The returned DRC url is persisted (`DocumentStore.SetDrcUrl`) so the link step below doesn't need to re-upload. 2. **Link to zaak** — once `IZaakSource.CreateZaak` has returned a `ZaakUrl` (persisted via `ApplicationStore.SetZaakUrl`), submit calls `documents.LinkToZaak(documentIds, zaakUrl)`, which POSTs a `zaakinformatieobjecten` (`{ZrcBaseUrl}`) per document that has a `DrcUrl`. Documents uploaded before a zaak existed (or under a config gap) have no `DrcUrl` yet and are silently skipped — same "nothing extra to link" behaviour as the local source. `ZgwHttpClient` (shared GET/POST-with-bearer-JWT plumbing) was factored out of `OpenZaakZaakSource` once `OpenZaakDocumentSource` needed the identical boilerplate. `vertrouwelijkheidaanduiding` is driven by a per-document-type stamdata table (WP-59, `Stamdata/documentconfidentialiteit.json`, ADR-0004), falling back to `"openbaar"` for any category absent from it. Unlike the zaak side, an upload's ZGW failure (past `DocumentStore.Add`) is caught and logged rather than persisted as a separate flag column — see "Write resilience" below for why the two write paths differ. ## Besluit write (WP-66) `POST /beoordeling/{id}/besluit` (the behandelportal's decision endpoint, WP-65b) routes its ZGW side-effect through `IZaakSource.RecordBesluit` the same way submit routes through `CreateZaak`: the local write (`ApplicationStore.RecordBesluit`) always happens first and stays the record of truth, then `OpenZaakZaakSource` additionally POSTs a new `statussen` entry to the aanvraag's zaak (`Aanvraag.ZaakUrl`, set by `CreateZaak`). There is no Besluiten (BRC) call here — the harness's catalogus (WP-56) provisions only a begin/eind `statustype` pair per zaaktype (`Ontvangen`/`Afgehandeld`), not one per decision outcome, so a real Besluiten API integration would need its own `besluittype` provisioning first (still "later" in the table above). Instead this reuses the exact statustype-resolution pattern `CreateZaak` already has (`FirstStatustypeUrlAsync`), just picking the highest `volgnummer` (`LastStatustypeUrlAsync`) instead of the lowest, and carries the besluit (`Goedkeuren`/`Afwijzen`/`MeerInfoOpvragen`) plus the behandelaar's toelichting in the status's free-text `statustoelichting` field so the outcome is still visible on the ZGW side. `RecordBesluit` is a no-op if the aanvraag never got a zaak (`Zgw:Enabled` was off at submit time, or the create diverged) — same "nothing to do" skip `LinkToZaak` uses for a null `zaakUrl`. `LocalZaakSource.RecordBesluit` is a no-op outright — the local decision already IS the record of truth there. `caller` is the acting `MedewerkerCaller` (WP-62), so the minted ZGW JWT's audit claims reflect the behandelaar, not a static identity. ## The ZGW client (`backend/src/BigRegister.Api/Zgw/`) - `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs (`ZrcBaseUrl`, `ZtcBaseUrl`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five ZGW APIs are separate base URLs; slices 1–3 need Zaken (ZRC), Catalogi (ZTC), and Documenten (DRC). - `ZgwTokenProvider.cs` — mints an **HS256 JWT per call** (`iss`/`client_id`/`iat`/`user_id`/ `user_representation`). No refresh flow — OpenZaak expires tokens 1h past `iat`, so per-call minting is the recommended pattern. Hand-rolled (no `Microsoft.IdentityModel.*` dependency). - `ZgwHttpClient.cs` — shared GET/POST-with-bearer-JWT plumbing used by both `OpenZaakZaakSource` and `OpenZaakDocumentSource`. Every request also carries `Accept-Crs`/`Content-Crs: EPSG:4326` — every ZGW call must declare a coordinate reference system even when no geometry is involved, or a real OpenZaak 412s ("Content-Crs header ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests never modelled the header, so it had shipped silently since WP-49/50. - `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is where **URL identity** becomes the trailing uuid; `Type` takes the internal aanvraag-type key (`AanvraagTypeFor`, below) — a real bug (found via a live behandelportal walkthrough, fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's `AANVRAAG_TYPES` trust boundary always rejected. - `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, maps each zaak's zaaktype URL back to the internal key via `Zgw:ZaaktypeUrls` (`AanvraagTypeFor` — a local lookup, no Catalogi round-trip), attaches `Authorization: Bearer `. - `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern. - `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the **inbound** NRC webhook, not a source/mapper: see the dedicated section below. ## Notificaties (NRC) webhook — inbound, WP-52 Unlike ZRC/ZTC/DRC (which the BFF calls outbound as a client), the Notificaties API calls **this BFF** — OpenZaak POSTs a `NotificatieDto`-shaped body to `POST /api/v1/zgw/notificaties` on every event on a subscribed kanaal. Auth is inverted too: no per-call JWT, just a fixed shared secret compared in constant time (`CryptographicOperations.FixedTimeEquals`) against `ZgwOptions.NotificatieAuthorization` — an unconfigured (empty) secret rejects every call, never accepts. Every attempt (accept or reject) is written to the same `AuthzAuditStore` the authz gate uses (`action="zgw:notificatie"`, `resource=hoofdObject` — a URL, not PII, `role="nrc"`) via the store directly, since there's no `Principal` for an NRC caller to run through the `AuditAuthz` helper. There is no cache to invalidate today (`/admin/cases` and every other read already goes straight to `IZaakSource` per call), so a valid notification's only visible effect right now is the audit row proving the round-trip works end-to-end. Add real invalidation at the `// ponytail:` marker in `Program.cs` if a cache is ever introduced. **A real deployment provisioning is out-of-band, one-time config against a live OpenZaak — not app code.** OpenZaak does not serve the Notificaties API itself — it's a separate application (`open-notificaties`, its own image/DB/celery stack). Register the `abonnement` once (e.g. via Open Notificaties' admin UI or a `POST` to its Abonnementen API) pointing at this BFF's public URL: ```jsonc { "callbackUrl": "https:///api/v1/zgw/notificaties", "auth": "", "kanalen": [{ "filters": {}, "naam": "zaken" }], } ``` `auth` is sent verbatim as the `Authorization` header on every callback (the NRC's `auth_type=api_key` default) — no `Bearer` prefix, matching this endpoint's plain string compare. **The dev harness (WP-58) skips the NRC entirely** — see "Notifications-enabled profile" below. ## Identity — the acting citizen (WP-53) Everything above used to hardcode a single owner (`DocumentStore.DemoOwner`) and a single static ZGW audit identity (`ZgwOptions.UserId`/`UserRepresentation`). WP-53 replaced both with one per-request `CallerIdentity` (subject BSN + display name + role, `Domain/Authorization/ CallerIdentity.cs`): - **Resolution**: an `IIdentityProvider` runs once per request (middleware in `Program.cs`, right after the correlation-id middleware) into `HttpContext.Items`, read back everywhere via `ctx.Caller()`. `StubIdentityProvider` (the only implementation today, **not a security boundary**) reads the existing `X-Role` header (unchanged — mirrors the FE's `?role=` toggle) plus a new `X-Subject` header for the BSN, defaulting to the single seeded citizen — so every request that doesn't send `X-Subject` (which is every request today; the FE never sends it) behaves exactly as before this WP. A production provider swaps in real OIDC/DigiD claims without touching a single consumer. - **`Authz.ResolvePrincipal(ctx)` kept its exact signature** — it now reads `ctx.Caller().Role` instead of the header directly, so its ~15 call sites across `Program.cs` needed no changes. - **Ownership**: every endpoint that used to pass `DocumentStore.DemoOwner` to a store (`ApplicationStore`, `DocumentStore`, `BriefStore`) now passes `ctx.Caller().Bsn`. - **The ZGW JWT** (`ZgwTokenProvider`) grew a `Mint(CallerIdentity)` overload alongside the original parameterless `Mint()`: citizen-scoped calls (create-zaak, upload, zaak-link, the citizen's own case list) mint with the caller's BSN/name as `user_id`/`user_representation`; calls not tied to one citizen (the admin cross-owner list, Catalogi metadata lookups) keep minting with the BFF's own system identity from `ZgwOptions`. `ZgwHttpClient.GetAsync`/ `PostAsync` take an optional `CallerIdentity?` that picks which `Mint` overload runs. - **Citizen-scoped reads**: `IZaakSource` gained `ListMyCases(CallerIdentity, now)` alongside the existing admin-only `ListCases(now)`. `LocalZaakSource` filters `ApplicationStore.List(bsn)` (unchanged local behaviour); `OpenZaakZaakSource` appends ZGW's `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=` query filter to `GET {ZrcBaseUrl}/zaken`. `GET /applications` (the citizen's own dashboard) now routes through this instead of calling `ApplicationStore` directly — the last "reads a static store directly" gap the ACL caveat below used to flag for a citizen-facing endpoint. **WP-62 split `CallerIdentity` into the two actor kinds ADR-0002 §3 requires** — a `ZorgverlenerCaller` (citizen, the WP-53 shape above) or a `MedewerkerCaller` (backoffice employee: `MedewerkerId` + `Rollen`, no BSN), backend-only, unused by any frontend until WP-64. `StubIdentityProvider` selects the medewerker kind when `X-Medewerker` is present (its value is the medewerkerId; `X-Rollen` is a comma-separated rollen list, defaulting to `Behandelaar`) — takes precedence over `X-Subject`; absent, every request today, falls through to the zorgverlener path unchanged. `CallerIdentity.SubjectId` (BSN or medewerkerId) is what `ZgwTokenProvider.Mint` now reads instead of `.Bsn` directly, so the ZGW JWT's `user_id` is correct for either kind with no further change (WP-66's besluit write mints this for free). The ownership-scoping seams (`ctx.Zorgverlener()`, `IDocumentSource.Upload`, `IZaakSource .ListMyCases`) are narrowed to `ZorgverlenerCaller` — a medewerker hitting a citizen-scoped SSP endpoint is a 500 today (unreachable, since no consumer sends `X-Medewerker` yet; WP-64 upgrades it to a 403 once real backoffice traffic exists). `Authz.CanBeoordelen(CallerIdentity)` is the first medewerker capability (rol-based, `MedewerkerRol.Behandelaar`), shipped only as a decision flag, never a rollen matrix. ## The five ZGW APIs (context for later slices) | API | Component | Used by | | ------------ | --------- | ---------------------------------------------------------------- | | Zaken | ZRC | slice 1 (read), WP-50 (create) | | Catalogi | ZTC | WP-50/66 (statustype/roltype/resultaattype for create + besluit) | | Documenten | DRC | WP-51 (upload + zaak↔document link) | | Besluiten | BRC | later (formal decisions) | | Notificaties | NRC | WP-52 (live status via webhooks, not polling) | ## How to add the next slice 1. **Read** — extend `IZaakSource` (or add a sibling interface, like `IDocumentSource`, WP-51) with the new operation; implement it on both the local store and the OpenZaak source. Keep the return type the existing DTO so the FE never changes. 2. **Write** (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (`status` + `rol` for a zaak; `zaakinformatieobject` for a document). Route it through the existing submit/mutation seam. 3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never the authority (ADR-0001). ## Coupling Low and one-directional. Consumer coupling is near zero — `IZaakSource`/`IDocumentSource` are each injected at one endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in `Zgw/`: add a slice by adding a source method + a mapper case, not by touching the FE or the contract. Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` (and its `OpenZaakDocumentSource` sibling) — make the read/write paths async if OpenZaak becomes the default. ## Run against real OpenZaak (WP-54) Everything above was, until WP-54, only proven against fixtures + a stub `HttpMessageHandler` — no live OpenZaak. `backend/openzaak/` is a **separate**, opt-in docker-compose harness (never merged into the root `docker-compose.yml`, which stays FE+BFF-only) that brings up a real OpenZaak, seeds a minimal catalogus/zaaktype/zaak via a bootstrap script, and backs one xunit test (`OpenZaakIntegrationTests.cs`, tagged `Category=Integration`) that points the BFF at it with `Zgw:Enabled=true`. See `backend/openzaak/README.md` for the exact commands; the test is excluded from the default `dotnet test` run and from CI (`--filter Category!=Integration`) since it only passes with the harness up. This is also where the `Content-Crs`/`Accept-Crs` header gap above was found: a real OpenZaak enforces ZGW's geo-header requirement in a way no stub-based test could catch, since a stub never rejects an unexpected (or missing) header. That is the harness's whole point — proving the seam against real protocol behaviour, not just the shapes we already assumed. ### Notifications-enabled profile (WP-58) The base harness above runs with `NOTIFICATIONS_DISABLED: 'true'` (no celery worker) — fine for proving the read/write ZGW seam, but it means a write to a notified resource never actually delivers anything. `docker-compose.openzaak.notificaties.yml` is an opt-in overlay that adds the one celery worker OpenZaak needs to deliver a notification, and flips that flag off. The two changes are inseparable: the moment `NOTIFICATIONS_DISABLED` is false, OpenZaak's `NotificationsConfig` must have a client configured or every write to a notified resource 500s and rolls back (`NOTIFICATIONS_GUARANTEE_DELIVERY` defaults true) — so `bootstrap-notificaties.sh` configures that client in the same step. A real Notificaties API (NRC) is a separate application this harness doesn't stand up (see the "Notificaties webhook" section above) — reproducing it here (its own DB + celery + a real `abonnement`/kanaal registration) would roughly triple the harness for a benefit this dev loop doesn't need: there's only ever one subscriber (this repo's own BFF). Instead `bootstrap-notificaties.sh` points OpenZaak's `NotificationsConfig` straight at the BFF's webhook via a `zgw_consumers.Service` (`auth_type=api_key`, so the configured secret is sent verbatim as the `Authorization` header — exactly what the endpoint's plain string-compare expects). Same delivery proof (`write → OpenZaak's celery worker → a real HTTP POST → the BFF's audit trail`), far less to stand up and keep alive. A real deployment with more than one subscriber, or that needs kanaal-filtered fan-out, needs a real NRC + `abonnement` — this harness's shortcut doesn't model that. The overlay's `celery` worker joins the repo root's own `docker compose up` network (by name, `api`) to reach the BFF — `host.docker.internal:host-gateway` was tried first, but this environment's rootless Podman doesn't route container→host-port traffic through it (DNS resolves, every TCP connect times out); container-to-container is the reliable path regardless of Docker vs. Podman. That means the notifications profile needs the repo root's `docker compose up` (or an equivalent `api` container on that network) running too, with `Zgw__NotificatieAuthorization` set: ```bash docker compose run --rm -d --name atomic-design-poc-api-1 --service-ports \ -e Zgw__NotificatieAuthorization='' api # repo root cd backend/openzaak docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d ./bootstrap-catalogus.sh BFF_AUTH='' ./bootstrap-notificaties.sh BFF_AUTH='' ./verify-notificatie.sh # proves a real delivery, end to end ``` Verified live in-session: the preflight in `bootstrap-notificaties.sh` proved the BFF's auth gate both ways (204 with the secret, 401 without/wrong), `verify-notificatie.sh` found the delivered `zgw:notificatie`/`allow` audit row for the PATCHed zaak, and re-running both scripts against the already-configured client stayed idempotent (no errors, no duplicate `Service` rows). ## Config ```jsonc // appsettings.json — off by default (POC runs offline on the local store) "Zgw": { "Enabled": true, "ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1", "ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1", "DrcBaseUrl": "https://open-zaak.example/documenten/api/v1", "ClientId": "big-register", "Secret": "", "UserId": "", "UserRepresentation": "", // WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map. "Bronorganisatie": "", "VerantwoordelijkeOrganisatie": "", "ZaaktypeUrls": { "registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/", "herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/", "intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/" }, // WP-51 (Documenten): upload category → informatieobjecttype URL map. "InformatieobjecttypeUrls": { "identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/", "diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/" }, // WP-52 (Notificaties): NRC base URL — a SEPARATE host/app from OpenZaak itself // (documentation/provisioning only, no outbound call) + the shared secret NRC must send // back on every webhook POST. "NrcBaseUrl": "https://open-notificaties.example/api/v1", "NotificatieAuthorization": "" } ``` ## Anti-corruption layer — two nested boundaries (what to learn) This setup is an anti-corruption layer (ACL) **twice over**, and seeing them as a pair is the lesson worth taking away: 1. **The BFF guards everything against upstream systems.** OpenZaak's foreign model — URL-as-identity, a `zaaktype` that is a URL _into another service_, `{count,next,previous, results}` pagination, HS256 JWT auth — never leaves the BFF. `ZgwZaakMapper` translates it into the BFF's own `ApplicationSummaryDto`; `IZaakSource` makes the boundary swappable (`LocalZaakSource` vs `OpenZaakZaakSource` return the _same_ DTO). 2. **The Angular app guards itself against the BFF.** `infrastructure/` is the only layer that touches the network (lint-enforced); every response crosses a `parse*` (`Result`) trust boundary + a `toDomain` mapper before any domain/UI code sees it (ADR-0001, ARCHITECTURE §6). The DTO at `/api/v1` is the membrane between them — which is why wiring OpenZaak touched **zero frontend code and produced zero api-client drift**. That was the proof the ACL held. Principles this demonstrates: - **An ACL is a _mapping_, not a passthrough.** A DTO that is the upstream shape renamed is corruption with extra steps; the valuable ACLs here (`ZgwZaakMapper`, the `parse*`/`toDomain` pairs) actively translate a foreign model into a local one. - **Put the ACL where trust changes, and make it the _only_ place.** One choke point per boundary — the `Zgw/` folder + `IZaakSource` server-side, `infrastructure/` client-side. - **Decision DTOs and the ACL are complementary.** BFF-lite (server decides, FE renders) is an ACL against _business-rule_ drift, layered on the ACL against _data-shape_ drift. - **A real seam is swap-testable offline.** Because the ACL returns a stable DTO, the ZGW client is unit-testable with fixtures + a stub handler — no live OpenZaak. - **Mark the honest edges.** The `ponytail:` sync-over-async note and the "coarse status map" comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be complete on day one, but its shortcuts should be visible. Caveat: `IZaakSource` covers the cases **read (admin + citizen-scoped) + create** path (WP-49/50/53), `IDocumentSource` covers **upload + zaak-link** (WP-51), the inbound `POST /zgw/notificaties` webhook (WP-52) closes the read/write/document/notify arc, and WP-53 threaded a real per-request `CallerIdentity` through all of it (ownership + the ZGW audit claims), and WP-54 added a docker OpenZaak harness + opt-in integration test proving the seam against a live instance (and, in doing so, caught the missing `Content-Crs`/`Accept-Crs` headers noted above). Other BFF endpoints (reference data like `SeedData`'s BRP/DUO mimics) still read static stores directly — ACL-ready (the DTO seam exists) but not yet swappable, and not part of this arc. That closes the phase-9 OpenZaak/ZGW arc (WP-49..54). ## See also - [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision. - [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change. - [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53 (identity seam + citizen-scoping), [WP-54](../project/backlog/WP-54-openzaak-integration-harness.md) (integration harness). - `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs`/`Data/IDocumentSource.cs` — the seams; `backend/openzaak/` — the live-OpenZaak test harness (WP-54). - [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).