## What & why S-19b-1. A write to the Objecten API now produces a **delivered** notification on the `objecten` kanaal in Open Notificaties. ADR-0028 switched Objecten's notifications off on purpose — there was no broker, worker, kanaal or abonnement, so wiring only the client side would have dropped every message on the floor. This slice builds the real path and turns it back on. - `objecten-celery` worker (mirrors `oz-celery`) + `CELERY_BROKER_URL`/`RESULT_BACKEND` on objecten-redis db 1 (db 0 is already the cache). `notifications_api_common` only *queues* the send; without a worker every register write is silently undelivered. - `nrc` service + `notifications_config` in Objecten's `setup_configuration`, reusing the `big-reference-seed` credential OpenZaak publishes with (NRC authorizes it via OpenZaak's AC, which grants it `heeft_alle_autorisaties` — no second credential needed). - The `objecten` kanaal in NRC's `setup_configuration`. The name is fixed by the Objects API (`NOTIFICATIONS_KANAAL`), not chosen here; publishing to an unregistered kanaal is exactly what the red check reported first. - `NOTIFICATIONS_DISABLED: "false"` in both compose files. - Writers address Objecten as `objecten.local` — see *Notes for reviewers*. - `make verify-objecten-notifications` — registers an abonnement on `objecten` pointing at a throwaway sink, writes a `RegisterRecord` exactly as the ACL does on approval, asserts the delivery. One assertion covering the whole chain: Objecten -> objecten-celery -> NRC -> nrc-beat -> callback. Wired into the CI `verify-stack` job and the summary table. **ADR-0029** records the decisions; ADR-0028's ceiling now points at it. Closes #152 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (dc9ca2c, red at the first hop: `NRC POST /api/v1/abonnement -> 400 "Kanaal met deze naam bestaat niet."`). - [x] Implementation makes the test pass (4488962, + two fixes found by CI, below). - [x] Conventional Commits referencing the issue (`refs #152`). - [x] CI green — all six jobs ona5fd47e, including `verify-stack` end to end (e2e included). - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (`verify-stack`'s bring-up step). - [x] Docs updated — ADR-0029 added, ADR-0028's ceiling annotated, BACKLOG.md split. - [x] ADR added in `docs/architecture/`. - [x] Demo note in `docs/demo-script.md` if user-visible — n/a, infrastructure only; nothing consumes the kanaal until S-19b-2 (#153). ## Notes for reviewers **The one genuinely non-obvious bit: writers address Objecten as `objecten.local:8000`, not `objecten:8000`.** NRC types a notification's `hoofdObject`/`resourceUrl` as DRF `URLField`, so Django's `URLValidator` runs on them — and it rejects a **single-label** host. Objecten fills both from the object url DRF built with `request.build_absolute_uri`, i.e. *the Host the caller used*. Writing via the plain service name returns 201 and then fails every publish in the background, forever, with ``` 400 {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]} ``` So the `objecten` service carries an `objecten.local` network alias and every writer uses it — `Acl__Objecten__BaseUrl`, `ObjectenGatewayIntegrationTests`, this slice's verify driver. An alias rather than a bare dotted `SITE_DOMAIN` so the host still *resolves*: a subscriber following `resourceUrl` reaches the record, which S-19b-2 will do. Readers keep the plain name. Same class of constraint as ADR-0028's Objecttypen base-URL rule. **Ceiling, stated in the ADR:** nothing enforces the alias — a future writer using `objecten:8000` gets a 201 and silently no notification. If a second writer ever appears, rename the compose service rather than adding a lint. **Two CI-only failures on the way here**, both worth knowing: 1. `SITE_DOMAIN` was my first guess at the mechanism and is simply not what builds those URLs — dropped ind76abf2. 2. The check correlated the delivery on the `reference` inside the record it wrote. An NRC notification carries `kanaal`/`resource`/`kenmerken`/`hoofdObject`/`resourceUrl` and **never the record data**, so it correlates on the object URL now (a5fd47e). **Cost:** one more long-running container on the memory-tight runner. It inherits the capped `UWSGI_PROCESSES: "1"` env, which the celery command ignores; if `verify-stack` gets tight again, celery concurrency is the next knob. **Follow-up:** S-19b-2 (#153) sources the projection from these events. Nothing subscribes to the `objecten` kanaal in the product yet — only the verify check does.Reviewed-on: #154
175 lines
9.0 KiB
Markdown
175 lines
9.0 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
|
|
(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.
|
|
- 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.
|
|
|
|
## 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.
|