Compare commits

..
Author SHA1 Message Date
not b30fa664d8 feat(event-subscriber): accept partial_update as a register write (refs #153)
CI / lint (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 1m14s
CI / unit (pull_request) Successful in 1m26s
CI / frontend (pull_request) Successful in 3m7s
CI / mutation (pull_request) Successful in 6m13s
CI / verify-stack (pull_request) Successful in 9m32s
The ACL upserts with PATCH, so every approval notification carries actie `partial_update`.
Accepting it makes the INGEDIEND → INGESCHREVEN transition project. `update` stays accepted
so a PUT-shaped write behaves the same; `destroy` deliberately does not — removing a
registration from the public register is its own decision, not a side effect of this one.

ADR-0030 records why the actie list is what it is.
2026-08-28 13:40:01 +02:00
not 0dd26a711a test(event-subscriber): approval arrives as partial_update, not update (refs #153)
The e2e reached INGEDIEND but never INGESCHREVEN. NRC's own log says why:

  {"event": "notification_received", "action": "partial_update",
   "resource_url": "http://objecten.local:8000/api/v2/objects/a9a7f125-..."}

The ACL PATCHes the object on approval. DRF routes a PATCH through the notifying `update()`
but reports the action as `partial_update`, so accepting only `create`/`update` drops every
approval on the floor — the exact state change the slice exists to project.

Red: the approval case is now a Theory over both acties, and the partial_update one fails.
2026-08-28 13:39:03 +02:00
not 7e0897a41e fix(infra): repoint the acl at OpenZaak's IP before opening a zaak (refs #153)
CI / build (pull_request) Successful in 1m9s
CI / lint (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 3m9s
CI / mutation (pull_request) Successful in 6m7s
CI / verify-stack (pull_request) Failing after 9m56s
The projection check now opens its zaak through the ACL, which puts it in the same bind
run-domain-check.sh already handles:

  400 {"name":"zaaktype","code":"bad-url","reason":"Voer een geldige URL in."}

OpenZaak reflects the request Host into the zaaktype `url` it returns and then rejects that
same URL on zaak-create when the host is single-label. The stack's ACL is configured with
`http://openzaak:8000/`, so it has to be recreated with ACL_OPENZAAK_BASEURL pointed at
OpenZaak's container IP first — the mechanism compose already documents on that variable.

Same class of constraint as the objecten.local alias (ADR-0029), and the third module now
known to reflect a request Host into data another module validates.
2026-08-28 13:18:00 +02:00
not 744f91a2b2 fix(infra): anchor wait-healthy's container lookup on the compose replica suffix (refs #153)
CI / build (pull_request) Successful in 1m6s
CI / lint (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m12s
CI / mutation (pull_request) Successful in 6m20s
CI / verify-stack (pull_request) Failing after 5m43s
Bring-up timed out with

  TIMEOUT: 'objecten' not healthy (status=none)

while the very `docker ps` it dumps showed infra-objecten-1 "Up 9 minutes (healthy)".

`--filter name=` is a substring match, so `objecten` also matches objecten-db,
objecten-redis and (since #152) objecten-celery. `head -1` took whichever docker listed
first; the celery worker declares no healthcheck, so it inspected as status=none and the
wait sat there until the deadline.

Not objecten-specific — `objecttypen` matches objecttypen-db the same way. The bug has been
latent since those services landed and was decided by listing order, which is why it only
surfaced now. Anchored on the replica suffix, matching both docker compose and
podman-compose naming — the same anchoring the verify check scripts already use.
2026-08-28 13:01:10 +02:00
5 changed files with 54 additions and 13 deletions
@@ -34,6 +34,12 @@ notification points at. The projection is a cache of the register; ZGW is no lon
as a kenmerk — so the record is read back through the ACL (`POST /register-records/read`).
§8.1 applies to Objecten exactly as ADR-0028 established: the ACL is the only code that talks
to it.
- The accepted acties are `create`, `update` and `partial_update`. The last one is not
defensive breadth: the ACL upserts with PATCH, and DRF routes a PATCH through the notifying
`update()` while naming the action `partial_update` — which is what Objecten publishes. So
every approval arrives as `partial_update`, and accepting only `create`/`update` drops the
one state change this slice exists to project. `destroy` is deliberately not accepted:
removing a registration from the public register is its own decision.
- The record already carries `id`, `status` and `reference`, so the row is the record. The
zaak-shaped surface goes: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId`, and
`ToEntry`'s `Resource == "status"` inference are replaced by `IsRegisterRecordWritten` +
+19 -2
View File
@@ -12,11 +12,15 @@
# which is the point of the re-source.
#
# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Does NOT manage the stack
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008/0030.
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Does not own the stack
# lifecycle (the caller brings it up and tears it down), but does recreate the `acl` service to
# repoint it — see below, and run-domain-check.sh, which does the same. Plain docker primitives only.
# See ADR-0007/0008/0030.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
compose="$root/infra/docker-compose.yml"
WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}"
cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; }
@@ -54,6 +58,19 @@ docker cp "$here/local/register-abonnement.py" "$drv:/subscribe.py" >/dev/null
docker start -a "$drv"
docker rm -f rr-pverify >/dev/null
# OpenZaak reflects the request Host into the zaaktype `url` it returns, and then rejects that same
# URL on zaak-create when the host is single-label ("Voer een geldige URL in."). The stack's ACL is
# configured with `http://openzaak:8000/`, so it must be repointed at OpenZaak's container IP before
# it can open a zaak — exactly what run-domain-check.sh does, and the same class of constraint as the
# `objecten.local` alias (ADR-0029). The ACL resolves the zaaktype itself (S-27, ADR-0021), so the
# base URL is the only thing to inject.
echo ">> recreating the acl service pointed at OpenZaak's IP"
ACL_OPENZAAK_BASEURL="http://$oz_ip:8000/" docker compose -f "$compose" up -d acl
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
# The container is replaced, so its IP may have changed.
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
acl_ip="$(ip "$acl")"
echo ">> opening a zaak through the ACL (which writes the INGEDIEND register record)"
reference="PROJ-$(date +%s)"
zaak_url="$(docker run --rm --network "$net" curlimages/curl:latest \
+7 -3
View File
@@ -15,9 +15,13 @@ set -euo pipefail
timeout="${WAIT_TIMEOUT:-420}"
deadline=$(( $(date +%s) + timeout ))
# compose service name -> container id. The name filter matches both docker
# compose ("infra-openzaak-1") and podman-compose ("infra_openzaak_1") naming.
cid_for() { docker ps -aq --filter "name=$1" | head -1; }
# compose service name -> container id. `--filter name=` is a substring match, so it is anchored on
# the compose replica suffix — otherwise 'objecten' also matches objecten-db / objecten-redis /
# objecten-celery, and 'objecttypen' matches objecttypen-db. Whichever docker listed first won, so a
# service with a sibling that has no healthcheck timed out with status=none while it was in fact
# healthy. The pattern matches both docker compose ("infra-objecten-1") and podman-compose
# ("infra_objecten_1") naming; the same anchoring the verify check scripts use.
cid_for() { docker ps -aq --filter "name=$1[-_][0-9]+\$" | head -1; }
for svc in "$@"; do
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
@@ -18,10 +18,20 @@ public sealed record Notification(
string Actie,
Uri ResourceUrl)
{
/// <summary>A register record written to Objecten — <c>create</c> on submit, <c>update</c> on
/// approval, since the ACL upserts the same object for a registration (§8.6).</summary>
/// <summary>
/// A register record written to Objecten — <c>create</c> on submit and <c>partial_update</c> on
/// approval, since the ACL upserts the same object for a registration (§8.6).
/// </summary>
/// <remarks>
/// <c>partial_update</c> is what a PATCH actually reports: DRF routes it through the notifying
/// <c>update()</c> but names the action <c>partial_update</c>, and that is what Objecten puts in
/// the notification. <c>update</c> is accepted too, so a PUT-shaped write would project the same
/// way. <c>destroy</c> is deliberately not: removing a registration from the public register is
/// its own decision, not a side effect of this one.
/// </remarks>
public bool IsRegisterRecordWritten =>
Kanaal == "objecten" && Resource == "object" && Actie is "create" or "update";
Kanaal == "objecten" && Resource == "object"
&& Actie is "create" or "update" or "partial_update";
/// <summary>The object holding the register record. For a <c>resource: object</c> notification
/// Objecten sends the object as both <c>hoofdObject</c> and <c>resourceUrl</c> — the object is
@@ -38,13 +38,17 @@ public sealed class NotificationProjectorTests
Assert.Equal("REG-2026-0001", entry.Reference);
}
[Fact]
public async Task approval_updates_the_same_row_from_ingediend_to_ingeschreven()
// The ACL PATCHes the same object on approval. DRF routes a PATCH through `update()` but reports
// the action as `partial_update`, which is what Objecten puts in the notification — so accepting
// only `create`/`update` silently drops every approval.
[Theory]
[InlineData("partial_update")]
[InlineData("update")]
public async Task approval_updates_the_same_row_from_ingediend_to_ingeschreven(string actie)
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
// The ACL PATCHes the same object on approval, so Objecten publishes an `update`.
await projector.HandleAsync(RecordWritten("update", status: RegistrationStatus.Ingeschreven));
await projector.HandleAsync(RecordWritten(actie, status: RegistrationStatus.Ingeschreven));
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal(ZaakId, entry.Id);
@@ -112,7 +116,7 @@ public sealed class NotificationProjectorTests
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten("update", status: RegistrationStatus.Ingeschreven));
await projector.HandleAsync(RecordWritten("partial_update", status: RegistrationStatus.Ingeschreven));
var callsAfterProjection = _acl.CallCount;
await projector.RebuildAsync();