Files
ehoandClaude Sonnet 5 3ff80c124f feat(openzaak): bounded retry + flagged write divergence (WP-60)
Local aanvraag/document writes and their paired ZGW writes aren't
transactional; a ZGW failure after the local write succeeds used to
diverge silently. ZgwHttpClient now retries transport-shaped failures
(not 500, which can follow a partial commit on the non-idempotent
statussen/rollen POSTs), and a ZGW failure that survives retry sets
Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing
or diverging quietly. No outbox/reconcile job: three request-triggered
write paths don't justify a persisted queue that would also need to
carry citizen PII for the JWT audit claims.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:11:55 +02:00

80 lines
5.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
- **Also shipped (WP-50):** `IZaakSource.CreateZaak` — the first write. Submitting an aanvraag
now also creates a Zaak + Status + Rol in OpenZaak when `Zgw:Enabled=true`, routed through the
existing submit endpoint with zero DTO change (same seam, same anti-corruption boundary).
- **Also shipped (WP-51):** `IDocumentSource` (`LocalDocumentSource`/`OpenZaakDocumentSource`,
same config-gated seam shape) — an upload registers a Documenten/DRC enkelvoudiginformatieobject
and, once a zaak exists, a submit links it in with a zaakinformatieobject.
- **Also shipped (WP-60):** bounded retry in `ZgwHttpClient` for transport-shaped ZGW failures,
plus a flagged (not silent) divergence — `Aanvraag.ZgwError` + a `zgw:divergence` audit row —
when a ZGW write still fails after retry. No outbox/background worker (see WP-60 for the
ladder check that ruled it out for this POC's write volume).
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), Notificaties/NRC webhooks
(WP-52, shipped instead as a direct-to-BFF delivery in WP-58), adding OpenZaak to
docker-compose, an automated reconciliation/repair job for a flagged divergence (WP-60).