Compare commits

..
Author SHA1 Message Date
not b496ac9477 refactor(event-subscriber): drop the hoofdObject fallback (refs #153)
CI / build (pull_request) Successful in 1m11s
CI / lint (pull_request) Successful in 1m25s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 3m5s
CI / mutation (pull_request) Successful in 6m7s
CI / verify-stack (pull_request) Failing after 11m42s
For a `resource: object` notification Objecten sends the object as both hoofdObject and
resourceUrl — the object *is* the main resource — so `HoofdObject ?? ResourceUrl` was a
branch that can never take its left side and that no test could distinguish. It came across
from the zaken path, where hoofdObject genuinely differed (the zaak behind a status).

Tests unchanged and green.
2026-08-28 12:38:27 +02:00
not 88a601123b docs(e2e): the happy path's public statuses now come from the register (refs #153)
Comment only — the assertions were already reference-matched and hold unchanged. Names the
new chain (ACL → Objecten → NRC → event-subscriber → projection) so the INGEDIEND assertion
reads as the proof of the re-source that it now is.
2026-08-28 12:37:21 +02:00
not 62fb986701 docs: ADR-0030 — the read projection is sourced from the register (refs #153)
Records the re-source and the three decisions inside it: the ACL writing an INGEDIEND record
on submit (without which re-sourcing silently drops every submitted registration), the dedup
key being the projected row rather than the notification, and the notification log holding
the row rather than the event.

Closes out ADR-0028's stated direction and the caveat it left open — the register record was
written but not yet read, and the two had to agree; there is now one source.
2026-08-28 12:36:52 +02:00
not ceb65991de feat(infra): subscribe the projection to the objecten kanaal (refs #153)
Completes the re-source (ADR-0030): the Event Subscriber's abonnement moves from `zaken` to
`objecten`, in both the local stack's `nrc-subscribe` and the CI projection check. The
OpenZaak → NRC check keeps its own `zaken` abonnement — OpenZaak still publishes, nothing
in the product listens.

- register-abonnement.py subscribes to `objecten`, and now treats the kanaal as part of
  "already current" — an abonnement left from before this slice points at the right callback
  but the wrong kanaal, and would never have been replaced on IP alone.
- run-projection-check.sh opens its zaak *through the ACL* instead of straight against
  OpenZaak, because the ACL is what writes the register record the projection is now derived
  from. A zaak created behind the ACL's back produces no row — which is the re-source working.
- The acceptance scenario is restated in register terms and gains the approval case: the same
  row moving INGEDIEND → INGESCHREVEN is now one registration's record being updated, not two
  unrelated ZGW events.
2026-08-28 12:35:44 +02:00
not 8af09b2c92 feat(event-subscriber): project register records read back through the ACL (refs #153)
HandleAsync reads the record at the notification's object URL through the ACL and writes it
to the projection verbatim — the record already carries id, status and reference, so there
is no mapping and no enrichment hop.

The dedup key is the object plus the state that write projects. It cannot be the object URL
alone (the ACL upserts one object per registration, so submit and approval notify about the
same URL and the approval would be swallowed), nor include the actie (a retried approval is
a second `update`). Keying on the projected row collapses redeliveries and lets genuine
state changes through — §8.6.
2026-08-28 12:32:42 +02:00
not 142ed454aa test(event-subscriber): the projection is sourced from register records (refs #153)
Ports, schema and failing tests for the subscriber half of S-19b-2, ahead of the
implementation.

The subscriber now listens on the `objecten` kanaal instead of `zaken`. An Objecten
notification carries no record data — only the object URL — so the record is read back
through the ACL (§8.1), and the zaak-shaped surface goes away: IsZaakCreated /
IsZaakStatusSet / ZaakUrl / ZaakId and ToEntry's `Resource == "status"` mapping are replaced
by IsRegisterRecordWritten + ObjectUrl.

The notification log now holds the projected row itself (register id, status, reference),
so a rebuild is a replay with no mapping rules and no upstream reads. The migration drops
the old columns rather than renaming them — EF scaffolded renames that would have carried
ZGW values into columns meaning something else — and empties both tables, since a
pre-slice row is neither reprojectable nor re-derivable from the new source.

Red: HandleAsync recognises a register write but does not yet read or project it, so the
seven projection assertions fail on an empty store.
2026-08-28 12:32:15 +02:00
not 566ef7dd64 feat(acl): write the INGEDIEND record on submit and read records back (refs #153)
- OpenZaakAsync upserts a RegisterRecord with status INGEDIEND after opening the zaak,
  keyed on the same zaak id approval later upserts to INGESCHREVEN. The reference comes
  from the registration, so this path needs no ZGW read-back.
- ObjectenGateway.GetAsync fetches an object by the URL a notification carried — no
  objecttype resolution, no search — and reads 404 as "no record" rather than an error.
- POST /register-records/read exposes it to the Event Subscriber, which may not talk to
  Objecten itself (§8.1).
2026-08-28 12:28:47 +02:00
not 06c0444859 test(acl): submit writes an INGEDIEND record, and records are readable back (refs #153)
Ports and failing tests for the ACL half of S-19b-2, ahead of the implementation.

Once the projection is sourced from Objecten (ADR-0028's stated direction), a submitted
registration has to exist in the register the moment the zaak is opened — otherwise
re-sourcing silently drops every INGEDIEND row, since today only approval writes a record.
So `OpenZaakAsync` gains a second write, and approval upserts that same record to
INGESCHREVEN.

The subscriber gets only an object URL on an `objecten` notification (the payload carries no
record data) and may not read Objecten itself (§8.1), so `IRegisterRecordGateway` gains a
read and `AclService` exposes it.

Red:
- AclService does not yet write on open → the record assertion fails on an empty list.
- ObjectenGateway.GetAsync is a shell throwing NotImplementedException; its tests pin the
  contract: fetch the object URL directly (no objecttype resolution, no search), the CRS
  header a geo API requires, static Token auth, and a 404 read as "nothing to project"
  rather than an error (§8.6).
2026-08-28 12:28:01 +02:00
8 changed files with 13 additions and 89 deletions
@@ -67,14 +67,6 @@ itself, so no in-image healthcheck tool is required.
- Three more images built each CI run (kept small; not on the health-gate list).
- Storage is ephemeral container fs — a demo backplane, not a retention target.
Object storage for Tempo / remote-write for Prometheus is a later concern.
- Tempo runs **single-binary**, so its distributor and ingester are one process and
some of its distributed-mode machinery is not just redundant but harmful. Its
ingester-pool health check is disabled (`ingester_client.pool_config`) because with
a single in-process ingester the check can never route around a failure — a 1s
loopback-gRPC deadline missed under CI load only evicted the one ingester and made
Tempo drop spans, which is how `verify-tracing` flaked (#156). Expect the same
shape from other distributed-mode knobs if we tune them; the fix is to switch to
real multi-ingester Tempo, not to re-enable them here.
## Coupling rules touched (CLAUDE.md §8)
@@ -34,12 +34,6 @@ 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` +
-12
View File
@@ -25,15 +25,3 @@ storage:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
# #156: don't let the distributor evict its own ingester. Tempo runs single-binary here, so the
# distributor and the ingester are the same process and the "pool" holds exactly one, in-process,
# member. dskit still health-checks it over loopback gRPC with a 1s deadline (checkinterval 15s);
# on the shared CI runner a transient stall blows that deadline, the only ingester is dropped from
# the pool ("removing distributor_pool failing healthcheck"), and every push then fails ("pusher
# failed to consume trace data", err="context canceled") until the next check — silently losing
# spans, which is how verify-tracing flaked. With one in-process ingester the check can never route
# around a failure, so it can only ever discard data. Turn it off.
ingester_client:
pool_config:
healthcheckenabled: false
+2 -19
View File
@@ -12,15 +12,11 @@
# 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 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.
# 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.
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; }
@@ -58,19 +54,6 @@ 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 \
-15
View File
@@ -59,20 +59,6 @@ def services_in_trace(trace_id):
return names
def tempo_ingest_state():
"""#156: distinguish a broken trace chain from Tempo dropping spans. `ingester_clients` is 0
when the distributor has evicted its (single, in-process) ingester over a failed loopback
health check — pushes fail and spans are lost, which looks identical to missing instrumentation
from here. Diagnostics only; never fails the check."""
try:
for line in _get(f"{TEMPO}/metrics").decode().splitlines():
if line.startswith("tempo_distributor_ingester_clients "):
return f"tempo {line.strip()} (0 = no ingester in the pool — evicted, so pushes\n are failing and spans are being dropped; see #156)"
except Exception as e:
return f"tempo /metrics unreadable: {e}"
return "tempo_distributor_ingester_clients not reported"
def main():
deadline = time.time() + TIMEOUT
generate_traffic()
@@ -88,7 +74,6 @@ def main():
generate_traffic()
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
file=sys.stderr)
print(f" {tempo_ingest_state()}", file=sys.stderr)
return 1
+3 -7
View File
@@ -15,13 +15,9 @@ set -euo pipefail
timeout="${WAIT_TIMEOUT:-420}"
deadline=$(( $(date +%s) + timeout ))
# 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; }
# 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; }
for svc in "$@"; do
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
@@ -18,20 +18,10 @@ public sealed record Notification(
string Actie,
Uri ResourceUrl)
{
/// <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>
/// <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>
public bool IsRegisterRecordWritten =>
Kanaal == "objecten" && Resource == "object"
&& Actie is "create" or "update" or "partial_update";
Kanaal == "objecten" && Resource == "object" && Actie is "create" or "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,17 +38,13 @@ public sealed class NotificationProjectorTests
Assert.Equal("REG-2026-0001", entry.Reference);
}
// 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)
[Fact]
public async Task approval_updates_the_same_row_from_ingediend_to_ingeschreven()
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten(actie, status: RegistrationStatus.Ingeschreven));
// The ACL PATCHes the same object on approval, so Objecten publishes an `update`.
await projector.HandleAsync(RecordWritten("update", status: RegistrationStatus.Ingeschreven));
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal(ZaakId, entry.Id);
@@ -116,7 +112,7 @@ public sealed class NotificationProjectorTests
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten("partial_update", status: RegistrationStatus.Ingeschreven));
await projector.HandleAsync(RecordWritten("update", status: RegistrationStatus.Ingeschreven));
var callsAfterProjection = _acl.CallCount;
await projector.RebuildAsync();