diff --git a/BACKLOG.md b/BACKLOG.md index 8a5ddbe..b45a9de 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -295,7 +295,7 @@ Split into independently deployable sub-slices (CLAUDE.md §13): Split into independently deployable sub-slices (CLAUDE.md §13): -- **S-19a** (#149) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR. +- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028). - **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a. --- diff --git a/docs/PRD.md b/docs/PRD.md index 57577ff..44cc860 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -207,7 +207,7 @@ A slice is done when: ## 15. Out of scope for v1 - OpenMetadata data governance module (v3 slice). -- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder). +- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder. - Production-grade Helm chart (sketch only). - Multi-tenancy. - Real outbound notifications (email/SMS) — logged to console in v1. diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md new file mode 100644 index 0000000..14c7bd4 --- /dev/null +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -0,0 +1,112 @@ +# ADR-0028: Objecten holds the register, OpenZaak holds the process + +- **Status:** Accepted +- **Date:** 2026-08-14 +- **Deciders:** Respellion engineering +- **Slice:** S-19a (#149), first of the S-19 (#20) split + +## Context + +Until this slice the register existed only as a **derived** thing: the read projection +rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no +system anywhere that holds "who is registered" as a first-class record — drop the +projection database and the only way back is to replay ZGW history and re-derive it. + +That is the wrong shape for a register. A BIG registration is a **fact about a person** +that outlives the case that produced it: it is looked up, corrected, superseded, and +retained on its own schedule. The zaak that produced it is a **process record** — it +opens, moves through statussen, and closes. Storing the fact inside the process record +(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two +lifecycles together: the register can then never be read, retained, or corrected without +going through the case system that happened to create it. + +S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord` +objecttype (ADR-0027). The open question this ADR closes: **where the authoritative +register record lives, and who writes it.** + +## Decision + +**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak +keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the +register record.** + +### Not zaak eigenschappen + +Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak. +They inherit the zaak's lifecycle and its archiving regime, and they give the public +register no queryable surface of its own. Objecten gives a JSON-schema-validated record +(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a +lifecycle the zaak cannot drag around with it. + +### The ACL writes it, not the domain or the Event Subscriber + +CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a +module, so the same rule applies: `ObjectenGateway` is the only code that talks to it, +and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the +Event Subscriber write the record when it sees the status notification — would make the +register a *second* derived artefact of ZGW, which is exactly the coupling this ADR +removes. + +### Two writes, converging rather than transactional + +Approval is now two writes across two modules, so it cannot be atomic. Both are made +idempotent instead: + +- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless; +- the register write is an **upsert keyed on the zaak id** — search Objecten for an + existing object with that `id`, then PATCH it or POST a new one. + +A caller that retries a half-failed approval therefore converges. This is the same +eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6), +not an exception carved out for this path. + +### The objecttype is resolved by name, lazily + +The objecttype URL and version number are assigned by Objecttypen at seed time, so they +cannot be pinned in config — the ACL resolves them by the configured name +(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the +same reasoning as ADR-0021 for zaaktypen. + +Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on` +on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution +is not cached, so it is retried on the next approval. + +- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a + transient typed `HttpClient` — in practice one extra GET per approval against a + neighbouring container. +- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW) + if approvals ever get hot enough for that GET to matter. + +## Consequences + +**Positive** + +- The register is a first-class record with its own schema, lifecycle and query surface, + independent of the case that produced it. +- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by + discipline in projection code. +- The read projection can become a cache of Objecten rather than a re-derivation of ZGW + (S-19b, #150). + +**Negative / costs** + +- Approval writes to two modules and is eventually consistent; a failure between them + leaves a zaak in eindstatus without a register record until the approval is retried. + Nothing repairs that automatically yet. +- One more upstream module on the approval path, and one more dev credential + (`Acl__Objecten__Token`) in compose. +- Until S-19b lands, the public register is still read from the NRC-derived projection, so + the register record is written but not yet read — the two must agree. + +## Coupling rules touched (CLAUDE.md §8) + +None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten, +exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL, +and no service reaches Objecten's database. + +## Verification + +`verify-domain` (`infra/run-domain-check.sh`) drives a real approval end-to-end and then +asserts, via `infra/register-record-check.py`, that Objecten holds exactly one +`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside +the public-safe schema. diff --git a/docs/demo-script.md b/docs/demo-script.md index e3f133a..1c4f3f8 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,40 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-19a — approval writes the register record to Objecten (#149, ADR-0028) + +**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also +writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process, +Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an +approval updates the existing object instead of creating a second one. + +```bash +# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it). +make up +# +# 2. End-to-end: the domain check submits a registration, walks it to Beoordelen, approves it, and +# then asserts Objecten holds exactly one RegisterRecord for *that* registration: +make verify-domain # → "OK — approval wrote the register record to Objecten: id=… status=INGESCHREVEN reference=…" +# +# 3. See it for yourself — every register record currently in Objecten: +curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \ + -H 'Accept-Crs: EPSG:4326' \ + 'http://localhost:8021/api/v2/objects' | python3 -m json.tool +``` + +Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything +else (ADR-0027), so no personal data can reach the world-readable register even by mistake. + +**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW +`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the +register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time +is pinned in config (ADR-0028, same reasoning as ADR-0021). + +**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten +is S-19b (#150). + +--- + ## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027) **Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the