## What & why S-10b: the self-service **diploma upload** is now real. After submitting, the citizen picks a PDF and uploads it; the portal base64-encodes it client-side → BFF → domain → **ACL**, which stores it in the ZGW **Documenten (DRC) API** as an `enkelvoudiginformatieobject` and relates it to the zaak, then the `WachtOpDocumenten` wait completes and the case advances to beoordeling. Per §8.1 only the ACL talks to ZGW. Closes #103 Mechanism in **ADR-0018** (proposal #107). Builds on S-10a (#102). The zaak-close-on-expiry item is carved to **#106 (S-10c)**. ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (red→green per layer). - [x] Conventional Commits referencing the issue (`refs #103`). - [ ] CI green — all Gitea Actions jobs (pending on this PR). - [x] `docker compose up` health unaffected (ACL boots on a placeholder informatieobjecttype URL; the real one is injected by verify-domain). - [x] Docs updated (ADR-0018, demo-script, BACKLOG + S-10c). - [x] ADR added (`docs/architecture/adr-0018-diploma-upload-via-acl-documenten.md`). - [x] Demo note in `docs/demo-script.md`. ## Notes for reviewers - **ACL** (`OpenZaakGateway.StoreDocumentAsync` + `AclService.StoreDiplomaAsync` + `POST /documenten`) reuses the existing gateway patterns (ZGW Bearer, buffered non-chunked body, **no CRS** — Documenten isn't geo). Unit-tested via the stub handler; an **integration test** stores a real document against live OpenZaak (verify-acl). - **Transport:** base64 JSON on every hop (portal encodes client-side) — I deviated from proposal #107's multipart to keep one contract shape and avoid `IFormFile`/antiforgery/multipart-client plumbing; fine at diploma size (ADR-0018 §Alternatives). - **Infra:** `seed_catalogus.py` seeds + publishes a "Diploma" `informatieobjecttype` and relates it to the zaaktype (while both concept); `verify-domain` injects its URL into the ACL. No new ZGW scopes (seed applicatie has `heeft_alle_autorisaties`). - **e2e:** uploads a real PDF (`setInputFiles`) after the openbaar INGEDIEND row confirms the zaak is open (so storage doesn't race the OpenZaak worker). - **Scope boundary:** the ZGW zaak is not set to a cancellation status on 30-day expiry — that's #106 (S-10c). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-20
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
|
||||
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
|
||||
expiry to #106 (S-10c).
|
||||
|
||||
## Context
|
||||
|
||||
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
|
||||
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
|
||||
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
|
||||
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
|
||||
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
|
||||
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
|
||||
zaaktype.**
|
||||
|
||||
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
|
||||
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
|
||||
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
|
||||
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers** —
|
||||
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
|
||||
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
|
||||
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
|
||||
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
|
||||
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
|
||||
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
|
||||
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
|
||||
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
|
||||
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
|
||||
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
|
||||
ZGW `inhoud`.
|
||||
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
|
||||
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
|
||||
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
|
||||
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
|
||||
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
|
||||
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
|
||||
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
|
||||
(a zeros-uuid placeholder otherwise, so the ACL still boots).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
|
||||
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
|
||||
default, one seed block).
|
||||
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
|
||||
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
|
||||
Playwright journey uploads a real PDF).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
|
||||
diploma, but not a pattern to reuse for large documents without streaming/multipart.
|
||||
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
|
||||
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
|
||||
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
|
||||
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
|
||||
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
|
||||
client, for no benefit at diploma size.
|
||||
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
|
||||
talks to ZGW).
|
||||
Reference in New Issue
Block a user