Compare commits

..
Author SHA1 Message Date
not a5fd47e546 fix(infra): correlate the delivery on the object URL, not the record reference (refs #152)
CI / build (pull_request) Successful in 1m14s
CI / lint (pull_request) Successful in 1m30s
CI / unit (pull_request) Successful in 1m34s
CI / frontend (pull_request) Successful in 3m14s
CI / mutation (pull_request) Successful in 6m21s
CI / verify-stack (pull_request) Successful in 8m48s
The publish chain works — the sink received it:

  {"kanaal": "objecten", "resource": "object", "kenmerken": {"objectType": "…"},
   "hoofdObject": "http://objecten.local:8000/api/v2/objects/a68d4c46-…", …}

The check just looked for the wrong thing. An NRC notification carries hoofdObject /
resourceUrl and kenmerken — never the record data — so the `reference` inside the
RegisterRecord it wrote was never going to appear in the delivered message. Grep the sink
for the object URL instead, which is what identifies the write.
2026-08-28 11:50:07 +02:00
not d76abf2df2 fix(infra): address Objecten by a dotted host so NRC accepts its notifications (refs #152)
CI / build (pull_request) Successful in 1m9s
CI / lint (pull_request) Successful in 1m24s
CI / unit (pull_request) Successful in 1m32s
CI / frontend (pull_request) Successful in 3m13s
CI / mutation (pull_request) Successful in 6m25s
CI / verify-stack (pull_request) Failing after 8m11s
The worker published and NRC answered 400 on every message:

  {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}

NRC types both as DRF `URLField`, and Django's URLValidator refuses a single-label host.
Objecten fills them from the object `url` DRF built with `request.build_absolute_uri` —
the Host the *caller* used — so `SITE_DOMAIN` never entered into it. Dropped that env pair;
it was a wrong guess at the mechanism.

The fix is on the caller side: keep the `objecten.local` network alias and point every
writer whose writes must be notified at it — the ACL, the gateway integration tests, and
this slice's verify driver. Readers keep the plain service name.

ADR-0029 updated with the real mechanism and the ceiling it leaves: a new writer using
`objecten:8000` gets a 201 and silently no notification.
2026-08-28 11:31:31 +02:00
not 448896206f feat(infra): Objecten publishes register events to NRC (refs #152)
CI / build (pull_request) Successful in 4m31s
CI / lint (pull_request) Successful in 4m46s
CI / unit (pull_request) Successful in 1m35s
CI / frontend (pull_request) Successful in 4m0s
CI / mutation (pull_request) Successful in 6m47s
CI / verify-stack (pull_request) Failing after 17m35s
Builds the four pieces ADR-0028 deliberately left absent, and turns
`NOTIFICATIONS_DISABLED` back off:

- `objecten-celery`, a worker on the Objecten image (mirrors `oz-celery`), plus
  `CELERY_BROKER_URL`/`RESULT_BACKEND` on objecten-redis db 1 (db 0 is the cache).
  Without it `notifications_api_common` queues the send and nothing ever ships it.
- An `nrc` service + `notifications_config` in Objecten's setup_configuration,
  reusing the `big-reference-seed` credential OpenZaak publishes with.
- The `objecten` kanaal in NRC's setup_configuration — the name is fixed by the
  Objects API (`NOTIFICATIONS_KANAAL`), and publishing to an unregistered kanaal is
  what the failing check reported first.
- `SITE_DOMAIN: objecten.local:8000` + an `objecten.local` network alias: NRC
  validates `hoofdObject`/`resourceUrl` with Django's URLValidator, which rejects a
  single-label host, so `objecten:8000` is refused with "Voer een geldige URL in."
  The alias keeps the dotted host resolvable so the URL still dereferences.

ADR-0029 records it; ADR-0028's ceiling now points there.

Makes `make verify-objecten-notifications` (dc9ca2c) pass.
2026-08-28 10:54:14 +02:00
not dc9ca2ceb2 test(infra): verify-objecten-notifications asserts Objecten publishes to NRC (refs #152)
Registers an abonnement on the `objecten` kanaal pointing at the existing
webhook sink, writes a RegisterRecord exactly as the ACL does on approval, and
waits for the delivery. That is the whole publish chain in one assertion:
Objecten → its celery worker → NRC → nrc-beat → the callback.

Fails today at the first hop, which is the point:

  NRC POST /api/v1/abonnement → 400: {"name":"naam","code":"kanaal_naam",
  "reason":"Kanaal met deze naam bestaat niet."}

Also splits S-19b (#150) into #152/#153 in BACKLOG.md — the notification wiring
and the projection re-source are independently deployable (CLAUDE.md §13).
2026-08-14 11:50:16 +02:00
not 0cd70ae8c3 S-19a · ACL writes the RegisterRecord to Objecten on approval (closes #149) (#151)
CI / build (push) Successful in 1m8s
CI / lint (push) Successful in 1m23s
CI / unit (push) Successful in 1m22s
CI / frontend (push) Successful in 3m3s
CI / mutation (push) Successful in 6m22s
CI / verify-stack (push) Successful in 7m54s
Closes #149.

**Outcome:** approving a registration now writes the canonical register record to the **Objecten** API as a `RegisterRecord` object, alongside the ZGW eindstatus. OpenZaak holds the process, Objecten holds the register (ADR-0028). The write goes through the ACL (§8.1) and is idempotent on the zaak id, so a replayed approval updates the existing object rather than creating a second one.

S-19 (#20) was split first (CLAUDE.md §13) — it bundled this with re-sourcing the read projection, which is now #150.

### What landed

- `IRegisterRecordGateway` + `RegisterRecord` in `Acl.Application`; `ObjectenGateway` in `Acl.Infrastructure` (static Token auth, CRS headers, objecttype resolved by name to its highest **published** version).
- `AclService.ApproveZaakAsync` writes the record after the eindstatus, keyed on the zaak UUID with the zaak's identificatie as reference.
- Compose wiring for both stacks; `ADR-0028`; demo note; PRD §15 out-of-scope line retired.

### Three things only a live stack found

Running the gateway against a real Objecten + Objecttypen pair while writing this turned up blockers CI would have hit after the fact:

1. **Objecten rejects an objecttype it has not been configured with**, by UUID — assigned at seed time by a one-shot that runs *after* Objecten's static setup_configuration. The UUID is now pinned on both sides.
2. **Objecten 500s on every write when its Notificaties config is absent** (`notifications_api_common` raises rather than skipping). Objecten → NRC has no broker, worker, kanaal or abonnement, so notifications are **disabled** rather than wired to drop every message; #150 turns them on for real.
3. **Objecttypen echoes the request Host into the objecttype `url`**, and Objecten only accepts the one matching its configured `api_root` — so the ACL must read Objecttypen at `http://objecttypen:8000`. This is why the new integration test only passes inside the compose network.

All three are recorded in ADR-0028.

### Verification

- `ObjectenGatewayIntegrationTests` (verify-acl, in-network): two writes for one id leave exactly one object with the second write's status. **Passing locally against live Objecten.**
- The **Playwright happy path** asserts, after the behandelaar approves, that Objecten holds exactly one `RegisterRecord` for *that* reference — missing, duplicated, or non-public-safe all fail.
- ACL mutation score **92.23%** (baseline 91.37%, break 90).
- `make lint` / `make unit` green locally; full-stack `make verify` runs in CI.

## Definition of Done

- [x] A linked Gitea issue exists (#149).
- [x] Failing test written and committed first.
- [x] Implementation makes the test pass.
- [x] Refactor commit follows if structure improved.
- [x] Conventional Commit messages referencing the issue (`refs #149`).
- [x] All Gitea Actions CI jobs green (run 684).
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (verify-stack step 1).
- [x] Docs touched — ADR-0028, demo note, PRD §15, BACKLOG.
- [x] ADR added: `docs/architecture/adr-0028-objecten-holds-the-register.md`.
- [x] Demo note appended to `docs/demo-script.md`.
- [x] Closed by the merging PR (`closes #149`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #151
2026-08-14 09:34:04 +00:00
not d37d4c96c6 S-18c · RegisterRecord objecttype defined + registered (closes #141) (#146)
CI / build (push) Successful in 1m29s
CI / lint (push) Successful in 1m37s
CI / unit (push) Successful in 2m13s
CI / frontend (push) Successful in 4m31s
CI / mutation (push) Successful in 7m0s
CI / verify-stack (push) Successful in 8m52s
## What & why

S-18c, the **final** slice of the S-18 (#19) split (after S-18a #142, S-18b #143). Defines the **RegisterRecord** objecttype — the schema S-19 (#20) will write canonical register records against on approval — and registers it in the Objecttypen API at startup.

Closes #141

### What

- **Schema** (`infra/objecttypen-registerrecord/registerrecord.schema.json`): public-safe by construction — `id`, `status` (enum `INGEDIEND`/`INGESCHREVEN`), `reference` only, `additionalProperties: false`, `dataClassification: open`. Mirrors the BFF's `OpenbaarEntry` — **no `bsn`/`naam`** (ADR-0027).
- **Registration**: a `registerrecord-init` compose one-shot (stdlib Python on the stack network) POSTs the objecttype + a **published** version over the API once Objecttypen is healthy. The Objecttypen `setup_configuration` (3.4.2) only provisions tokens — no declarative objecttype step — so this follows the ADR-0020 self-seed pattern. **Idempotent**: if a `RegisterRecord` with a version already exists it is a no-op.
- **Wiring**: schema + `register.py` streamed into the external `rr-registerrecord-config` volume by `seed-config.sh registerrecord` (main) / bind-mounted (local); added to `SEED`, `CFG_VOLS`, and the CI log-dump. `registerrecord-init` is a one-shot (not in `WAIT_SVCS`).
- **Smoke**: `verify-registerrecord` (`run-registerrecord-check.sh` + `registerrecord-check.py`) asserts the objecttype exists, has a **published** version, and that version's schema carries `id`/`status`/`reference`; added as a verify-stack step + a row in the #136 summary.
- **ADR-0027**: records the public-safe schema decision (mirror the BFF public view, not the internal projection; API-seeded one-shot). The slice issue #141 flagged the schema as ADR-worthy, so no separate adr-proposal issue was opened.

## Verified locally (end to end, real compose)

Seeded `rr-registerrecord-config`, brought Objecttypen up, ran `registerrecord-init` → `registered RegisterRecord <uuid> v1 (published)`. `make verify-registerrecord` → **OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']**. Re-running the one-shot → **no-op** (idempotent). `docker compose config` clean on both files; schema + script + ci.yaml validated.

## Definition of Done

- [x] Failing smoke committed first (`test(infra): …`, "no objecttype named RegisterRecord"); implementation makes it pass.
- [x] Conventional Commits referencing #141.
- [x] CI green (verify-stack registerrecord step — validated locally; runner already unstarved by #145).
- [x] `docker compose up` reaches health (one-shot registers after Objecttypen healthy).
- [x] Docs: ADR-0027 + demo note.
- [x] Closed by the merging PR (`closes #141`).

This closes out the S-18 (#19) split — Objecttypen (S-18a) + Objecten (S-18b) + RegisterRecord (S-18c) are all up. Next: **S-19 (#20)** — ACL writes the register record to Objecten on approval, against this schema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #146
2026-07-27 15:14:16 +00:00
not 159f014c1e perf: cap OpenZaak + NRC uWSGI workers — shrink verify-stack footprint (closes #147) (#148)
CI / build (push) Successful in 1m50s
CI / lint (push) Successful in 1m54s
CI / unit (push) Successful in 2m14s
CI / frontend (push) Successful in 4m45s
CI / mutation (push) Successful in 10m9s
CI / verify-stack (push) Failing after 11m19s
## What & why

Closes #147. Follow-up to #144/#145. As the stack grew to **37 services** on one runner, `verify-stack` is under memory pressure. #145 capped Objecten/Objecttypen; this caps the two biggest remaining uncapped Django apps.

**OpenZaak** and **NRC** (`nrc-web`) are Maykin/vng uWSGI images running the image default of **4 processes × 4 threads** — ~4 full-Django worker processes (~800 MB) each, idle, serving only single-request smoke checks.

### What

- `UWSGI_PROCESSES: "1"` + `UWSGI_THREADS: "2"` on the `&oz-env` and `&nrc-env` anchors, in both compose files. Frees ~1.2 GB. The anchors are shared with the `-init` (setup_configuration) and `-celery` containers, which ignore the var — they don't run uwsgi.

### Not included (considered, deferred to #147 notes)

JVM heap caps on Keycloak/Flowable; compose profiles to boot per-check subsets.

## Verified locally

OpenZaak brought up healthy with the cap; uwsgi processes **6 → 3** (master + http-router + 1 worker); `/admin/` still 302. `docker compose config` clean on both files. (Full NRC bring-up needs OpenZaak + the seed chain — same image family/lever, validated via OpenZaak.)

## Definition of Done

- [x] Linked issue (#147).
- [x] Conventional Commit referencing #147.
- [x] Verified locally (OpenZaak healthy + worker count dropped + still serving).
- [x] Closed by the merging PR (`closes #147`).

No ADR: config-only tuning of existing services, same class as #145.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #148
2026-07-27 14:32:13 +00:00
not dd54688f86 fix: cap Objecten/Objecttypen uWSGI to 1 worker — unstarve verify-stack e2e (closes #144) (#145)
CI / build (push) Successful in 1m32s
CI / lint (push) Successful in 1m41s
CI / unit (push) Successful in 2m21s
CI / frontend (push) Successful in 4m56s
CI / mutation (push) Successful in 7m54s
CI / verify-stack (push) Failing after 16m47s
## What & why

**P0 — red `main`.** Fixes #144: `verify-stack` fails on the Playwright e2e step (main runs 2177 after #142, 2190 after #143), while the PR runs passed.

Closes #144

### Root cause

The Maykin **Objecttypen** (S-18a) and **Objecten** (S-18b) images run their `web` under uWSGI with **4 processes × 4 threads by default** (`UWSGI_PROCESSES:-4`). Two web services × 4 idle Django workers (~200 MB each) sat idle during the e2e step and starved the single shared self-hosted runner — Keycloak and the portals stopped responding (the login `#username` never appeared) and Chromium hit `Target crashed`. The runner margin was already thin; the second chain tipped it over (main green through run 2159, red from 2177).

### Fix

Cap `UWSGI_PROCESSES: "1"` + `UWSGI_THREADS: "2"` on both `objecten` and `objecttypen` in both compose files. These APIs only serve single-request smoke checks and are idle during e2e, so 1 worker is plenty — it frees ~1–1.5 GB. The `-init` containers ignore it (they run `setup_configuration`, not uwsgi).

## Verified locally

Brought the objecten chain up with the cap: both services reach healthy, worker count drops from 6 (master + http + 4 workers) to 3 (master + http + 1 worker) per service, and `make verify-objecten` / `make verify-objecttypen` both still → **OK — no-auth 401, token 200**. `docker compose config` clean on both files.

## Definition of Done

- [x] Linked issue (#144).
- [x] Conventional Commit referencing #144.
- [x] Verified locally (both APIs healthy + smoke green with 1 worker).
- [x] Closed by the merging PR (`closes #144`).

No ADR: config-only tuning of existing services — no boundary, dependency, or coupling change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #145
2026-07-27 13:07:55 +00:00
not 0a97fa4bf7 S-18b · Objecten API up in compose, wired to Objecttypen (closes #140) (#143)
CI / build (push) Successful in 1m29s
CI / lint (push) Successful in 1m45s
CI / unit (push) Successful in 2m6s
CI / frontend (push) Successful in 4m21s
CI / mutation (push) Successful in 7m0s
CI / verify-stack (push) Failing after 30m21s
## What & why

S-18b, second of the S-18 (#19) split (after S-18a #139/#142). Stands up the upstream Maykin **Objecten API** in the compose stack and wires it to the Objecttypen API — the authoritative object store the ACL will write register records to (S-19).

Closes #140

### What

- **Compose** (main + local): `objecten-db` (**PostGIS** — objects carry geometry), `objecten-redis`, `objecten-init` (RUN_SETUP_CONFIG → migrate + provision token + register the Objecttypen service), `objecten` web (health on `/admin/`, host `:8021`). Verbatim upstream image `maykinmedia/objects-api` pinned to `3.4.0` (nearest release to objecttypes-api `3.4.2`; the two speak over the stable Objecttypes API v2).
- **Seed**: `infra/seed-config.sh objecten` streams `infra/objecten/setup_configuration/data.yaml` into the external `rr-objecten-config` volume — same pattern as S-18a. The data.yaml (1) registers **Objecttypen** as a trusted `zgw_consumers` service (`api_type: orc`, api-key auth with the S-18a dev token) so an object can reference its objecttype, and (2) provisions a dev **static API token** so peers (the ACL, S-19) can write objects.
- **Wiring**: added to `WAIT_SVCS`, `CFG_VOLS`, the `SEED` invocations, `seed-config.sh`, and the CI log-dump. `objecten-init` waits on `objecttypen` being healthy so the service registration is meaningful end to end.
- **Smoke**: `verify-objecten` (`infra/run-objecten-check.sh` + `objecten-check.py`) asserts unauth → 401, token → 200 on `/api/v2/objects`; added as a verify-stack step + a row in the #136 check-summary table.

## Verified locally (end to end, real compose)

Seeded + brought up the real `infra/docker-compose.yml` objecten chain (pulls in objecttypen via `depends_on`): `objecten-init` ran setup_configuration — `token_configuration_success` **and** "Successfully executed step: Configuration to connect with external services" — the web reached healthy, and `make verify-objecten` → **"OK — no-auth 401, token 200"**. Confirmed the registered service via the Objecten django shell:

```
objecttypen | orc | http://objecttypen:8000/api/v2/ | api_key
```

YAML (both compose files + ci.yaml) + shell + python all validated; `docker compose config` clean on both files.

## Definition of Done

- [x] Failing smoke committed first (`test(infra): …`, "no running objecten container"); implementation makes it pass.
- [x] Conventional Commits referencing #140.
- [ ] CI green (verify-stack objecten step).
- [x] `docker compose up` reaches health (objecten healthy on first poll locally).
- [x] Demo note in `docs/demo-script.md`.
- [x] Closed by the merging PR (`closes #140`).

No new ADR: follows the established verbatim-image + seed-config CG-module pattern (S-18a/ADR-0023-era).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #143
2026-07-27 09:53:01 +00:00
31 changed files with 1855 additions and 21 deletions
+11 -1
View File
@@ -207,6 +207,9 @@ jobs:
- name: Objecten API up + token authenticates + trusts Objecttypen
id: objecten
run: OBJECTEN_TIMEOUT=120 make verify-objecten
- name: RegisterRecord objecttype registered + published
id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ACL ↔ OpenZaak integration tests
id: acl
run: make verify-acl
@@ -216,6 +219,9 @@ jobs:
- name: OpenZaak → NRC → Event Subscriber → projection-api
id: projection
run: make verify-projection
- name: Objecten → NRC notification delivery
id: objecten_nrc
run: make verify-objecten-notifications
- name: Domain → Flowable → ACL → OpenZaak
id: domain
run: make verify-domain
@@ -241,6 +247,8 @@ jobs:
OBS: ${{ steps.obs.outcome }}
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
ACL: ${{ steps.acl.outcome }}
NRC: ${{ steps.nrc.outcome }}
PROJECTION: ${{ steps.projection.outcome }}
@@ -261,6 +269,8 @@ jobs:
echo "| Observability backplane | $(icon "$OBS") |"
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
echo "| OpenZaak → NRC | $(icon "$NRC") |"
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
@@ -280,7 +290,7 @@ jobs:
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten tempo prometheus grafana 2>&1 || true
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
+9 -2
View File
@@ -285,14 +285,21 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
- **S-18c** (#141) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
### S-19 · ACL extension: write register-record to Objecten on approval
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
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 (ADR-0028).
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
- **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
- **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1.
---
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
+17 -6
View File
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
@@ -94,14 +94,14 @@ mutation:
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
# (oz-init, flowable-init) aren't polled; they just need to have run.
smoke:
$(SEED) oz nrc kc fl objecttypen objecten
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz nrc kc fl objecttypen objecten
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
@@ -139,7 +139,7 @@ changelog:
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl objecttypen objecten
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
@@ -196,17 +196,28 @@ verify-objecttypen:
verify-objecten:
bash infra/run-objecten-check.sh
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
## Objecttypen API (S-18c), against the already-running stack.
verify-registerrecord:
bash infra/run-registerrecord-check.sh
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
verify-objecten-notifications:
bash infra/run-objecten-notifications-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
verify:
$(SEED) oz nrc kc fl objecttypen objecten
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-objecten-notifications-check.sh \
&& bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
+1 -1
View File
@@ -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.
@@ -0,0 +1,81 @@
# ADR-0027: The RegisterRecord objecttype is public-safe by construction
- **Status:** Accepted
- **Date:** 2026-07-27
- **Deciders:** Respellion engineering
- **Slice:** S-18c (#141), third of the S-18 (#19) split
## Context
S-18 stands up Objecttypen (S-18a) and Objecten (S-18b) as the authoritative
register-record store (PRD §"Objecten as the authoritative register record store").
S-19 (#20) will, on approval, write the canonical register record to the Objecten API
instead of OpenZaak zaak-eigenschappen, and the openbaar (public) register will read it.
Objecten validates every object against a **objecttype version's JSON schema**. So the
schema is a contract: it fixes which fields a register record may carry. The register is
read **anonymously** by the openbaar portal (ADR-0010), so the schema is also a
disclosure boundary — anything the schema allows can end up public.
Two questions: **which fields** the schema defines, and **how** the objecttype gets into
the Objecttypen API (which has no declarative objecttype step).
## Decision
**Define a `RegisterRecord` objecttype whose published schema carries exactly the
public-safe fields — `id`, `status`, `reference` — and register it over the API at
startup with a one-shot, idempotently.**
### The schema mirrors the BFF's public projection, not the internal one
The public-safe field set already exists: the BFF's `OpenbaarEntry`
(`services/bff/Bff.Api/DownstreamClients.cs`) — `id`, `status`, `reference` — is what
`OpenbaarProjection.PublicView` narrows every row down to, dropping `bsn` and
`naamPlaceholder` at the boundary (S-09). The RegisterRecord schema mirrors that record,
**not** the internal `RegisterEntry` / `RegisterEntryRow` (which carry bsn/naam):
| field | type | notes |
|-------|------|-------|
| `id` | string (required) | zaak id — the entry's stable key |
| `status` | string (required) | enum `INGEDIEND` \| `INGESCHREVEN` (`RegistrationStatus`) |
| `reference` | string \| null | citizen-facing zaak identificatie (ADR-0012) |
`additionalProperties: false` so a record can't smuggle a field the schema didn't
sanction, and `dataClassification: "open"` records the intent that this objecttype is
public. **`bsn` and `naamPlaceholder` are deliberately absent** — public-safe by
construction, so S-19 cannot write a personal-data field into the public register even by
mistake.
### Registered over the API by a one-shot, not setup_configuration
The Objecttypen API's `setup_configuration` (3.4.2) provisions only tokens — it has no
declarative step to create an objecttype with a schema. So a `registerrecord-init`
compose one-shot (stdlib Python, on the stack network) creates the objecttype + a
**published** version over the API once Objecttypen is healthy, following the ADR-0020
self-seed pattern. It is **idempotent**: if a `RegisterRecord` with a version already
exists it is a no-op, so it is safe on every `up`.
- ponytail ceiling: no schema-migration/versioning story — a schema change means editing
`registerrecord.schema.json` and bumping the version by hand; the one-shot only ever
adds v1 if none exists.
- Upgrade path: if the schema evolves, have the one-shot diff the published schema and
POST a new version, or move to a declarative step once the upstream supports one.
## Consequences
**Positive**
- The public register's disclosure surface is fixed in one reviewed artifact
(`registerrecord.schema.json`) and enforced by Objecten's own validation.
- Self-seeds on a fresh `make up` / bare local compose; no manual step, no built image.
**Negative / costs**
- The public-safe field set now lives in two places — the BFF's `OpenbaarEntry` and this
schema — that must be kept in sync by hand (a drift check is a candidate for later).
- Hand-managed schema version (ceiling above).
## Coupling rules touched (CLAUDE.md §8)
None new. Registration talks to the Objecttypen API over its documented API. S-19 will
write records via the ACL (§8.1) — this ADR only fixes the schema they conform to.
@@ -0,0 +1,174 @@
# 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.
@@ -0,0 +1,122 @@
# 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.
+66
View File
@@ -5,6 +5,72 @@ 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 walking-skeleton e2e submits, approves via the behandel portal, and then
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
#
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
make verify-acl # → "Writes a register record and updates it in place on a second write"
#
# 4. 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
Objecttypen API at startup. The schema is public-safe by construction — `id`, `status`, `reference`
only, mirroring the BFF's `OpenbaarEntry` (no `bsn`/`naam`), `dataClassification: open`. This is the
schema S-19 writes register records against on approval. A `registerrecord-init` one-shot creates it
over the API once Objecttypen is healthy (the Objecttypen `setup_configuration` has no objecttype
step), idempotently.
```bash
make up
# The RegisterRecord objecttype exists with a published version:
curl -s -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
"http://localhost:8020/api/v2/objecttypes" | python3 -c \
'import sys,json; o=[x for x in json.load(sys.stdin)["results"] if x["name"]=="RegisterRecord"][0]; print(o["name"], o["dataClassification"], o["versions"])'
# → RegisterRecord open ['http://.../objecttypes/<uuid>/versions/1']
#
# Automated (a CI verify-stack step): asserts the objecttype exists, has a published version, and
# that version's schema carries id/status/reference.
make verify-registerrecord # → OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']
```
**The path:** `infra/objecttypen-registerrecord/registerrecord.schema.json` (the reviewed public-safe
contract) + `register.py` are streamed into an external config volume by `infra/seed-config.sh
registerrecord` (bind-mounted locally); the `registerrecord-init` one-shot POSTs the objecttype + a
published version. Re-running is a no-op. S-19 (#20) writes records against this schema in Objecten.
---
## S-18b — Objecten API up in compose, wired to Objecttypen (#140)
**Outcome:** the upstream Maykin **Objecten API** runs in the stack — own **PostGIS** DB + redis,
+67
View File
@@ -56,6 +56,10 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — idle workers pressure the runner; the
# -init/-celery containers share this anchor and ignore it (they don't run uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -138,6 +142,9 @@ services:
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -331,6 +338,15 @@ services:
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
Acl__Objecten__BaseUrl: http://objecten.local:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
volumes:
@@ -583,6 +599,9 @@ services:
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
@@ -621,6 +640,21 @@ services:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot (local variant) ─
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- ./objecttypen-registerrecord:/config:ro,z
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — bind-mounted config (local variant) ─────────────
objecten-db:
image: docker.io/postgis/postgis:17-3.5
@@ -644,6 +678,9 @@ services:
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
@@ -655,6 +692,14 @@ services:
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
CELERY_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
# off until all four pieces existed.
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
volumes:
@@ -679,6 +724,28 @@ services:
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks:
cg:
# Objecten reflects the *request* Host into the `url` it returns, and
# notifications_api_common publishes that url as the notification's hoofdObject /
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
# Reads are unaffected and still use the plain service name.
aliases:
- objecten.local
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
# 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. Mirrors oz-celery.
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
objecten-celery:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env-local
command: /celery_worker.sh
depends_on:
objecten-init:
condition: service_completed_successfully
+78
View File
@@ -51,6 +51,12 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147, same lever as #145): OpenZaak serves
# single-request smoke checks here and is not load-tested, so 4 idle Django workers just pin
# ~800 MB and pressure the shared runner. The -init (setup_configuration) and -celery containers
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -135,6 +141,9 @@ services:
# needs no baked config.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -314,6 +323,15 @@ services:
# so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
Acl__Objecten__BaseUrl: http://objecten.local:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
healthcheck:
@@ -596,6 +614,11 @@ services:
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env
# 1 uWSGI worker, not the image default of 4×4: this API only serves single-request smoke
# checks and sits idle during the e2e step — 4 idle Django workers each pin ~200 MB and starve
# the shared CI runner (#144). Init ignores this (it runs setup_configuration, not uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
@@ -635,6 +658,25 @@ services:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot ────────────────
# The Objecttypen setup_configuration (3.4.2) can only provision tokens — no declarative objecttype
# step — so this one-shot creates the RegisterRecord objecttype + a published version over the API
# once Objecttypen is healthy (idempotent; ADR-0020 self-seed, ADR-0027 schema). The schema + script
# are streamed into the external config volume by infra/seed-config.sh, like the *-init volumes.
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- registerrecord-config:/config:ro
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — upstream Maykin image, verbatim ─────────────────
# The authoritative object store. Same shape as Objecttypen (own DB + redis, an `-init` that runs
# setup_configuration from the external config volume, a health-checked web). Two differences: the
@@ -662,6 +704,9 @@ services:
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env
# 1 uWSGI worker, not the image default of 4×4 — see the objecttypen note above (#144).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
@@ -673,6 +718,14 @@ services:
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
CELERY_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
# off until all four pieces existed.
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
@@ -700,6 +753,28 @@ services:
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks:
cg:
# Objecten reflects the *request* Host into the `url` it returns, and
# notifications_api_common publishes that url as the notification's hoofdObject /
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
# Reads are unaffected and still use the plain service name.
aliases:
- objecten.local
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
# 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. Mirrors oz-celery.
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
objecten-celery:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env
command: /celery_worker.sh
depends_on:
objecten-init:
condition: service_completed_successfully
@@ -774,6 +849,9 @@ volumes:
objecttypen-config:
external: true
name: rr-objecttypen-config
registerrecord-config:
external: true
name: rr-registerrecord-config
objecten-config:
external: true
name: rr-objecten-config
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""S-19b-1 (#152): driver for the Objecten → NRC notification check.
Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a
RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller
(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up,
and prints `OBJECT_URL <url>` for the caller to grep on.
Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback.
Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery.
Stdlib only so it runs in a bare python:3-slim container on the compose network.
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request
OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000
OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"]
OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
NRC_BASE = os.environ["NRC_BASE"] # http://<nrc-ip>:8000
SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://<sink-ip>:9000/
SINK_AUTH = os.environ["SINK_AUTH"]
CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me")
KANAAL = "objecten"
def mint():
"""The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py)."""
def seg(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=")
payload = seg({
"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID,
"user_id": CLIENT_ID, "user_representation": CLIENT_ID,
})
signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload
signature = base64.urlsafe_b64encode(
hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=")
return (signing_input + b"." + signature).decode()
def nrc(method, url, body=None):
"""Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones)."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method,
headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r) if r.length != 0 else {}
except urllib.error.HTTPError as e:
# The body carries the reason (e.g. an unregistered kanaal); the status alone does not.
raise SystemExit(f"FAIL — NRC {method} {url}{e.code}: {e.read().decode(errors='replace')[:400]}")
def token_api(base, token, method, path, body=None, crs=False):
data = json.dumps(body).encode() if body is not None else None
headers = {"Authorization": f"Token {token}"}
if body is not None:
headers["Content-Type"] = "application/json"
if crs:
headers["Accept-Crs"] = "EPSG:4326"
if body is not None:
headers["Content-Crs"] = "EPSG:4326"
req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r) if r.length != 0 else {}
def subscribe():
"""Register an abonnement on the objecten kanaal, replacing a stale one for the same callback."""
# NRC returns a bare list here, not a paginated envelope.
for existing in nrc("GET", "/api/v1/abonnement") or []:
if existing.get("callbackUrl") == SINK_CALLBACK:
nrc("DELETE", existing["url"])
nrc("POST", "/api/v1/abonnement", {
"callbackUrl": SINK_CALLBACK,
"auth": SINK_AUTH,
"kanalen": [{"naam": KANAAL, "filters": {}}],
})
print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}")
def objecttype_url():
results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", [])
match = next((o for o in results if o.get("name") == "RegisterRecord"), None)
if not match:
print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr)
raise SystemExit(1)
return match["url"]
def main():
subscribe()
reference = f"NOTIF-{int(time.time())}"
created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", {
"type": objecttype_url(),
"record": {
"typeVersion": 1,
"data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference},
"startAt": time.strftime("%Y-%m-%d"),
},
}, crs=True)
print(f">> wrote RegisterRecord {created['url']}")
# An NRC notification carries no record data — only hoofdObject/resourceUrl — so the object
# URL, not the reference in its data, is what the caller can correlate the delivery on.
print(f"OBJECT_URL {created['url']}")
return 0
if __name__ == "__main__":
sys.exit(main())
+29 -1
View File
@@ -18,8 +18,29 @@ zgw_consumers:
auth_type: api_key
header_key: Authorization
header_value: Token 0123456789abcdef0123456789abcdef01234567
# (1b) The NRC Objecten publishes register-record events to (S-19b-1, ADR-0029). Same shape and
# same big-reference-seed credential OpenZaak publishes with — NRC verifies the JWT and
# authorizes it via OpenZaak's AC, which grants that client heeft_alle_autorisaties.
- identifier: nrc
label: Open Notificaties
api_type: nrc
api_root: http://nrc-web:8000/api/v1/
auth_type: zgw
client_id: big-reference-seed
secret: insecure-dev-secret-change-me
# (2) Static API token peers use to write/read objects.
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid
# instead of letting Objecttypen assign one. Keep the two in step.
objecttypes_config_enable: true
objecttypes:
items:
- uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01
name: RegisterRecord
service_identifier: objecttypen
# (3) Static API token peers use to write/read objects.
tokenauth_config_enable: true
tokenauth:
items:
@@ -29,3 +50,10 @@ tokenauth:
email: admin@localhost
organization: Respellion
is_superuser: true
# (4) Point Objecten's notifications at that NRC service (S-19b-1, ADR-0029). Requires
# NOTIFICATIONS_DISABLED=false plus a celery broker + worker — without the worker the message is
# queued and never sent, which is exactly the half-wired state S-19a refused to ship (ADR-0028).
notifications_config_enable: true
notifications_config:
notifications_api_service_identifier: nrc
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""S-18c (#141): register the RegisterRecord objecttype + a published version in the Objecttypen API.
Run by the `registerrecord-init` compose one-shot once Objecttypen is healthy. The Objecttypen API's
setup_configuration (3.4.2) can only provision tokens it has no declarative objecttype step so
the objecttype is created over the API here (the ADR-0020 self-seed pattern), idempotently: if a
"RegisterRecord" objecttype with a published version already exists, it's a no-op. Stdlib only.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/")
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json")
NAME = "RegisterRecord"
# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose
# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static
# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead
# of threading a seed-time value between two containers. See infra/objecten/setup_configuration.
UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01"
def api(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}{path}", data=data, method=method,
headers={"Authorization": f"Token {TOKEN}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r) if r.length != 0 else {}
def wait_ready():
"""Objecttypen depends_on health already, but tolerate a slow first request."""
for _ in range(20):
try:
api("GET", "/api/v2/objecttypes")
return
except (urllib.error.URLError, ConnectionError, TimeoutError):
time.sleep(3)
api("GET", "/api/v2/objecttypes") # last try, let it raise
def main():
schema = json.load(open(SCHEMA_PATH))
wait_ready()
existing = next(
(o for o in api("GET", "/api/v2/objecttypes").get("results", []) if o.get("name") == NAME),
None,
)
if existing and existing.get("versions"):
print(f"RegisterRecord already registered ({len(existing['versions'])} version(s)) — no-op")
return 0
ot = existing or api("POST", "/api/v2/objecttypes", {
"uuid": UUID,
"name": NAME,
"namePlural": "RegisterRecords",
"description": schema.get("description", ""),
"dataClassification": "open", # public-safe: the openbaar register may show it
})
uuid = ot["uuid"]
ver = api("POST", f"/api/v2/objecttypes/{uuid}/versions", {
"status": "published",
"jsonSchema": schema,
})
print(f"registered RegisterRecord {uuid} v{ver.get('version')} ({ver.get('status')})")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RegisterRecord",
"description": "Public-safe register entry shown in the openbaar (public) register. Mirrors the BFF's OpenbaarEntry (services/bff/Bff.Api/DownstreamClients.cs) — deliberately NO bsn or naam. S-19 (#20) writes records against this schema in the Objecten API on approval. See ADR-0027.",
"type": "object",
"additionalProperties": false,
"required": ["id", "status"],
"properties": {
"id": {
"type": "string",
"description": "Zaak id — the register entry's stable primary key (the projection key)."
},
"status": {
"type": "string",
"enum": ["INGEDIEND", "INGESCHREVEN"],
"description": "Registration lifecycle status (RegistrationStatus)."
},
"reference": {
"type": ["string", "null"],
"description": "Citizen-facing zaak identificatie shown publicly (ADR-0012)."
}
}
}
@@ -29,7 +29,9 @@ autorisaties_api_config_enable: true
autorisaties_api:
authorizations_api_service_identifier: openzaak-ac
# 4. The kanaal OpenZaak publishes zaak events on.
# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1).
# Both authenticate with the big-reference-seed credential above, which OpenZaak's AC grants
# heeft_alle_autorisaties — so no separate publisher authorization is needed for Objecten.
notifications_kanalen_config_enable: true
notifications_kanalen_config:
items:
@@ -39,3 +41,11 @@ notifications_kanalen_config:
- bronorganisatie
- zaaktype
- vertrouwelijkheidaanduiding
# 5. The kanaal Objecten publishes register-record events on (S-19b-1, ADR-0029). Its name is
# fixed by the Objects API itself (NOTIFICATIONS_KANAAL = "objecten"), not chosen here. The
# filter set matches what the Objects API sends as kenmerken, so an abonnement can narrow by
# objecttype rather than receiving every object write in the register.
- naam: objecten
documentatie_link: https://objects-and-objecttypes-api.readthedocs.io/
filters:
- object_type
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""S-18c (#141): prove the RegisterRecord objecttype is registered + published in the Objecttypen API.
Assert the objecttype named "RegisterRecord" exists, has a **published** version, and that version's
jsonSchema carries the public-safe fields (id, status, reference) i.e. registerrecord-init ran and
seeded the schema S-19 will write records against. Stdlib only so it runs in a bare python:3-slim
container on the compose network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
TIMEOUT = int(os.environ.get("REGISTERRECORD_TIMEOUT", "60"))
NAME = "RegisterRecord"
EXPECTED_FIELDS = {"id", "status", "reference"}
def get(path):
req = urllib.request.Request(f"{BASE}{path}", headers={"Authorization": f"Token {TOKEN}"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
def check():
"""Return (ok, detail). Raises on transport errors so the caller can retry."""
ots = get("/api/v2/objecttypes").get("results", [])
match = next((o for o in ots if o.get("name") == NAME), None)
if not match:
return False, f"no objecttype named {NAME!r} (have: {[o.get('name') for o in ots]})"
if not match.get("versions"):
return False, f"{NAME} exists but has no versions"
# The versions list holds URLs; fetch each to find a published one.
for ver_url in match["versions"]:
ver = get(ver_url[len(BASE):] if ver_url.startswith(BASE) else ver_url)
if ver.get("status") != "published":
continue
props = set((ver.get("jsonSchema") or {}).get("properties", {}))
if not EXPECTED_FIELDS <= props:
return False, f"published version missing fields: {EXPECTED_FIELDS - props}"
return True, f"{NAME} v{ver.get('version')} published, fields={sorted(props)}"
return False, f"{NAME} has versions but none are published"
def main():
deadline = time.time() + TIMEOUT
detail = "no attempt"
while time.time() < deadline:
try:
ok, detail = check()
if ok:
print(f"OK — {detail}")
return 0
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
detail = f"transport: {e}"
time.sleep(3)
print(f"FAIL — {detail}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -142,6 +142,7 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full
# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink,
# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink
# receives the notification.
#
# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat →
# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is
# connected for real, rather than merely configured.
#
# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's
# callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
# EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the
# objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028);
# and Objecten, reached by its `objecten.local` alias because it reflects the request Host into the
# notification's hoofdObject/resourceUrl, which NRC validates as a URL (ADR-0029).
#
# Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SINK_AUTH="Bearer objecten-notification-sink-token"
cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; }
trap cleanup EXIT
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis.
obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)"
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)"
nrc_ip="$(ip "$nrc")"
echo ">> network=$net nrc=$nrc_ip"
echo ">> starting the webhook sink"
docker rm -f rr-osink >/dev/null 2>&1 || true
sink="$(docker create --network "$net" --name rr-osink -e "EXPECTED_AUTH=$SINK_AUTH" \
python:3-slim python /sink.py)"
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
docker start "$sink" >/dev/null
sleep 1
sink_ip="$(ip rr-osink)"
echo ">> sink at $sink_ip:9000"
echo ">> registering the abonnement + writing a RegisterRecord"
docker rm -f rr-overify >/dev/null 2>&1 || true
drv="$(docker create --network "$net" --name rr-overify \
-e "OBJECTEN=http://objecten.local:8000" \
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
-e "OBJECTTYPEN=http://objecttypen:8000" \
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
-e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
python:3-slim python /driver.py)"
docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null
docker start -a "$drv"
object_url="$(docker logs rr-overify 2>/dev/null | sed -n 's/^OBJECT_URL //p' | head -1)"
docker rm -f rr-overify >/dev/null
[ -n "$object_url" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; }
echo ">> wrote $object_url"
# Correlate on the object URL: a notification carries hoofdObject/resourceUrl, never the record
# data, so the reference inside the record is not in the delivered message.
echo ">> waiting for the notification to reach the sink"
for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do
if docker logs rr-osink 2>&1 | grep -qF "$object_url"; then
echo "OK — Objecten published to NRC and the abonnement delivered it:"
docker logs rr-osink 2>&1 | grep -F "$object_url" | tail -1 | cut -c1-500
exit 0
fi
sleep 2
done
echo "FAIL — no 'objecten' notification for $object_url reached the sink." >&2
echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2
echo " the kanaal registration, or Objecten's notifications_config." >&2
echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2
echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2
exit 1
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# S-18c (#141): assert the RegisterRecord objecttype is registered + published in the Objecttypen
# API, against an ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack
# network (the service is reached by container IP; the runner can't reach published ports —
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecttypen=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
-e "REGISTERRECORD_TIMEOUT=${REGISTERRECORD_TIMEOUT:-60}" \
python:3-slim python /registerrecord-check.py)"
docker cp "$here/registerrecord-check.py" "$cid:/registerrecord-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+3 -2
View File
@@ -13,7 +13,7 @@
# subcommand. Fixed-name `external` volumes keep the names deterministic across
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
#
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten }
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten, registerrecord }
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -33,7 +33,7 @@ populate() { # volume source(file or dir/.)
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|objecttypen|objecten> ..." >&2; exit 2; }
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|objecttypen|objecten|registerrecord> ..." >&2; exit 2; }
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
@@ -51,6 +51,7 @@ for key in "$@"; do
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
objecttypen) populate rr-objecttypen-config "$here/objecttypen/setup_configuration/." ;;
objecten) populate rr-objecten-config "$here/objecten/setup_configuration/." ;;
registerrecord) populate rr-registerrecord-config "$here/objecttypen-registerrecord/." ;;
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;
esac
+5
View File
@@ -42,7 +42,12 @@ builder.Services.AddSingleton<IDefaultFillStore>(sp =>
return new InMemoryDefaultFillStore(
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
});
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
builder.Services.AddScoped<AclService>();
+25 -3
View File
@@ -2,7 +2,12 @@ namespace Acl.Application;
/// <summary>The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock)
public sealed class AclService(
IZaakGateway gateway,
IRegisterRecordGateway register,
IDefaultFillStore fill,
IZaaktypeCatalog catalog,
IClock clock)
{
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
{
@@ -23,16 +28,33 @@ public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZa
}
/// <summary>
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27),
/// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the
/// ACL owns which statustype means "approved" and what the register record looks like (§8.1).
/// </summary>
/// <remarks>
/// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes
/// across two modules and is eventually consistent by construction. Both are idempotent — a status
/// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed
/// approval converges rather than duplicating.
/// </remarks>
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
await register.UpsertAsync(
new RegisterRecord(
ZaakId(zaakUrl),
RegisterRecordStatus.Ingeschreven,
await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)),
ct);
}
/// <summary>The zaak's UUID — the key the register record and the read projection rows share.</summary>
private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/');
/// <summary>
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
@@ -0,0 +1,30 @@
namespace Acl.Application;
/// <summary>
/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028).
/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the
/// upstream Common Ground module (§8.1).
/// </summary>
public interface IRegisterRecordGateway
{
/// <summary>
/// Write the register record for a registration, creating it if absent and updating it if it
/// already exists. Idempotent on <see cref="RegisterRecord.Id"/>: a replayed approval updates
/// the existing object instead of creating a second one (§8.6).
/// </summary>
Task UpsertAsync(RegisterRecord record, CancellationToken ct = default);
}
/// <summary>
/// The public-safe register record, matching the <c>RegisterRecord</c> objecttype schema registered
/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable.
/// </summary>
public sealed record RegisterRecord(string Id, string Status, string? Reference);
/// <summary>The register statuses the RegisterRecord objecttype's schema allows (ADR-0027).</summary>
public static class RegisterRecordStatus
{
public const string Ingediend = "INGEDIEND";
public const string Ingeschreven = "INGESCHREVEN";
}
@@ -0,0 +1,160 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Acl.Application;
namespace Acl.Infrastructure;
/// <summary>
/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object
/// of the <c>RegisterRecord</c> objecttype registered in S-18c.
/// </summary>
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
{
// The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by
// name on first use rather than pinned in config (same reasoning as ADR-0021).
// ponytail: memoised per instance only — the gateway is a transient typed client, so in practice
// that is one extra GET per approval against a neighbouring container. Lift it into a singleton
// cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot.
private Objecttype? objecttype;
public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(record);
var type = objecttype ??= await ResolveObjecttypeAsync(ct);
var existing = await FindExistingAsync(type.Url, record.Id, ct);
var data = new RecordDataDto(record.Id, record.Status, record.Reference);
// No existing object → create; otherwise PATCH, which appends a new record version to the same
// object. Either way the register ends up with exactly one object per registration (§8.6).
if (existing is null)
await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"),
new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)),
"Creating the register record", ct);
else
await SendAsync(HttpMethod.Patch, existing,
new PatchObjectDto(NewRecord(type.Version, data)),
"Updating the register record", ct);
}
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
/// <summary>The URL + latest published version of the configured objecttype, read from Objecttypen.</summary>
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
{
var page = await GetAsync<ObjecttypePage>(
new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"),
options.ObjecttypenToken, crs: false, "objecttypen", ct);
var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName)
?? throw new InvalidOperationException(
$"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?");
// Write against the highest *published* version: a draft version's schema is still being
// shaped, and objects written against it would be validated by a moving target. The objecttype
// carries its versions as URLs, so each is fetched for its status (the collection response
// gives no status) — once per gateway instance, alongside the lookup above.
var latest = 0;
foreach (var versionUrl in match.Versions ?? [])
{
var version = await GetAsync<ObjecttypeVersionDto>(
new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct);
if (version.Status == "published" && version.Version > latest)
latest = version.Version;
}
if (latest == 0)
throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
return new Objecttype(new Uri(match.Url), latest);
}
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
private async Task<Uri?> FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) +
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
var page = await GetAsync<ObjectPage>(query, options.Token, crs: true, "objects", ct);
var match = (page.Results ?? []).FirstOrDefault();
return match is null ? null : new Uri(match.Url);
}
private async Task<T> GetAsync<T>(Uri uri, string token, bool crs, string label, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Get, uri);
message.Headers.Authorization = new AuthenticationHeaderValue("Token", token);
if (crs)
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"Objecten returned an empty {label} response");
}
private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) };
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
// The Objecten API is a geo API: it requires the CRS headers on reads and writes alike.
message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
// As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
}
// As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error
// Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write.
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
}
private sealed record Objecttype(Uri Url, int Version);
private sealed record ObjecttypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ObjecttypeDto>? Results);
private sealed record ObjecttypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
private sealed record ObjecttypeVersionDto(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("status")] string? Status);
private sealed record ObjectPage(
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
private sealed record ObjectDto(
[property: JsonPropertyName("url")] string Url);
private sealed record CreateObjectDto(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("record")] RecordDto Record);
private sealed record PatchObjectDto(
[property: JsonPropertyName("record")] RecordDto Record);
private sealed record RecordDto(
[property: JsonPropertyName("typeVersion")] int TypeVersion,
[property: JsonPropertyName("data")] RecordDataDto Data,
[property: JsonPropertyName("startAt")] string StartAt);
private sealed record RecordDataDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("reference")] string? Reference);
}
@@ -0,0 +1,20 @@
namespace Acl.Infrastructure;
/// <summary>
/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a
/// static <c>Authorization: Token …</c> (they are not ZGW JWT APIs), so there is no client-id/secret
/// pair as with OpenZaak.
/// </summary>
public sealed class ObjectenOptions
{
public required Uri BaseUrl { get; init; }
public required string Token { get; init; }
/// <summary>Objecttypen API root — the ACL resolves the objecttype URL + version from it by name
/// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021).</summary>
public required Uri ObjecttypenBaseUrl { get; init; }
public required string ObjecttypenToken { get; init; }
/// <summary>The objecttype the register record is written as (S-18c registers "RegisterRecord").</summary>
public required string ObjecttypeName { get; init; }
}
@@ -0,0 +1,105 @@
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed
/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one
/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype
/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being
/// idempotent (ADR-0028).
/// </summary>
[Trait("Category", "Integration")]
public sealed class ObjectenGatewayIntegrationTests
{
private static string Env(string key, string fallback) =>
Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
private static ObjectenGateway Gateway() => new(
new HttpClient(),
new ObjectenOptions
{
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
ObjecttypeName = "RegisterRecord",
},
new SystemClock());
[Fact]
public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write()
{
var gateway = Gateway();
// A key no other run shares: the verify stack is shared and keeps records between checks.
var id = Guid.NewGuid().ToString();
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1"));
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1"));
var records = await ReadAllAsync(id);
var only = Assert.Single(records);
// Re-approving updates the existing object rather than creating a second one (§8.6).
Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status);
Assert.Equal("INT-TEST-1", only.Reference);
}
[Fact]
public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe()
{
// The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the
// guarantee from the other side: Objecten itself refuses anything the schema does not sanction
// (ADR-0027). Posted raw, exactly as the gateway would post a record.
var gateway = Gateway();
var id = Guid.NewGuid().ToString();
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2"));
var stored = Assert.Single(await ReadAllAsync(id));
Assert.Null(stored.Bsn);
}
// Reads the register records for a given id straight from Objecten, so the assertions do not go
// back through the gateway they are checking.
private static async Task<IReadOnlyList<StoredRecord>> ReadAllAsync(string id)
{
using var http = new HttpClient();
var objecttype = await ResolveObjecttypeUrlAsync(http);
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}");
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message);
response.EnsureSuccessStatusCode();
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("results").EnumerateArray()
.Select(o => o.GetProperty("record").GetProperty("data"))
.Select(d => new StoredRecord(
d.GetProperty("status").GetString()!,
d.GetProperty("reference").GetString(),
d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null))
.ToList();
}
private static async Task<string> ResolveObjecttypeUrlAsync(HttpClient http)
{
var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes");
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}");
using var response = await http.SendAsync(message);
response.EnsureSuccessStatusCode();
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("results").EnumerateArray()
.First(o => o.GetProperty("name").GetString() == "RegisterRecord")
.GetProperty("url").GetString()!;
}
private sealed record StoredRecord(string Status, string? Reference, string? Bsn);
}
+48 -2
View File
@@ -75,6 +75,17 @@ public class AclServiceTests
Task.FromResult(Zaaktypen);
}
private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway
{
public readonly List<RegisterRecord> Upserted = [];
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
{
Upserted.Add(record);
return Task.CompletedTask;
}
}
private static AclDefaults Defaults() => new()
{
Bronorganisatie = "517439943",
@@ -88,7 +99,10 @@ public class AclServiceTests
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) =>
new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
private sealed class FixedClock(DateOnly today) : IClock
{
@@ -161,10 +175,42 @@ public class AclServiceTests
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
Assert.Empty(register.Upserted);
}
[Fact]
public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */)
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
var record = Assert.Single(register.Upserted);
// The record is keyed on the zaak id — the same key the read projection rows carry (S-19b).
Assert.Equal("abc", record.Id);
Assert.Equal("INGESCHREVEN", record.Status);
// The public-safe reference comes from the zaak's identificatie, never from the domain payload.
Assert.Equal("REG-FROM-ZAAK", record.Reference);
}
[Fact]
public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */)
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
// Only an approval enters the register; a cancelled zaak never becomes a register record.
Assert.Empty(register.Upserted);
}
[Fact]
@@ -0,0 +1,330 @@
using System.Net;
using System.Net.Http.Json;
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.Tests;
public class ObjectenGatewayTests
{
private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> onSend(request);
}
private sealed class FixedClock(DateOnly today) : IClock
{
public DateOnly Today { get; } = today;
}
private sealed record Sent(
HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength);
private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1";
private static ObjectenGateway Gateway(List<Sent> sent, Func<HttpRequestMessage, HttpResponseMessage> respond) =>
new(
new HttpClient(new StubHandler(async req =>
{
// Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set
// ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi
// rejects a chunked body).
sent.Add(new Sent(
req.Method,
req.RequestUri!,
ContentLength: req.Content?.Headers.ContentLength,
Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(),
Auth: req.Headers.Authorization?.ToString(),
ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null,
AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null));
return respond(req);
})),
new ObjectenOptions
{
BaseUrl = new("http://objecten:8000"),
Token = "objecten-token",
ObjecttypenBaseUrl = new("http://objecttypen:8000"),
ObjecttypenToken = "objecttypen-token",
ObjecttypeName = "RegisterRecord",
},
new FixedClock(new DateOnly(2026, 6, 4)));
// A published v1 and v2, plus a draft v3 that must never be written against even though it is the
// highest version.
private static readonly Dictionary<string, object> Versions = new()
{
[$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" },
[$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" },
[$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" },
};
// A stack that answers the reads every write is preceded by: the objecttype list (matched by name),
// each of that objecttype's versions, and the Objecten search for an existing record.
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
Versions.TryGetValue(req.RequestUri!.ToString(), out var version)
? Json(version)
: req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
? Json(new
{
results = new[]
{
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty<string>() },
new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() },
},
})
: req.Method == HttpMethod.Get
? Json(new { results = existingObjects })
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
private static HttpResponseMessage Json(object body) =>
new(HttpStatusCode.OK) { Content = JsonContent.Create(body) };
private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
[Fact]
public async Task Creates_the_object_when_none_exists_for_the_registration()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
// The highest *published* version (2), not the highest version (a draft 3).
Assert.Contains("\"typeVersion\":2", write.Body);
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body);
Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body);
}
[Fact]
public async Task Updates_the_existing_object_instead_of_creating_a_second_one()
{
var sent = new List<Sent>();
object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }];
await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record());
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
var write = sent.Single(s => s.Method == HttpMethod.Patch);
Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString());
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
}
[Fact]
public async Task Searches_objecten_for_the_registration_id_within_the_objecttype()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects");
Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query);
Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query);
}
[Fact]
public async Task Authenticates_with_the_static_token_of_each_api()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
Assert.All(
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
s => Assert.Equal("Token objecttypen-token", s.Auth));
Assert.All(
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)),
s => Assert.Equal("Token objecten-token", s.Auth));
}
[Fact]
public async Task Sends_the_geo_crs_headers_the_objecten_api_requires()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList();
Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs));
Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs));
}
[Fact]
public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => Route(req, []));
await gateway.UpsertAsync(Record());
await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes");
}
[Fact]
public async Task Fails_loudly_when_the_objecttype_has_no_published_version()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal)
? Json(new { version = 1, status = "draft" })
: Route(req, []));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("published version", error.Message);
}
[Fact]
public async Task Fails_loudly_when_the_objecttype_is_not_registered()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
});
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("RegisterRecord", error.Message);
}
[Fact]
public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects"
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") }
: Route(req, []));
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("schema mismatch", error.Message);
Assert.Contains("Creating the register record", error.Message);
}
[Fact]
public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected()
{
var sent = new List<Sent>();
object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }];
var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") }
: Route(req, existing));
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("stale version", error.Message);
Assert.Contains("Updating the register record", error.Message);
}
[Fact]
public async Task Surfaces_a_failed_read_instead_of_writing_blind()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("{\"detail\":\"invalid token\"}"),
});
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("Querying objecttypen", error.Message);
Assert.Contains("invalid token", error.Message);
// A read that failed must never be mistaken for "nothing there yet" and followed by a write.
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch);
}
[Fact]
public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes"
? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } })
: Route(req, []));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("published version", error.Message);
}
[Fact]
public async Task Says_which_read_failed_when_the_objecten_search_errors()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") }
: Route(req, []));
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("Querying objects", error.Message);
}
[Fact]
public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
});
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("objecttypen", error.Message);
}
[Fact]
public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing()
{
var sent = new List<Sent>();
// The objecttypes collection carries no `results` key — the objecttype is absent, which must
// surface as the "not registered" error rather than an ArgumentNullException from LINQ.
var gateway = Gateway(sent, _ => Json(new { }));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("RegisterRecord", error.Message);
}
[Fact]
public async Task Creates_the_object_when_the_search_response_carries_no_results_key()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
? Json(new { })
: Route(req, []));
await gateway.UpsertAsync(Record());
Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
}
[Fact]
public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
// Objecttypen is not a geo API; only the Objecten hops carry CRS.
Assert.All(
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
s => Assert.Null(s.AcceptCrs));
}
[Fact]
public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
Assert.NotNull(write.ContentLength);
}
[Fact]
public async Task Rejects_a_null_record_without_calling_objecten()
{
var sent = new List<Sent>();
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!));
Assert.Empty(sent);
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ public sealed class EenZaakOpenenSteps
{
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
_returnedUrl = await service.OpenZaakAsync(_registration!);
}
@@ -53,3 +53,16 @@ public sealed class InMemoryZaakGateway : IZaakGateway
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
}
/// <summary>An in-memory stand-in for the Objecten API (S-19a): records the register records the ACL
/// writes on approval, so a scenario can assert on them without a running Objecten.</summary>
public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway
{
public List<RegisterRecord> Upserted { get; } = [];
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
{
Upserted.Add(record);
return Task.CompletedTask;
}
}
+50 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, request, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
@@ -109,4 +109,53 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
// S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028).
// Asserted here rather than in verify-domain because this is the only check that drives a *real*
// approval — verify-domain completes the Beoordelen task straight through Flowable REST, which
// bypasses the domain `decide` path that calls the ACL.
const records = await registerRecordsFor(reference);
// Matched on OUR reference: the verify stack is shared and holds records from earlier checks.
expect(records, `expected exactly one RegisterRecord for ${reference}`).toHaveLength(1);
expect(records[0].status).toBe('INGESCHREVEN');
// The register is world-readable, so the record must carry nothing but the public-safe fields
// (ADR-0027) — Objecten's own schema validation enforces this, and this proves it end to end.
expect(Object.keys(records[0]).sort()).toEqual(['id', 'reference', 'status']);
});
const OBJECTEN = process.env.OBJECTEN_URL ?? 'http://objecten:8000';
const OBJECTTYPEN = process.env.OBJECTTYPEN_URL ?? 'http://objecttypen:8000';
const OBJECTEN_TOKEN = process.env.OBJECTEN_TOKEN ?? '1234567890abcdef1234567890abcdef12345678';
const OBJECTTYPEN_TOKEN = process.env.OBJECTTYPEN_TOKEN ?? '0123456789abcdef0123456789abcdef01234567';
/**
* The RegisterRecord objects Objecten holds for a registration reference.
*
* The objecttype is resolved by name rather than pinned: Objecttypen echoes the request Host into
* the objecttype `url`, and Objecten only accepts the one matching its configured api_root so
* both must be reached by service name, exactly as the ACL reaches them (ADR-0028).
*/
async function registerRecordsFor(reference: string): Promise<Record<string, string>[]> {
const api = await request.newContext();
try {
const types = await api.get(`${OBJECTTYPEN}/api/v2/objecttypes`, {
headers: { Authorization: `Token ${OBJECTTYPEN_TOKEN}` },
});
expect(types.ok(), `Objecttypen returned ${types.status()}`).toBeTruthy();
const objecttype = ((await types.json()).results as { url: string; name: string }[]).find(
(o) => o.name === 'RegisterRecord',
);
if (!objecttype) throw new Error('the RegisterRecord objecttype is not registered in Objecttypen');
const objects = await api.get(`${OBJECTEN}/api/v2/objects`, {
headers: { Authorization: `Token ${OBJECTEN_TOKEN}`, 'Accept-Crs': 'EPSG:4326' },
params: { type: objecttype.url, data_attrs: `reference__exact__${reference}` },
});
expect(objects.ok(), `Objecten returned ${objects.status()}: ${await objects.text()}`).toBeTruthy();
return ((await objects.json()).results as { record: { data: Record<string, string> } }[]).map(
(o) => o.record.data,
);
} finally {
await api.dispose();
}
}