## What & why S-19b-2, closing out ADR-0028's stated direction: **the read projection is now derived from the `RegisterRecord` in Objecten, not from ZGW zaak events.** Until now the subscriber listened on `zaken` and *inferred* register state from case events — a `zaak/create` meant INGEDIEND, and any `status/create` was assumed to be the approval (it may not read OpenZaak, so it could not tell statustypen apart). The reference wasn't in the notification at all, so every projection made a second hop to the ACL. The register — a fact about a person — was being reconstructed by guessing at the lifecycle of the case that produced it. - The subscriber's abonnement moves to the `objecten` kanaal (S-19b-1 made it publish). - An Objecten notification carries **no record data**, only the object URL, so the record is read back through the ACL (`POST /register-records/read`) — §8.1 applies to Objecten exactly as ADR-0028 established. - The record carries `id`, `status` and `reference`, so the row *is* the record: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId` and `ToEntry`'s `Resource == "status"` inference are all gone, and so is the ACL enrichment hop. - **The ACL now writes an INGEDIEND record on submit.** Without it, re-sourcing would silently drop every submitted registration from the public register, since only approval wrote a record. - `processed_notifications` holds the projected row (`register_id`, `status`, `reference`) instead of the ZGW event, so a rebuild is a replay with no mapping rules and no upstream reads at all. **ADR-0030** records it. ADR-0028's open caveat — record written but not yet read, "the two must agree" — is closed: there is one source now. Closes #153 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing tests committed before the implementation — two red/green pairs, ACL side (06c0444→566ef7d) and subscriber side (142ed45→8af09b2). - [x] Refactor commit follows (b496ac9). - [x] Conventional Commits referencing the issue (`refs #153`). - [x] CI green — all six jobs onb30fa66, `verify-stack` end to end including the e2e. - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (`verify-stack`'s bring-up step — see the wait-healthy fix below). - [x] Docs updated — ADR-0030 added, ADR-0028's consequence + caveat annotated, BACKLOG.md, e2e header comment. - [x] ADR added in `docs/architecture/`. - [x] Demo note in `docs/demo-script.md` — n/a: no user-visible change. The openbaar register shows the same two statuses for the same registrations; only where they come from changed. ## Notes for reviewers **The decision I'd most like a second opinion on** is the one the issue didn't settle: what happens to INGEDIEND. Objecten held only INGESCHREVEN records, so re-sourcing forced a choice between (a) the ACL also writing on submit, (b) a public register that lists only actual registrations, or (c) a hybrid keeping both kanalen. I took (a): visible behaviour is unchanged and the register holds the whole lifecycle. (b) is arguably the better *semantics* for a public register but narrows what the portal shows and reads against PRD §68 ("~50 register entries with diverse statuses"); (c) leaves the projection half-derived from ZGW, which is the coupling ADR-0028 set out to remove. All three are laid out in ADR-0030. **The dedup key is the projected row**, `objecten:object:{url}:{status}:{reference}` — not the object URL (the ACL upserts *one object per registration*, so submit and approval notify about the same URL and the approval would be swallowed as a duplicate) and not URL+actie (a retried approval is a second `update`). Redeliveries collapse, genuine state changes don't. §8.6. **The migration drops columns rather than renaming them.** EF scaffolded renames — `resource` → `register_id`, `zaak_id` → `status` — which would have carried ZGW values into columns meaning something else, and a rebuild would then have projected that garbage. It also empties both tables: a pre-slice row describes a zaak event the new projector can't reproject, and those registrations have no RegisterRecord in Objecten either, so they're not re-derivable from the new source. Stated as a ceiling in the ADR — fine while stacks are ephemeral, backfill from Objecten if a long-lived environment ever needs it. **`run-projection-check.sh` now opens its zaak through the ACL** instead of straight against OpenZaak, because the ACL is what writes the record. A zaak created behind the ACL's back produces no projection row — that's the re-source working, not a gap. ## Three fixes CI found, none of them in the projection logic 1. **`wait-healthy.sh` matched the wrong container** (744f91a). Bring-up timed out with `TIMEOUT: 'objecten' not healthy (status=none)` while the `docker ps` it dumps showed objecten `Up 9 minutes (healthy)`. `--filter name=` is a substring match, so `objecten` also matches `objecten-db`/`objecten-redis`/`objecten-celery`, and `head -1` took whichever docker listed first — the celery worker has no healthcheck, hence `status=none`. Latent since those services landed and decided purely by listing order; `objecttypen` matches `objecttypen-db` the same way. Anchored on the compose replica suffix, which the verify scripts already do. 2. **The ACL had to be repointed at OpenZaak's IP** (7e0897a). Opening the zaak through the ACL put this check in the same bind run-domain-check.sh already handles: `400 {"name":"zaaktype","code":"bad-url","reason":"Voer een geldige URL in."}`. OpenZaak reflects the request Host into the zaaktype URL and then rejects it on zaak-create when single-label — the mechanism compose already documents on `ACL_OPENZAAK_BASEURL`. 3. **Approval arrives as `partial_update`, not `update`** (0dd26a7→b30fa66) — the one real bug in the slice. The ACL upserts with PATCH; DRF routes it through the notifying `update()` but names the action `partial_update`, so the projector dropped every approval. Only the e2e could catch it: `verify-projection` drives a submit, and per ADR-0028 the e2e is the only check that drives a *real* approval. `verify-tracing` also failed once (run 722) on a path this PR doesn't touch, and passed on a plain re-run of the same commit. Tempo logged `pusher failed to consume trace data` / `distributor_pool failing healthcheck` — it dropped spans under runner load rather than the trace chain being broken. Filed as **#156** rather than absorbed here. **Correction to the #152 PR notes:** I wrote there that celery concurrency was "the next knob" if verify-stack got tight. It isn't — `CELERY_WORKER_CONCURRENCY` already defaults to 1 in the Maykin image, so `objecten-celery` is already a single-process worker. Noted in #156. **Possible follow-up, deliberately not done here:** an `openzaak.local` network alias mirroring `objecten.local` would remove the ACL-repoint dance from both run-domain-check.sh and run-projection-check.sh. It changes the host in every zaak URL the system produces, which is too broad a ripple to land inside an unrelated slice — worth its own issue. **Known costs, all in the ADR:** submission is now two writes across two modules and eventually consistent (same posture ADR-0028 accepted for approval); projecting now depends on the ACL being reachable on the main path, not just for enrichment (NRC retries, so it converges); and OpenZaak still publishes to `zaken` with nothing in the product listening — kept because `verify-nrc` asserts that path.Reviewed-on: #155
176 lines
9.1 KiB
Markdown
176 lines
9.1 KiB
Markdown
# 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.
|
|
|
|
### The objecttype's UUID is pinned, not server-assigned
|
|
|
|
Objecten refuses to store an object whose objecttype it has not been configured with
|
|
(`ObjectType with url=… is not configured`), and its configuration identifies an
|
|
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
|
|
when the container starts, before the `registerrecord-init` one-shot has run.
|
|
|
|
Rather than thread a seed-time UUID from one container into another's config, the UUID is
|
|
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
|
|
fixed UUID (the Objecttypen API accepts a client-supplied one), and
|
|
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
|
|
declared up front, both stay idempotent, and neither has to wait for the other.
|
|
|
|
The cost is a constant duplicated across two files that must be kept in step; each carries
|
|
a comment pointing at the other.
|
|
|
|
### The ACL must reach Objecttypen at the URL Objecten knows it by
|
|
|
|
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
|
|
matches an incoming object's `type` against the `api_root` it was configured with. So an
|
|
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
|
|
URL that Objecten then rejects as "not one of the available choices" — even though it is
|
|
the same objecttype.
|
|
|
|
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
|
|
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
|
|
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
|
|
integration tests only pass from inside the compose network.
|
|
|
|
### Objecten's notifications are off for this slice
|
|
|
|
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
|
|
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
|
|
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
|
|
|
|
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
|
|
no abonnement for it. Configuring only the client side would make writes succeed while
|
|
every message was dropped on the floor — a delivery path that looks wired and isn't. So
|
|
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
|
|
|
|
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
|
register write yet.
|
|
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
|
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
|
|
|
## 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 —
|
|
done in S-19b-2 (#153), ADR-0030.
|
|
|
|
**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.
|
|
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
|
name (compose + `register.py`).
|
|
- ~~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.~~ Closed by ADR-0030:
|
|
the projection is now derived from the register, so there is only one source to agree with.
|
|
|
|
## 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
|
|
|
|
The end-to-end assertion lives in the Playwright happy path
|
|
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
|
|
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
|
|
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
|
|
public-safe schema.
|
|
|
|
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
|
|
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
|
|
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
|
|
calls the ACL. The e2e is the only check that drives a real approval.
|
|
|
|
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
|
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
|
pair: two writes for the same id leave exactly one object, carrying the second write's
|
|
status and nothing outside the public-safe schema.
|
|
|
|
All three findings above — the pinned UUID, the notifications block, and the base-URL
|
|
constraint — came out of running the gateway against those live modules while writing the
|
|
slice, not out of CI.
|