diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs
new file mode 100644
index 0000000..623388f
--- /dev/null
+++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs
@@ -0,0 +1,21 @@
+using BigRegister.Api.Contracts;
+
+namespace BigRegister.Api.Data;
+
+///
+/// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an
+/// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak)
+/// replace the local SQLite store behind the same
+/// contract — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
+///
+/// Default binding is (offline). Setting Zgw:Enabled=true
+/// swaps in OpenZaakZaakSource. Slice 1 is read-only; create/update stay on the
+/// local write path until WP-50. The interface returns the wire DTO (not the domain
+/// ) precisely so each source owns its own mapping — the OpenZaak
+/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag.
+///
+public interface IZaakSource
+{
+ /// Every case, newest-first (the admin cross-owner list, WP-36).
+ IReadOnlyList ListCases(DateTimeOffset now);
+}
diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
new file mode 100644
index 0000000..3d24f0f
--- /dev/null
+++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
@@ -0,0 +1,15 @@
+using BigRegister.Api.Contracts;
+
+namespace BigRegister.Api.Data;
+
+///
+/// The default — the cases come from the local SQLite
+/// , exactly as before the seam existed (WP-49). Zero
+/// behaviour change: this is the same ListAll().ToAdminSummaryDto(now) the
+/// /admin/cases endpoint used to call inline.
+///
+public sealed class LocalZaakSource : IZaakSource
+{
+ public IReadOnlyList ListCases(DateTimeOffset now) =>
+ ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
+}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index be2545a..57c1b4c 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -10,6 +10,7 @@ using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
+using BigRegister.Api.Zgw;
using BigRegister.Stamdata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Console;
@@ -40,6 +41,22 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
// override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
+// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
+// (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never
+// changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the
+// OpenZaak client (needs the base URLs + credentials in the Zgw config section).
+var zgw = builder.Configuration.GetSection("Zgw").Get() ?? new ZgwOptions();
+if (zgw.Enabled)
+{
+ builder.Services.AddSingleton(zgw);
+ builder.Services.AddSingleton();
+ builder.Services.AddHttpClient();
+}
+else
+{
+ builder.Services.AddSingleton();
+}
+
var app = builder.Build();
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
@@ -314,11 +331,8 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.Produces(StatusCodes.Status404NotFound);
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
-api.MapGet("/admin/cases", (HttpContext ctx) => CasesAdmin(ctx, () =>
-{
- var now = DateTimeOffset.UtcNow;
- return Results.Ok(ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList());
-}))
+api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
+ Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Produces>()
.ProducesProblem(StatusCodes.Status403Forbidden);
diff --git a/backend/src/BigRegister.Api/Stamdata/Beroep.cs b/backend/src/BigRegister.Api/Stamdata/Beroep.cs
new file mode 100644
index 0000000..9231558
--- /dev/null
+++ b/backend/src/BigRegister.Api/Stamdata/Beroep.cs
@@ -0,0 +1,13 @@
+namespace BigRegister.Stamdata;
+
+///
+/// One row of the beroepen (BIG professions) stamdata (config-as-code, ADR-0004): the
+/// master list of registered professions. The first property () is the
+/// table key by convention (see StamdataTable) and the target of the FK-like
+/// references from opleidingen and specialismen (see
+/// StamdataValidationTests). Non-temporal — a profession is either registrable or it
+/// isn't; the mapping's validity window lives on opleidingen.
+///
+/// This is the typed shape beroepen.json deserializes into.
+///
+public sealed record Beroep(string Code, string Naam);
diff --git a/backend/src/BigRegister.Api/Stamdata/Opleiding.cs b/backend/src/BigRegister.Api/Stamdata/Opleiding.cs
new file mode 100644
index 0000000..90b0fd2
--- /dev/null
+++ b/backend/src/BigRegister.Api/Stamdata/Opleiding.cs
@@ -0,0 +1,14 @@
+namespace BigRegister.Stamdata;
+
+///
+/// One row of the opleidingen (study programs) stamdata (config-as-code, ADR-0004): a study
+/// program and the BIG profession it leads to, valid for a period. The first property
+/// () is the table key; holds a beroepen.Code
+/// — a stamdata → stamdata reference the build gate enforces (StamdataValidationTests),
+/// so orphaning a beroep fails CI, never prod. Temporal (/
+/// , half-open [van, tot)): a null
+/// means "still valid"; a future pre-schedules a program.
+///
+/// This is the typed shape opleidingen.json deserializes into.
+///
+public sealed record Opleiding(string Code, string Naam, string Beroep, DateOnly GeldigVan, DateOnly? GeldigTot);
diff --git a/backend/src/BigRegister.Api/Stamdata/Specialisme.cs b/backend/src/BigRegister.Api/Stamdata/Specialisme.cs
new file mode 100644
index 0000000..d3a2ba6
--- /dev/null
+++ b/backend/src/BigRegister.Api/Stamdata/Specialisme.cs
@@ -0,0 +1,13 @@
+namespace BigRegister.Stamdata;
+
+///
+/// One row of the specialismen (professional specialisms) stamdata (config-as-code,
+/// ADR-0004): a registered specialism and the base BIG profession it belongs to. The first
+/// property () is the table key; holds a
+/// beroepen.Code — the second stamdata → stamdata reference the build gate enforces
+/// (StamdataValidationTests), demonstrating one table referenced by two others.
+/// Non-temporal.
+///
+/// This is the typed shape specialismen.json deserializes into.
+///
+public sealed record Specialisme(string Code, string Naam, string Beroep);
diff --git a/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs b/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs
index 52dee56..7b73de0 100644
--- a/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs
+++ b/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs
@@ -11,6 +11,9 @@ public static class StamdataCatalog
public static readonly IReadOnlyList All = new[]
{
StamdataTable.Of("professions", "Opleiding → beroep"),
+ StamdataTable.Of("beroepen", "Beroepen (BIG)"),
+ StamdataTable.Of("opleidingen", "Opleidingen → beroep"),
+ StamdataTable.Of("specialismen", "Specialismen → beroep"),
// PolicyQuestions and future tables migrate here, same one-liner each.
};
diff --git a/backend/src/BigRegister.Api/Stamdata/beroepen.json b/backend/src/BigRegister.Api/Stamdata/beroepen.json
new file mode 100644
index 0000000..a79ddc8
--- /dev/null
+++ b/backend/src/BigRegister.Api/Stamdata/beroepen.json
@@ -0,0 +1,10 @@
+[
+ { "code": "arts", "naam": "Arts" },
+ { "code": "verpleegkundige", "naam": "Verpleegkundige" },
+ { "code": "fysiotherapeut", "naam": "Fysiotherapeut" },
+ { "code": "apotheker", "naam": "Apotheker" },
+ { "code": "tandarts", "naam": "Tandarts" },
+ { "code": "verloskundige", "naam": "Verloskundige" },
+ { "code": "gz-psycholoog", "naam": "Gezondheidszorgpsycholoog" },
+ { "code": "psychotherapeut", "naam": "Psychotherapeut" }
+]
diff --git a/backend/src/BigRegister.Api/Stamdata/opleidingen.json b/backend/src/BigRegister.Api/Stamdata/opleidingen.json
new file mode 100644
index 0000000..549e391
--- /dev/null
+++ b/backend/src/BigRegister.Api/Stamdata/opleidingen.json
@@ -0,0 +1,10 @@
+[
+ { "code": "geneeskunde", "naam": "Geneeskunde", "beroep": "arts", "geldigVan": "2000-01-01", "geldigTot": null },
+ { "code": "verpleegkunde", "naam": "Verpleegkunde (hbo-v)", "beroep": "verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": null },
+ { "code": "fysiotherapie", "naam": "Fysiotherapie", "beroep": "fysiotherapeut", "geldigVan": "2000-01-01", "geldigTot": null },
+ { "code": "farmacie", "naam": "Farmacie", "beroep": "apotheker", "geldigVan": "2000-01-01", "geldigTot": null },
+ { "code": "tandheelkunde", "naam": "Tandheelkunde", "beroep": "tandarts", "geldigVan": "2000-01-01", "geldigTot": null },
+ { "code": "verloskunde", "naam": "Verloskunde", "beroep": "verloskundige", "geldigVan": "2000-01-01", "geldigTot": null },
+ { "code": "gz-psychologie", "naam": "Gezondheidszorgpsychologie", "beroep": "gz-psycholoog", "geldigVan": "2005-01-01", "geldigTot": null },
+ { "code": "inservice-a", "naam": "Inservice A-opleiding (verpleegkunde, oud)", "beroep": "verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": "2012-01-01" }
+]
diff --git a/backend/src/BigRegister.Api/Stamdata/specialismen.json b/backend/src/BigRegister.Api/Stamdata/specialismen.json
new file mode 100644
index 0000000..989e435
--- /dev/null
+++ b/backend/src/BigRegister.Api/Stamdata/specialismen.json
@@ -0,0 +1,8 @@
+[
+ { "code": "huisartsgeneeskunde", "naam": "Huisartsgeneeskunde", "beroep": "arts" },
+ { "code": "cardiologie", "naam": "Cardiologie", "beroep": "arts" },
+ { "code": "kindergeneeskunde", "naam": "Kindergeneeskunde", "beroep": "arts" },
+ { "code": "ziekenhuisfarmacie", "naam": "Ziekenhuisfarmacie", "beroep": "apotheker" },
+ { "code": "orthodontie", "naam": "Orthodontie", "beroep": "tandarts" },
+ { "code": "geriatriefysiotherapie", "naam": "Geriatriefysiotherapie", "beroep": "fysiotherapeut" }
+]
diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
new file mode 100644
index 0000000..b32761a
--- /dev/null
+++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
@@ -0,0 +1,82 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using BigRegister.Api.Contracts;
+using BigRegister.Api.Data;
+
+namespace BigRegister.Api.Zgw;
+
+/// One page of a ZGW list response — the uniform {count,next,previous,results}
+/// envelope every ZGW collection uses. next is a full URL (or null) to follow.
+public sealed record ZgwPage(
+ [property: JsonPropertyName("count")] int Count,
+ [property: JsonPropertyName("next")] string? Next,
+ [property: JsonPropertyName("results")] IReadOnlyList Results);
+
+///
+/// The backed by a real OpenZaak / ZGW Zaken API (WP-49). Reads
+/// zaken (following pagination), resolves each zaaktype's human label from the Catalogi API
+/// (cached), and maps into via .
+/// Selected only when Zgw:Enabled=true; the default stays .
+///
+/// Auth: a fresh HS256 JWT per request () on the Authorization
+/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution).
+///
+public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
+{
+ // ponytail: sync-over-async — IZaakSource is sync to match the local store + the existing
+ // sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the
+ // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
+ // default and this blocking call shows up under load.
+ public IReadOnlyList ListCases(DateTimeOffset now) =>
+ ListCasesAsync().GetAwaiter().GetResult();
+
+ private async Task> ListCasesAsync()
+ {
+ var zaken = await GetAllAsync($"{options.ZrcBaseUrl}/zaken");
+ var labels = new Dictionary();
+ var result = new List(zaken.Count);
+ foreach (var z in zaken)
+ {
+ if (!labels.TryGetValue(z.Zaaktype, out var label))
+ labels[z.Zaaktype] = label = await ZaaktypeLabelAsync(z.Zaaktype);
+ result.Add(ZgwZaakMapper.ToSummaryDto(z, label));
+ }
+ return result;
+ }
+
+ /// Follow the next links, accumulating every page's results.
+ private async Task> GetAllAsync(string url)
+ {
+ var all = new List();
+ string? next = url;
+ while (next is not null)
+ {
+ var page = await GetAsync>(next);
+ all.AddRange(page.Results);
+ next = page.Next;
+ }
+ return all;
+ }
+
+ /// A zaaktype's human label (omschrijving) from the Catalogi API.
+ private async Task ZaaktypeLabelAsync(string zaaktypeUrl)
+ {
+ var zt = await GetAsync(zaaktypeUrl);
+ return zt.Omschrijving;
+ }
+
+ private async Task GetAsync(string url)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Get, url);
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint());
+ req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ using var res = await http.SendAsync(req);
+ res.EnsureSuccessStatusCode();
+ return (await res.Content.ReadFromJsonAsync())
+ ?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
+ }
+
+ private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
+}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
new file mode 100644
index 0000000..2a51c6b
--- /dev/null
+++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
@@ -0,0 +1,34 @@
+namespace BigRegister.Api.Zgw;
+
+///
+/// Config for connecting to OpenZaak / the ZGW APIs (WP-49), bound from the Zgw
+/// section of appsettings. Disabled by default so the POC runs fully offline on the local
+/// SQLite store; set Zgw:Enabled=true (plus the URLs + credentials) to source cases
+/// from a real OpenZaak.
+///
+/// The ZGW standard is FIVE separate services, each its own base URL — Slice 1 only needs
+/// the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API (ZTC).
+/// The others (DRC/BRC/NRC) arrive with later slices (WP-51/52).
+///
+public sealed class ZgwOptions
+{
+ public bool Enabled { get; init; }
+
+ /// Zaken API (ZRC) base URL, e.g. https://open-zaak.example/zaken/api/v1.
+ public string ZrcBaseUrl { get; init; } = "";
+
+ /// Catalogi API (ZTC) base URL — used to resolve a zaaktype URL to its label.
+ public string ZtcBaseUrl { get; init; } = "";
+
+ /// Client ID registered in the Autorisaties API (goes into the JWT iss/client_id).
+ public string ClientId { get; init; } = "";
+
+ /// Client secret — the HS256 signing key for the JWT. Held only by the BFF, never the browser.
+ public string Secret { get; init; } = "";
+
+ /// End-user identity for the ZGW audit trail (JWT user_id). Wire from the session in prod.
+ public string UserId { get; init; } = "big-register-bff";
+
+ /// Human-readable end-user name for the audit trail (JWT user_representation).
+ public string UserRepresentation { get; init; } = "BIG-register BFF";
+}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs
new file mode 100644
index 0000000..b408581
--- /dev/null
+++ b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs
@@ -0,0 +1,43 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace BigRegister.Api.Zgw;
+
+///
+/// Mints the JWT that authenticates the BFF to the ZGW APIs (WP-49). OpenZaak expects a
+/// short-lived HS256 assertion signed with the client secret, carrying iss/
+/// client_id (identity), iat (issued-at), and user_id/
+/// user_representation (for the ZGW audit trail). There is no OAuth refresh dance —
+/// OpenZaak rejects tokens more than an hour past iat, so we mint a fresh token
+/// per call (cheap, keeps iat current and the audit user correct).
+///
+/// ponytail: hand-rolled base64url JWT with — a ZGW token is a
+/// plain HS256 JWS, so this ~15 lines beats adding Microsoft.IdentityModel.* just to sign
+/// three claims. Swap in a library if we ever need RS256 / JWKS.
+///
+public sealed class ZgwTokenProvider(ZgwOptions options)
+{
+ public string Mint()
+ {
+ var header = new { alg = "HS256", typ = "JWT" };
+ var payload = new
+ {
+ iss = options.ClientId,
+ iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ client_id = options.ClientId,
+ user_id = options.UserId,
+ user_representation = options.UserRepresentation,
+ };
+
+ var signingInput = $"{Encode(header)}.{Encode(payload)}";
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(options.Secret));
+ var signature = Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)));
+ return $"{signingInput}.{signature}";
+ }
+
+ private static string Encode(object o) => Base64Url(JsonSerializer.SerializeToUtf8Bytes(o));
+
+ private static string Base64Url(byte[] bytes) =>
+ Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs
new file mode 100644
index 0000000..327d521
--- /dev/null
+++ b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs
@@ -0,0 +1,58 @@
+using System.Text.Json.Serialization;
+using BigRegister.Api.Contracts;
+
+namespace BigRegister.Api.Zgw;
+
+///
+/// The subset of a ZGW Zaak (Zaken API / ZRC) the read slice needs. The full resource has
+/// dozens of fields; we bind only what maps to . Note the
+/// two ZGW traits that force an anti-corruption layer: is the resource's
+/// identity (not a bare id), and is a URL into another service
+/// (Catalogi/ZTC) that must be resolved to a human label.
+///
+public sealed record ZgwZaak(
+ [property: JsonPropertyName("url")] string Url,
+ [property: JsonPropertyName("identificatie")] string Identificatie,
+ [property: JsonPropertyName("zaaktype")] string Zaaktype,
+ [property: JsonPropertyName("startdatum")] DateOnly Startdatum,
+ [property: JsonPropertyName("einddatum")] DateOnly? Einddatum,
+ [property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum);
+
+///
+/// Anti-corruption map: ZGW Zaak → the existing the FE
+/// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype
+/// join get flattened away, so nothing downstream (the FE) sees ZGW shapes.
+///
+public static class ZgwZaakMapper
+{
+ /// Last path segment of a ZGW resource URL — the uuid that identifies it.
+ public static string Uuid(string url) => url.TrimEnd('/').Split('/').Last();
+
+ public static ApplicationSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel)
+ {
+ // ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
+ // one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
+ // Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end.
+ var status = z.Einddatum is null
+ ? new AanvraagStatusDto("InBehandeling", Referentie: z.Identificatie, Manual: true)
+ : new AanvraagStatusDto("Goedgekeurd", Referentie: z.Identificatie);
+
+ var created = Iso(z.Registratiedatum ?? z.Startdatum);
+ var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
+
+ return new ApplicationSummaryDto(
+ Id: Uuid(z.Url),
+ Type: zaaktypeLabel,
+ Status: status,
+ DocumentIds: Array.Empty(), // zaak↔document links arrive with WP-51 (DRC)
+ CreatedAt: created,
+ UpdatedAt: updated,
+ SubmittedAt: created,
+ Owner: z.Identificatie); // real initiator (rol/BSN) needs a rollen lookup — later slice
+ }
+
+ // Keep the wire shape identical to the local source (round-trip datetime): a ZGW date
+ // becomes midnight UTC so the FE's date parsing sees the same format either backend.
+ private static string Iso(DateOnly d) =>
+ d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o");
+}
diff --git a/backend/src/BigRegister.Api/appsettings.json b/backend/src/BigRegister.Api/appsettings.json
index 10f68b8..96cc344 100644
--- a/backend/src/BigRegister.Api/appsettings.json
+++ b/backend/src/BigRegister.Api/appsettings.json
@@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "_Zgw": "WP-49: set Enabled=true + the URLs/credentials to source cases from a real OpenZaak. Off = local SQLite store (offline POC default).",
+ "Zgw": {
+ "Enabled": false,
+ "ZrcBaseUrl": "",
+ "ZtcBaseUrl": "",
+ "ClientId": "",
+ "Secret": "",
+ "UserId": "big-register-bff",
+ "UserRepresentation": "BIG-register BFF"
+ }
}
diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
new file mode 100644
index 0000000..4a774f5
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
@@ -0,0 +1,78 @@
+using System.Net;
+using System.Text;
+using BigRegister.Api.Zgw;
+
+namespace BigRegister.Tests;
+
+///
+/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no
+/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches
+/// zaaktype labels, and always sends a Bearer token.
+///
+public class OpenZaakZaakSourceTests
+{
+ private const string ZrcBase = "https://oz.example/zaken/api/v1";
+ private const string ZtBase = "https://oz.example/catalogi/api/v1";
+
+ private static string Page1 => $$"""
+ { "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [
+ { "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1",
+ "zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01",
+ "einddatum": null, "registratiedatum": "2026-03-01" } ] }
+ """;
+
+ private static string Page2 => $$"""
+ { "count": 2, "next": null, "results": [
+ { "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2",
+ "zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01",
+ "einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] }
+ """;
+
+ private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }""";
+
+ [Fact]
+ public void Follows_pagination_caches_zaaktype_and_sends_bearer_token()
+ {
+ var handler = new StubHandler(url => url switch
+ {
+ _ when url == $"{ZrcBase}/zaken" => Page1,
+ _ when url == $"{ZrcBase}/zaken?page=2" => Page2,
+ _ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype,
+ _ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
+ });
+
+ var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
+ var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
+
+ var cases = source.ListCases(DateTimeOffset.UtcNow);
+
+ // Both pages accumulated.
+ Assert.Equal(2, cases.Count);
+ Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id));
+ Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type));
+ Assert.Equal("InBehandeling", cases[0].Status.Tag); // open
+ Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed
+
+ // Zaaktype resolved once despite two zaken sharing it (cache).
+ Assert.Single(handler.Requests, r => r.Contains("zaaktypen"));
+ // Every outbound request carried a Bearer token.
+ Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
+ }
+
+ private sealed class StubHandler(Func respond) : HttpMessageHandler
+ {
+ public List Requests { get; } = new();
+ public List AuthSchemes { get; } = new();
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var url = request.RequestUri!.ToString();
+ Requests.Add(url);
+ AuthSchemes.Add(request.Headers.Authorization?.Scheme);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
+ });
+ }
+ }
+}
diff --git a/backend/tests/BigRegister.Tests/StamdataValidationTests.cs b/backend/tests/BigRegister.Tests/StamdataValidationTests.cs
index eadda22..9ffd4b2 100644
--- a/backend/tests/BigRegister.Tests/StamdataValidationTests.cs
+++ b/backend/tests/BigRegister.Tests/StamdataValidationTests.cs
@@ -18,12 +18,26 @@ public class StamdataValidationTests
/// which steers the editor toward closing validity only once nothing current relies on it.
private sealed record StamdataRef(string Description, IEnumerable Keys, Func Resolves);
+ // The beroepen master-list keys every other profession table points at.
+ private static readonly IReadOnlySet BeroepCodes =
+ StamdataFile.Load("beroepen").Select(b => b.Code).ToHashSet(StringComparer.Ordinal);
+
private static readonly IReadOnlyList References = new[]
{
new StamdataRef(
"Diploma.Opleiding → professions.program (valid today)",
SeedData.Diplomas.Select(d => d.Opleiding),
key => Professions.ByProgram.ContainsKey(key)),
+ // Stamdata → stamdata references: two tables point at beroepen.code, so deleting or
+ // renaming a beroep that either still uses fails the build (WP-48 gate, generalized).
+ new StamdataRef(
+ "Opleiding.beroep → beroepen.code",
+ StamdataFile.Load("opleidingen").Select(o => o.Beroep),
+ key => BeroepCodes.Contains(key)),
+ new StamdataRef(
+ "Specialisme.beroep → beroepen.code",
+ StamdataFile.Load("specialismen").Select(s => s.Beroep),
+ key => BeroepCodes.Contains(key)),
};
[Fact]
diff --git a/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs
new file mode 100644
index 0000000..2d1fe3e
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs
@@ -0,0 +1,66 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using BigRegister.Api.Zgw;
+
+namespace BigRegister.Tests;
+
+///
+/// The ZGW JWT is hand-signed (no library), so it needs a check that it's actually a valid
+/// HS256 JWS with the claims OpenZaak requires. Decodes the minted token and re-verifies the
+/// signature with the shared secret.
+///
+public class ZgwTokenProviderTests
+{
+ private static readonly ZgwOptions Options = new()
+ {
+ ClientId = "big-register",
+ Secret = "super-secret-signing-key",
+ UserId = "u-123",
+ UserRepresentation = "Dr. Test",
+ };
+
+ [Fact]
+ public void Mints_a_three_part_jwt_with_the_required_claims()
+ {
+ var token = new ZgwTokenProvider(Options).Mint();
+
+ var parts = token.Split('.');
+ Assert.Equal(3, parts.Length);
+
+ var header = JsonSerializer.Deserialize(Decode(parts[0]));
+ Assert.Equal("HS256", header.GetProperty("alg").GetString());
+ Assert.Equal("JWT", header.GetProperty("typ").GetString());
+
+ var payload = JsonSerializer.Deserialize(Decode(parts[1]));
+ Assert.Equal("big-register", payload.GetProperty("iss").GetString());
+ Assert.Equal("big-register", payload.GetProperty("client_id").GetString());
+ Assert.Equal("u-123", payload.GetProperty("user_id").GetString());
+ Assert.Equal("Dr. Test", payload.GetProperty("user_representation").GetString());
+ // iat is a recent unix second
+ var iat = payload.GetProperty("iat").GetInt64();
+ Assert.InRange(iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 5, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 5);
+ }
+
+ [Fact]
+ public void Signature_verifies_with_the_shared_secret()
+ {
+ var token = new ZgwTokenProvider(Options).Mint();
+ var parts = token.Split('.');
+
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Options.Secret));
+ var expected = Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{parts[0]}.{parts[1]}")));
+
+ Assert.Equal(expected, parts[2]);
+ }
+
+ private static string Decode(string b64Url)
+ {
+ var s = b64Url.Replace('-', '+').Replace('_', '/');
+ s = s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=');
+ return Encoding.UTF8.GetString(Convert.FromBase64String(s));
+ }
+
+ private static string Base64Url(byte[] bytes) =>
+ Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+}
diff --git a/backend/tests/BigRegister.Tests/ZgwZaakMapperTests.cs b/backend/tests/BigRegister.Tests/ZgwZaakMapperTests.cs
new file mode 100644
index 0000000..7fb4bd7
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/ZgwZaakMapperTests.cs
@@ -0,0 +1,71 @@
+using System.Text.Json;
+using BigRegister.Api.Zgw;
+
+namespace BigRegister.Tests;
+
+///
+/// The anti-corruption map is where ZGW's URL-as-identity + cross-service zaaktype join get
+/// flattened into the FE's existing DTO. Feeds a captured ZGW Zaak shape and asserts the
+/// mapping — the guarantee that the FE never sees a ZGW shape.
+///
+public class ZgwZaakMapperTests
+{
+ private const string OpenZaakJson = """
+ {
+ "url": "https://open-zaak.example/zaken/api/v1/zaken/6f2c5f6e-1b1a-4b7e-9c3d-000000000001",
+ "identificatie": "ZAAK-2026-0000000001",
+ "zaaktype": "https://open-zaak.example/catalogi/api/v1/zaaktypen/aaaaaaaa-0000-0000-0000-000000000001",
+ "startdatum": "2026-03-01",
+ "einddatum": null,
+ "registratiedatum": "2026-03-02"
+ }
+ """;
+
+ private const string ClosedZaakJson = """
+ {
+ "url": "https://open-zaak.example/zaken/api/v1/zaken/6f2c5f6e-1b1a-4b7e-9c3d-000000000002",
+ "identificatie": "ZAAK-2026-0000000002",
+ "zaaktype": "https://open-zaak.example/catalogi/api/v1/zaaktypen/aaaaaaaa-0000-0000-0000-000000000001",
+ "startdatum": "2026-01-05",
+ "einddatum": "2026-02-10",
+ "registratiedatum": "2026-01-06"
+ }
+ """;
+
+ [Fact]
+ public void Maps_url_identity_zaaktype_and_open_status()
+ {
+ var zaak = JsonSerializer.Deserialize(OpenZaakJson)!;
+
+ var dto = ZgwZaakMapper.ToSummaryDto(zaak, "Herregistratie arts");
+
+ // URL as identity → the trailing uuid, not the whole URL.
+ Assert.Equal("6f2c5f6e-1b1a-4b7e-9c3d-000000000001", dto.Id);
+ // zaaktype URL resolved to its human label (the cross-service join).
+ Assert.Equal("Herregistratie arts", dto.Type);
+ Assert.Equal("ZAAK-2026-0000000001", dto.Status.Referentie);
+ Assert.Equal("InBehandeling", dto.Status.Tag); // no einddatum → open
+ Assert.True(dto.Status.Manual);
+ Assert.Empty(dto.DocumentIds);
+ // Wire shape matches the local source: round-trip datetime at midnight UTC.
+ Assert.Equal("2026-03-02T00:00:00.0000000Z", dto.CreatedAt);
+ }
+
+ [Fact]
+ public void Closed_zaak_maps_to_goedgekeurd()
+ {
+ var zaak = JsonSerializer.Deserialize(ClosedZaakJson)!;
+
+ var dto = ZgwZaakMapper.ToSummaryDto(zaak, "Herregistratie arts");
+
+ Assert.Equal("Goedgekeurd", dto.Status.Tag);
+ Assert.Equal("2026-02-10T00:00:00.0000000Z", dto.UpdatedAt); // einddatum drives UpdatedAt
+ }
+
+ [Fact]
+ public void Uuid_extracts_trailing_segment_ignoring_trailing_slash()
+ {
+ Assert.Equal("abc", ZgwZaakMapper.Uuid("https://host/zaken/api/v1/zaken/abc"));
+ Assert.Equal("abc", ZgwZaakMapper.Uuid("https://host/zaken/api/v1/zaken/abc/"));
+ }
+}
diff --git a/docs/README.md b/docs/README.md
index 276e854..9d74c84 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,6 +23,8 @@ condensed, cross-linked curriculum.
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). |
+| [architecture/0005-openzaak-behind-bff.md](reference/architecture/0005-openzaak-behind-bff.md) | ADR — connect to OpenZaak (ZGW APIs) behind the BFF via a config-gated data-source seam; the FE never changes. |
+| [openzaak-integration.md](reference/openzaak-integration.md) | How the BFF sources cases from OpenZaak (the IZaakSource seam + ZGW client), and how to add the next slice. |
| [stamdata.md](reference/stamdata.md) | How stamdata (config-as-code reference data) is laid out, how to add a table with zero UI code, and why coupling stays low. |
| [audit-log.md](reference/audit-log.md) | How the data-minimised authz/PII-reveal audit trail is built, how to audit a new action, and the one-producer-hub coupling. |
| [feature-flags.md](reference/feature-flags.md) | How runtime feature flags work (catalog-as-code + runtime state), how to add one, and the hand-wired gating coupling to watch. |
diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md
index c20bb92..ac874ce 100644
--- a/docs/project/backlog/README.md
+++ b/docs/project/backlog/README.md
@@ -93,6 +93,10 @@ for its existing violations, so every WP ends green.
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
+| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
+| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | todo |
+| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | todo |
+| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo |
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -114,6 +118,10 @@ WP-40 (P2) → WP-43 (P3) → WP-41 → WP-42 → WP-44 → WP-45 (P4). Ordering
make the generator simple) + 43; 45 (`create-ssp`) needs 43+44. 37/38/39/40/43/46 are otherwise
independent. Two open tool forks, decided as step 1 of their WP: 38 dependency-cruiser vs Sheriff;
43 plop vs Angular schematics.
+Phase 9 (OpenZaak/ZGW integration, WP-49..52) is strictly ordered 49 → 50 → 51 → 52: 49 lands
+the source seam + ZGW client the rest reuse; 50 (create) needs a zaak to exist; 51 (documents)
+links to 50's zaak; 52 (notificaties) reacts to changes on the zaken 49–51 manage. All slices
+stay behind `Zgw:Enabled` (default off) so the POC keeps running offline.
## WP template
diff --git a/docs/project/backlog/WP-49-openzaak-zaken-read-seam.md b/docs/project/backlog/WP-49-openzaak-zaken-read-seam.md
new file mode 100644
index 0000000..36a00c0
--- /dev/null
+++ b/docs/project/backlog/WP-49-openzaak-zaken-read-seam.md
@@ -0,0 +1,59 @@
+# WP-49 — OpenZaak zaken read seam (slice 1)
+
+Status: done
+Phase: 9 — OpenZaak / ZGW integration
+
+## Why
+
+The POC serves cases from local SQLite. To grow toward production it must be able to source
+them from a real ZGW backend (OpenZaak) **without changing the frontend** (BFF-lite, ADR-0001).
+The backend had no data-access abstraction to swap behind, no outbound HTTP, and no JWT. This is
+the first thin vertical slice: read-only zaken.
+
+## Read first
+
+- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
+- [openzaak-integration.md](../reference/openzaak-integration.md)
+
+## Decisions (pre-made, don't relitigate)
+
+- OpenZaak's anti-corruption layer lives in the **BFF**, never the browser.
+- Source selected by config (`Zgw:Enabled`, default false) → POC still runs offline.
+- Each source maps into the **existing** `ApplicationSummaryDto` → no api-client drift, no FE change.
+- Fresh HS256 JWT **per call** (no refresh flow). Hand-rolled (no new NuGet).
+- `IZaakSource` is sync (matches the endpoint + local store); OpenZaak source does sync-over-async.
+
+## Files
+
+- `Data/IZaakSource.cs`, `Data/LocalZaakSource.cs`
+- `Zgw/{ZgwOptions,ZgwTokenProvider,ZgwZaakMapper,OpenZaakZaakSource}.cs`
+- `Program.cs` (DI + resolve `IZaakSource` in `/admin/cases`), `appsettings.json` (`Zgw` section)
+- tests: `ZgwTokenProviderTests`, `ZgwZaakMapperTests`, `OpenZaakZaakSourceTests`
+
+## Steps
+
+1. Extract the cases read into `IZaakSource`; `LocalZaakSource` delegates to `ApplicationStore`.
+2. Build the `Zgw/` client (options, JWT minter, ZGW→DTO mapper, paginating HTTP source).
+3. Wire DI by config; refactor `/admin/cases` to resolve `IZaakSource`.
+4. Unit-test the minter, mapper, and source (fixtures + stub `HttpMessageHandler`).
+
+## Acceptance criteria
+
+- [x] `/admin/cases` serves identical DTOs via `LocalZaakSource` (default, offline).
+- [x] `OpenZaakZaakSource` follows pagination, resolves+caches zaaktype labels, sends a Bearer token.
+- [x] JWT verifies (HS256) with the required claims.
+- [x] `dotnet test` green (142); `npm run ci` green with **no api-client drift** (FE untouched).
+
+## Verification
+
+`cd backend && dotnet test`; `npm run ci`; manual: `/beheer/zaken` still lists cases with `Zgw:Enabled=false`.
+
+## Out of scope
+
+Create-zaak (WP-50), Documenten/DRC (WP-51), Notificaties (WP-52), real inbound OIDC/JWT,
+OpenZaak in docker-compose.
+
+## Risks
+
+Sync-over-async blocks a thread under load if OpenZaak becomes the default → make the read path
+async then (noted at the call site).
diff --git a/docs/project/backlog/WP-50-openzaak-create-zaak.md b/docs/project/backlog/WP-50-openzaak-create-zaak.md
new file mode 100644
index 0000000..715666e
--- /dev/null
+++ b/docs/project/backlog/WP-50-openzaak-create-zaak.md
@@ -0,0 +1,52 @@
+# WP-50 — OpenZaak create-zaak (first write slice)
+
+Status: todo
+Phase: 9 — OpenZaak / ZGW integration
+
+## Why
+
+WP-49 made the cases **read** path source-swappable. The next slice is the first **write**:
+create a Zaak in OpenZaak when an aanvraag is submitted, still behind the config gate, still
+without changing the FE contract.
+
+## Read first
+
+- [openzaak-integration.md](../reference/openzaak-integration.md) — "How to add the next slice"
+- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md), [ADR-0001](../reference/architecture/0001-bff-lite-decision-dtos.md)
+
+## Decisions (pre-made, don't relitigate)
+
+- Route the create through the existing submit/mutation seam; keep the FE response DTO identical.
+- A create needs a `zaaktype` **URL** from Catalogi (OpenZaak validates it by fetching) — map
+ the aanvraag `type` → a configured zaaktype URL.
+- Follow the create with a `status` + a `rol` (initiator/betrokkene by BSN) as ZGW expects.
+
+## Files
+
+- Extend `IZaakSource` (or add a write method) + `OpenZaakZaakSource`; `LocalZaakSource` keeps
+ the current local submit.
+- `ZgwOptions`: a `type → zaaktype URL` map + `bronorganisatie`/`verantwoordelijkeOrganisatie` (RSIN).
+
+## Steps
+
+1. Add `CreateZaak` to the source seam; local impl = current submit, OpenZaak impl = POST to ZRC.
+2. Map aanvraag `type` → zaaktype URL; POST zaak, then status + rol.
+3. Map the created Zaak back into the existing submit response DTO.
+
+## Acceptance criteria
+
+- [ ] Submitting with `Zgw:Enabled=true` creates a Zaak (+ status + rol) in OpenZaak.
+- [ ] FE submit response DTO unchanged; no api-client drift.
+- [ ] Covered by tests (stub handler asserts the POST bodies + type→zaaktype mapping).
+
+## Verification
+
+`dotnet test`; against a docker OpenZaak if available.
+
+## Out of scope
+
+Documenten (WP-51), Notificaties (WP-52).
+
+## Risks
+
+Create needs read scope on Catalogi (type-URL validation) — provision AC scopes accordingly.
diff --git a/docs/project/backlog/WP-51-openzaak-documenten.md b/docs/project/backlog/WP-51-openzaak-documenten.md
new file mode 100644
index 0000000..bdcaa69
--- /dev/null
+++ b/docs/project/backlog/WP-51-openzaak-documenten.md
@@ -0,0 +1,52 @@
+# WP-51 — OpenZaak Documenten (DRC) upload + link
+
+Status: todo
+Phase: 9 — OpenZaak / ZGW integration
+
+## Why
+
+Uploaded documents currently persist as bytes in local SQLite (`DocumentStore`). To be
+production-ready they must live in OpenZaak's **Documenten API (DRC)** as
+`enkelvoudiginformatieobjecten`, linked to a Zaak via `zaakinformatieobject` — behind the same
+config gate, still without a FE contract change.
+
+## Read first
+
+- [openzaak-integration.md](../reference/openzaak-integration.md)
+- WP-49 (the seam pattern), WP-50 (create-zaak, the zaak to link to)
+
+## Decisions (pre-made, don't relitigate)
+
+- Introduce an `IDocumentSource` sibling of `IZaakSource`; local impl = `DocumentStore`,
+ OpenZaak impl = DRC. FE upload DTOs unchanged.
+- A document needs an `informatieobjecttype` **URL** from Catalogi (like zaaktype for a zaak).
+- Upload → returns document URL → `zaakinformatieobject` links it to the zaak URL.
+
+## Files
+
+- `Data/IDocumentSource.cs`, `Data/LocalDocumentSource.cs`, `Zgw/OpenZaakDocumentSource.cs`
+- `ZgwOptions`: `informatieobjecttype` URL(s) + `DrcBaseUrl`.
+
+## Steps
+
+1. Abstract the upload/read/link paths behind `IDocumentSource`.
+2. OpenZaak impl: POST `enkelvoudiginformatieobjecten` (content), then POST `zaakinformatieobjecten`.
+3. Map DRC document metadata back into the existing document DTOs.
+
+## Acceptance criteria
+
+- [ ] With `Zgw:Enabled=true`, an upload lands in DRC and is linked to its zaak.
+- [ ] FE upload/list DTOs unchanged; no api-client drift.
+- [ ] Tests cover the DRC POST bodies + the zaak-link step (stub handler).
+
+## Verification
+
+`dotnet test`; against a docker OpenZaak if available.
+
+## Out of scope
+
+Notificaties (WP-52), content virus-scanning / blob-storage tuning.
+
+## Risks
+
+Large file content over base64/multipart — mind memory; stream if needed.
diff --git a/docs/project/backlog/WP-52-openzaak-notificaties.md b/docs/project/backlog/WP-52-openzaak-notificaties.md
new file mode 100644
index 0000000..6dc9ce5
--- /dev/null
+++ b/docs/project/backlog/WP-52-openzaak-notificaties.md
@@ -0,0 +1,51 @@
+# WP-52 — OpenZaak Notificaties (NRC) live status
+
+Status: todo
+Phase: 9 — OpenZaak / ZGW integration
+
+## Why
+
+With cases in OpenZaak, case status changes in the backoffice, not in this app. Production
+"live" status needs the **Notificaties API (NRC)**: subscribe to zaak events and update on
+webhook, rather than polling. Last slice of the ZGW integration arc.
+
+## Read first
+
+- [openzaak-integration.md](../reference/openzaak-integration.md)
+- WP-49/50/51 (the read/write/document slices this builds on)
+
+## Decisions (pre-made, don't relitigate)
+
+- The BFF exposes a webhook endpoint that NRC calls; it validates an Authorization header the
+ BFF issued, then invalidates any cached case data / notifies the FE.
+- Subscription (`abonnement` on the `zaken` kanaal) is provisioning/config, not runtime code.
+- FE update mechanism reuses the existing RemoteData reload — no new FE contract.
+
+## Files
+
+- A new BFF webhook endpoint (`POST /zgw/notificaties`) + Authorization validation.
+- `ZgwOptions`: `NrcBaseUrl` + the webhook shared secret.
+
+## Steps
+
+1. Add the webhook endpoint (auth-checked, no PII logged — reuse the audit seam).
+2. On a zaak event, invalidate cache / push an update to the FE.
+3. Document the `abonnement` provisioning (out-of-band, one-time).
+
+## Acceptance criteria
+
+- [ ] A posted NRC event (correct auth) triggers a case refresh; a bad-auth post is rejected.
+- [ ] No PII in the webhook logs.
+- [ ] Tests cover auth accept/reject + the refresh trigger.
+
+## Verification
+
+`dotnet test`; against a docker OpenZaak + NRC if available.
+
+## Out of scope
+
+Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload.
+
+## Risks
+
+Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code.
diff --git a/docs/reference/architecture/0005-openzaak-behind-bff.md b/docs/reference/architecture/0005-openzaak-behind-bff.md
new file mode 100644
index 0000000..08ce418
--- /dev/null
+++ b/docs/reference/architecture/0005-openzaak-behind-bff.md
@@ -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.
diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md
new file mode 100644
index 0000000..1750864
--- /dev/null
+++ b/docs/reference/openzaak-integration.md
@@ -0,0 +1,88 @@
+# OpenZaak / ZGW integration — how the BFF connects (& how to extend)
+
+How the BFF sources cases from 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).
+
+## 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 interface. Returns the existing
+ `ApplicationSummaryDto`, so each implementation owns its own mapping.
+- `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore`
+ (offline, unchanged behaviour).
+- `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`.
+- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient()
+else AddSingleton()`. The `/admin/cases` endpoint resolves
+ `IZaakSource` from DI — routes + DTOs untouched.
+
+## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
+
+- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs
+ (`ZrcBaseUrl`, `ZtcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five
+ ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC).
+- `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).
+- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
+ where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
+ human label (the cross-service join).
+- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
+ caches zaaktype labels, attaches `Authorization: Bearer `.
+
+## The five ZGW APIs (context for later slices)
+
+| API | Component | Used by |
+| ------------ | --------- | --------------------------------------------------- |
+| Zaken | ZRC | slice 1 (read), WP-50 (create) |
+| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) |
+| 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, e.g. `IDocumentSource`) with
+ the new operation; implement it on both `LocalZaakSource` and the OpenZaak source. Keep the
+ return type the existing DTO so the FE never changes.
+2. **Write** (create-zaak, WP-50) — a create needs a `zaaktype` URL from Catalogi (OpenZaak
+ validates it by fetching), then usually a follow-up `status` + `rol`. 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` is 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` — make the cases read
+path async if OpenZaak becomes the default.
+
+## 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",
+ "ClientId": "big-register", "Secret": "",
+ "UserId": "", "UserRepresentation": ""
+}
+```
+
+## 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/52 (later slices).
+- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs` — the seam.
+- [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).
diff --git a/docs/reference/stamdata.md b/docs/reference/stamdata.md
index b5641ed..863f47d 100644
--- a/docs/reference/stamdata.md
+++ b/docs/reference/stamdata.md
@@ -6,6 +6,11 @@ build**, not a runtime-editable database. For the _why_, see
[ADR-0004 — Stamdata as code](architecture/0004-stamdata-as-code.md); this page is _how the
code is laid out and how to add a table without coupling_. Built in WP-29, hardened in WP-48.
+Tables today: `professions` (opleiding-program → beroep), `beroepen` (the BIG professions master
+list), `opleidingen` (temporal; `beroep` → `beroepen.code`) and `specialismen` (`beroep` →
+`beroepen.code`). The last two are **stamdata → stamdata** references — one table keyed on by two
+others — enforced by the CI gate below.
+
## The one rule that shapes everything: no runtime write path
The catalog is the source of truth and lives in code. The admin editor **downloads** an
@@ -50,8 +55,11 @@ component**. This is the payoff of the schema-driven design.
`backend/tests/BigRegister.Tests/StamdataValidationTests.cs`. `Every_catalog_table_is_valid`
covers every registered table generically; the `StamdataRef` list catches dangling
-references (today: `Diploma.Opleiding → professions.program`). A bad edit, an orphaning
-delete, or a premature expire **fails the PR build** — never prod.
+references — both seed → stamdata (`Diploma.Opleiding → professions.program`) and
+stamdata → stamdata (`Opleiding.beroep → beroepen.code`, `Specialisme.beroep →
+beroepen.code`). A bad edit, an orphaning delete, or a premature expire **fails the PR
+build** — never prod. Adding a cross-table FK is one `StamdataRef` entry: the referencing
+keys + a resolver against the target table's (valid-today) keys.
## Coupling