Files
register-referentie/docs/architecture/adr-0029-objecten-publishes-to-nrc.md
not 2125fb0cfd
CI / build (push) Successful in 1m14s
CI / lint (push) Successful in 1m28s
CI / unit (push) Successful in 1m28s
CI / frontend (push) Successful in 3m8s
CI / mutation (push) Successful in 6m35s
CI / verify-stack (push) Successful in 9m27s
feat(infra): Objecten publishes register events to NRC (closes #152) (#154)
## 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 on a5fd47e, 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 in d76abf2.
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
2026-08-28 10:11:54 +00:00

123 lines
6.4 KiB
Markdown

# ADR-0029: Objecten publishes register events to NRC
- **Status:** Accepted
- **Date:** 2026-08-14
- **Deciders:** Respellion engineering
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
## Context
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
turning the client side on alone would have produced a delivery path that looks wired and
drops every message.
S-19b-2 (#153) wants the read projection sourced from register writes rather than
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
builds the four missing pieces and lifts the ceiling.
## Decision
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
exactly as ADR-0007 did for OpenZaak.**
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
alongside `zaken`.
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
`objecten-redis` db 1 (db 0 is already the cache).
### One NRC, one credential, one kanaal per publisher
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
authorization is needed. A second NRC, or a second credential, would buy isolation this
reference application has no use for.
The kanaal name is **not ours to choose**: the Objects API sends
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
```
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
```
which `objecten-celery` then retries with exponential backoff, forever, in the background —
the write itself having returned 201.
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
caller side: the `objecten` service carries an `objecten.local` network alias, and every
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
integration tests, this slice's verify check — addresses it there. An alias rather than a
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
unaffected and keep using the plain service name.
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
match Objecten's configured `api_root`": these modules put request-derived hosts into data
another module then validates or dereferences.
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
and silently no notification.
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
so the plain name stops working, rather than adding a lint.
### A worker, not a synchronous send
`notifications_api_common` only schedules the send on transaction commit. Without a worker
the task sits in redis forever and every register write is silently undelivered — the exact
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
subscriber, and `nrc-beat` already drains NRC's delivery queue.
## Verification
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
asserts the notification reaches the sink. That is the whole chain in one assertion:
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
config.
## Consequences
**Positive**
- A register write is now observable by anything that subscribes, which is what S-19b-2
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
**Negative / costs**
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
now touches two modules.
- Objecten now has two in-network names, and which one a caller uses silently decides
whether its writes are notified (ceiling above).
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
is visible only in the worker log.
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
read NRC's own delivery admin rather than building a retry layer here.
## Coupling rules touched (CLAUDE.md §8)
None bent. This is infrastructure between two upstream modules, over their documented
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.