diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs
index 623388f..9c29b0d 100644
--- a/backend/src/BigRegister.Api/Data/IZaakSource.cs
+++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs
@@ -9,8 +9,8 @@ namespace BigRegister.Api.Data;
/// 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
+/// swaps in OpenZaakZaakSource. Slice 1 (WP-49) was read-only;
+/// (WP-50) is the first write. 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.
///
@@ -18,4 +18,15 @@ public interface IZaakSource
{
/// Every case, newest-first (the admin cross-owner list, WP-36).
IReadOnlyList ListCases(DateTimeOffset now);
+
+ ///
+ /// Register a just-submitted as a zaak (WP-50). The aanvraag is
+ /// already persisted locally (ApplicationStore.Submit already ran) — this is the
+ /// integration side-effect, and its return value is what the submit endpoint hands back to
+ /// the FE (ADR-0001: route the create through the existing submit response DTO, don't add a
+ /// second one). The local source is a pure passthrough of the already-computed local
+ /// reference/status; the OpenZaak source creates a Zaak (+ status + rol) and maps the result
+ /// back into the same shape.
+ ///
+ (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
}
diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
index 3d24f0f..7dad750 100644
--- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
+++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
@@ -12,4 +12,9 @@ public sealed class LocalZaakSource : IZaakSource
{
public IReadOnlyList ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
+
+ /// No external zaak to create — the aanvraag's local submit already IS the record
+ /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.
+ public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
+ (aanvraag.Referentie!, aanvraag.ToStatusDto(now));
}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index 57c1b4c..b2d252a 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -299,7 +299,7 @@ api.MapDelete("/applications/{id}", (string id) =>
// Submit runs the server-owned rules, sets autoApprovable, and transitions the
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
-api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
+api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken) =>
{
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound();
@@ -324,7 +324,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
- return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
+
+ // WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough
+ // of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak
+ // in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
+ // zero FE contract change either way).
+ var (referentie, status) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow);
+ return Results.Ok(new SubmitApplicationResponse(referentie, status));
})
.Produces()
.ProducesProblem(StatusCodes.Status409Conflict)
diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
index b32761a..e095d30 100644
--- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
+++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
@@ -15,13 +15,15 @@ public sealed record ZgwPage(
[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 .
+/// The backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50
+/// write). Reads zaken (following pagination), resolves each zaaktype's human label from the
+/// Catalogi API (cached), and maps into via
+/// . Creates a zaak + status + rol for a just-submitted aanvraag.
/// 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).
+/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution);
+/// creating one additionally needs write scope on Zaken.
///
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
{
@@ -78,5 +80,108 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
+ // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
+
+ /// Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
+ /// initial status → resolve + POST the initiator rol (BSN). Sync-over-async for the same
+ /// reason as (see the ponytail note there) — a submit is already a
+ /// single request/response round trip, so no extra concurrency concern.
+ ///
+ /// ponytail: no compensating transaction — if any ZGW call here throws, the aanvraag is
+ /// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak.
+ /// Acceptable for a first write slice against a demo backend; a production arc would need a
+ /// retry/reconciliation story (or an outbox) before this dual-write can be trusted.
+ public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
+ CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult();
+
+ private async Task<(string Referentie, AanvraagStatusDto Status)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now)
+ {
+ if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
+ throw new InvalidOperationException(
+ $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
+
+ var zaak = await PostAsync($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest(
+ Zaaktype: zaaktypeUrl,
+ Bronorganisatie: options.Bronorganisatie,
+ VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
+ Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
+ Identificatie: aanvraag.Referentie
+ ?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")));
+
+ var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
+ await PostAsync($"{options.ZrcBaseUrl}/statussen",
+ new CreateStatusRequest(zaak.Url, statustypeUrl, now));
+
+ var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
+ await PostAsync($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest(
+ Zaak: zaak.Url,
+ BetrokkeneType: "natuurlijk_persoon",
+ Roltype: roltypeUrl,
+ Roltoelichting: "Initiator",
+ BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner)));
+
+ return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie));
+ }
+
+ // ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype
+ // Catalogi returns for the zaaktype, rather than a fully-configured per-type mapping like
+ // ZaaktypeUrls — good enough while a zaaktype has exactly one initial status and one
+ // initiator role (the normal case); add per-type config if that ever stops holding.
+ private async Task FirstStatustypeUrlAsync(string zaaktypeUrl)
+ {
+ var page = await GetAsync>(
+ $"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
+ var first = page.Results.OrderBy(s => s.Volgnummer).FirstOrDefault()
+ ?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}.");
+ return first.Url;
+ }
+
+ private async Task FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl)
+ {
+ var page = await GetAsync>(
+ $"{options.ZtcBaseUrl}/roltypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}&omschrijvingGeneriek=initiator");
+ var first = page.Results.FirstOrDefault()
+ ?? throw new InvalidOperationException($"No 'initiator' roltype found for zaaktype {zaaktypeUrl}.");
+ return first.Url;
+ }
+
+ private async Task PostAsync(string url, object body)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
+ 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 POST {url} returned null body.");
+ }
+
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
+
+ private sealed record Statustype(
+ [property: JsonPropertyName("url")] string Url,
+ [property: JsonPropertyName("volgnummer")] int Volgnummer);
+
+ private sealed record Roltype([property: JsonPropertyName("url")] string Url);
+
+ private sealed record CreateZaakRequest(
+ [property: JsonPropertyName("zaaktype")] string Zaaktype,
+ [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
+ [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
+ [property: JsonPropertyName("startdatum")] DateOnly Startdatum,
+ [property: JsonPropertyName("identificatie")] string Identificatie);
+
+ private sealed record CreateStatusRequest(
+ [property: JsonPropertyName("zaak")] string Zaak,
+ [property: JsonPropertyName("statustype")] string Statustype,
+ [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet);
+
+ private sealed record CreateRolRequest(
+ [property: JsonPropertyName("zaak")] string Zaak,
+ [property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
+ [property: JsonPropertyName("roltype")] string Roltype,
+ [property: JsonPropertyName("roltoelichting")] string Roltoelichting,
+ [property: JsonPropertyName("betrokkeneIdentificatie")] BetrokkeneIdentificatie BetrokkeneIdentificatie);
+
+ private sealed record BetrokkeneIdentificatie([property: JsonPropertyName("inpBsn")] string InpBsn);
}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
index 2a51c6b..8f1d495 100644
--- a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
+++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
@@ -31,4 +31,16 @@ public sealed class ZgwOptions
/// Human-readable end-user name for the audit trail (JWT user_representation).
public string UserRepresentation { get; init; } = "BIG-register BFF";
+
+ /// Aanvraag Type (registratie/herregistratie/intake) → zaaktype URL (Catalogi),
+ /// so create-zaak (WP-50) knows which zaaktype to open per wizard. OpenZaak validates the URL
+ /// by fetching it, so an unconfigured or wrong entry fails loudly at create time.
+ public Dictionary ZaaktypeUrls { get; init; } = new();
+
+ /// RSIN of the organisation registering the zaak (bronorganisatie, WP-50).
+ public string Bronorganisatie { get; init; } = "";
+
+ /// RSIN of the organisation responsible for the zaak (verantwoordelijkeOrganisatie,
+ /// WP-50) — usually the same RSIN as .
+ public string VerantwoordelijkeOrganisatie { get; init; } = "";
}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs
index 327d521..1569889 100644
--- a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs
+++ b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs
@@ -55,4 +55,9 @@ public static class ZgwZaakMapper
// 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");
+
+ /// Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling
+ /// coarse status (no einddatum yet), same convention as .
+ public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
+ new("InBehandeling", Referentie: identificatie, Manual: true);
}
diff --git a/backend/src/BigRegister.Api/appsettings.json b/backend/src/BigRegister.Api/appsettings.json
index 96cc344..1b2ac0d 100644
--- a/backend/src/BigRegister.Api/appsettings.json
+++ b/backend/src/BigRegister.Api/appsettings.json
@@ -6,7 +6,7 @@
}
},
"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": "WP-49/50: set Enabled=true + the URLs/credentials/RSINs/zaaktype map to source + create cases against a real OpenZaak. Off = local SQLite store (offline POC default).",
"Zgw": {
"Enabled": false,
"ZrcBaseUrl": "",
@@ -14,6 +14,13 @@
"ClientId": "",
"Secret": "",
"UserId": "big-register-bff",
- "UserRepresentation": "BIG-register BFF"
+ "UserRepresentation": "BIG-register BFF",
+ "Bronorganisatie": "",
+ "VerantwoordelijkeOrganisatie": "",
+ "ZaaktypeUrls": {
+ "registratie": "",
+ "herregistratie": "",
+ "intake": ""
+ }
}
}
diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
index 4a774f5..3edeaac 100644
--- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
+++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
@@ -1,5 +1,6 @@
using System.Net;
using System.Text;
+using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
namespace BigRegister.Tests;
@@ -59,16 +60,98 @@ public class OpenZaakZaakSourceTests
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
}
+ [Fact]
+ public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back()
+ {
+ const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
+ var handler = new StubHandler(url => url switch
+ {
+ _ when url == $"{ZrcBase}/zaken" => $$"""
+ { "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
+ "zaaktype": "{{zaaktypeUrl}}", "startdatum": "2026-07-28",
+ "einddatum": null, "registratiedatum": "2026-07-28" }
+ """,
+ _ when url.StartsWith($"{ZtBase}/statustypen") => """
+ { "count": 1, "next": null,
+ "results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
+ """,
+ _ when url.StartsWith($"{ZtBase}/roltypen") => """
+ { "count": 1, "next": null,
+ "results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] }
+ """,
+ _ when url == $"{ZrcBase}/statussen" => "{}",
+ _ when url == $"{ZrcBase}/rollen" => "{}",
+ _ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
+ });
+
+ var options = new ZgwOptions
+ {
+ ZrcBaseUrl = ZrcBase,
+ ZtcBaseUrl = ZtBase,
+ ClientId = "c",
+ Secret = "s",
+ Bronorganisatie = "123443210",
+ VerantwoordelijkeOrganisatie = "123443210",
+ ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
+ };
+ var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
+ var aanvraag = new Aanvraag
+ {
+ Id = "a1",
+ Type = "registratie",
+ Owner = "111222333",
+ Referentie = "BIG-2026-000123",
+ };
+
+ var (referentie, status) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero));
+
+ Assert.Equal("BIG-2026-000123", referentie);
+ Assert.Equal("InBehandeling", status.Tag);
+ Assert.Equal("BIG-2026-000123", status.Referentie);
+
+ string BodyOf(string url) => handler.Bodies[handler.Requests.LastIndexOf(url)];
+
+ // Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie.
+ var zaakBody = BodyOf($"{ZrcBase}/zaken");
+ Assert.Contains(zaaktypeUrl, zaakBody);
+ Assert.Contains("123443210", zaakBody);
+ Assert.Contains("BIG-2026-000123", zaakBody);
+
+ // Status: points at the created zaak's URL and the resolved statustype.
+ var statusBody = BodyOf($"{ZrcBase}/statussen");
+ Assert.Contains($"{ZrcBase}/zaken/uuid-new", statusBody);
+ Assert.Contains("statustypen/st-1", statusBody);
+
+ // Rol: points at the created zaak, the resolved initiator roltype, and the BSN.
+ var rolBody = BodyOf($"{ZrcBase}/rollen");
+ Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody);
+ Assert.Contains("roltypen/rt-initiator", rolBody);
+ Assert.Contains("111222333", rolBody);
+ }
+
+ [Fact]
+ public void CreateZaak_throws_when_the_aanvraag_type_has_no_configured_zaaktype()
+ {
+ var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
+ var handler = new StubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
+ var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
+ var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
+
+ Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow));
+ }
+
private sealed class StubHandler(Func respond) : HttpMessageHandler
{
public List Requests { get; } = new();
public List AuthSchemes { get; } = new();
+ public List Bodies { get; } = new();
protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
AuthSchemes.Add(request.Headers.Authorization?.Scheme);
+ Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? "");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
diff --git a/docs/reference/architecture/0005-openzaak-behind-bff.md b/docs/reference/architecture/0005-openzaak-behind-bff.md
index 08ce418..6f2b8c0 100644
--- a/docs/reference/architecture/0005-openzaak-behind-bff.md
+++ b/docs/reference/architecture/0005-openzaak-behind-bff.md
@@ -64,6 +64,8 @@ up front — the migration stance ADR-0001 already prescribes.
- **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.
+- **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).
+- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), 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
index 63f0095..0d87f89 100644
--- a/docs/reference/openzaak-integration.md
+++ b/docs/reference/openzaak-integration.md
@@ -1,9 +1,10 @@
# 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).
+How the BFF sources (and now creates) cases 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) and
+[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (the first write: create-zaak).
## The one rule: OpenZaak sits behind the BFF, never in the browser
@@ -14,14 +15,48 @@ 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/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and
+ `CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping.
- `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore`
- (offline, unchanged behaviour).
+ (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).
- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient()
-else AddSingleton()`. The `/admin/cases` endpoint resolves
- `IZaakSource` from DI — routes + DTOs untouched.
+else AddSingleton()`. The `/admin/cases` GET and the
+ `/applications/{id}/submit` POST both resolve `IZaakSource` 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)` and returns exactly that in the unchanged
+`SubmitApplicationResponse`. 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 current stand-in for real identity (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 shortcuts, marked at the call sites: (a) "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; (b) no compensating transaction — if any ZGW call throws, the
+aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend;
+a production arc needs retry/reconciliation or an outbox before trusting this dual-write).
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
@@ -75,7 +110,14 @@ path async if OpenZaak becomes the default.
"ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1",
"ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1",
"ClientId": "big-register", "Secret": "",
- "UserId": "", "UserRepresentation": ""
+ "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/"
+ }
}
```
@@ -111,9 +153,9 @@ Principles this demonstrates:
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: today only the cases **read** path has a source interface (`IZaakSource`). Other BFF
+Caveat: `IZaakSource` now covers the cases **read + create** path (WP-49/50). Other BFF
endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not
-yet swappable. That is the WP-50/51/52 roadmap, plus the two cross-cutting WPs the arc needs for
+yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for
production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN
is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything
is fixture/mock-tested against no live instance).