feat: read projection sourced from the register in Objecten (closes #153) (#155)
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m26s
CI / unit (push) Successful in 1m37s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 6m42s
CI / verify-stack (push) Failing after 11m26s

## What & why

S-19b-2, closing out ADR-0028's stated direction: **the read projection is now derived from the
`RegisterRecord` in Objecten, not from ZGW zaak events.**

Until now the subscriber listened on `zaken` and *inferred* register state from case events — a
`zaak/create` meant INGEDIEND, and any `status/create` was assumed to be the approval (it may not
read OpenZaak, so it could not tell statustypen apart). The reference wasn't in the notification
at all, so every projection made a second hop to the ACL. The register — a fact about a person —
was being reconstructed by guessing at the lifecycle of the case that produced it.

- The subscriber's abonnement moves to the `objecten` kanaal (S-19b-1 made it publish).
- An Objecten notification carries **no record data**, only the object URL, so the record is read
  back through the ACL (`POST /register-records/read`) — §8.1 applies to Objecten exactly as
  ADR-0028 established.
- The record carries `id`, `status` and `reference`, so the row *is* the record: `IsZaakCreated`,
  `IsZaakStatusSet`, `ZaakUrl`, `ZaakId` and `ToEntry`'s `Resource == "status"` inference are all
  gone, and so is the ACL enrichment hop.
- **The ACL now writes an INGEDIEND record on submit.** Without it, re-sourcing would silently
  drop every submitted registration from the public register, since only approval wrote a record.
- `processed_notifications` holds the projected row (`register_id`, `status`, `reference`) instead
  of the ZGW event, so a rebuild is a replay with no mapping rules and no upstream reads at all.

**ADR-0030** records it. ADR-0028's open caveat — record written but not yet read, "the two must
agree" — is closed: there is one source now.

Closes #153

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing tests committed before the implementation — two red/green pairs, ACL side
      (06c0444566ef7d) and subscriber side (142ed458af09b2).
- [x] Refactor commit follows (b496ac9).
- [x] Conventional Commits referencing the issue (`refs #153`).
- [x] CI green — all six jobs on b30fa66, `verify-stack` end to end including the e2e.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes
      (`verify-stack`'s bring-up step — see the wait-healthy fix below).
- [x] Docs updated — ADR-0030 added, ADR-0028's consequence + caveat annotated, BACKLOG.md,
      e2e header comment.
- [x] ADR added in `docs/architecture/`.
- [x] Demo note in `docs/demo-script.md` — n/a: no user-visible change. The openbaar register
      shows the same two statuses for the same registrations; only where they come from changed.

## Notes for reviewers

**The decision I'd most like a second opinion on** is the one the issue didn't settle: what
happens to INGEDIEND. Objecten held only INGESCHREVEN records, so re-sourcing forced a choice
between (a) the ACL also writing on submit, (b) a public register that lists only actual
registrations, or (c) a hybrid keeping both kanalen. I took (a): visible behaviour is unchanged
and the register holds the whole lifecycle. (b) is arguably the better *semantics* for a public
register but narrows what the portal shows and reads against PRD §68 ("~50 register entries with
diverse statuses"); (c) leaves the projection half-derived from ZGW, which is the coupling
ADR-0028 set out to remove. All three are laid out in ADR-0030.

**The dedup key is the projected row**, `objecten:object:{url}:{status}:{reference}` — not the
object URL (the ACL upserts *one object per registration*, so submit and approval notify about
the same URL and the approval would be swallowed as a duplicate) and not URL+actie (a retried
approval is a second `update`). Redeliveries collapse, genuine state changes don't. §8.6.

**The migration drops columns rather than renaming them.** EF scaffolded renames — `resource` →
`register_id`, `zaak_id` → `status` — which would have carried ZGW values into columns meaning
something else, and a rebuild would then have projected that garbage. It also empties both
tables: a pre-slice row describes a zaak event the new projector can't reproject, and those
registrations have no RegisterRecord in Objecten either, so they're not re-derivable from the new
source. Stated as a ceiling in the ADR — fine while stacks are ephemeral, backfill from Objecten
if a long-lived environment ever needs it.

**`run-projection-check.sh` now opens its zaak through the ACL** instead of straight against
OpenZaak, because the ACL is what writes the record. A zaak created behind the ACL's back
produces no projection row — that's the re-source working, not a gap.

## Three fixes CI found, none of them in the projection logic

1. **`wait-healthy.sh` matched the wrong container** (744f91a). Bring-up timed out with
   `TIMEOUT: 'objecten' not healthy (status=none)` while the `docker ps` it dumps showed
   objecten `Up 9 minutes (healthy)`. `--filter name=` is a substring match, so `objecten` also
   matches `objecten-db`/`objecten-redis`/`objecten-celery`, and `head -1` took whichever docker
   listed first — the celery worker has no healthcheck, hence `status=none`. Latent since those
   services landed and decided purely by listing order; `objecttypen` matches `objecttypen-db`
   the same way. Anchored on the compose replica suffix, which the verify scripts already do.
2. **The ACL had to be repointed at OpenZaak's IP** (7e0897a). Opening the zaak through the ACL
   put this check 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 and then rejects it on zaak-create when
   single-label — the mechanism compose already documents on `ACL_OPENZAAK_BASEURL`.
3. **Approval arrives as `partial_update`, not `update`** (0dd26a7b30fa66) — the one real bug
   in the slice. The ACL upserts with PATCH; DRF routes it through the notifying `update()` but
   names the action `partial_update`, so the projector dropped every approval. Only the e2e could
   catch it: `verify-projection` drives a submit, and per ADR-0028 the e2e is the only check that
   drives a *real* approval.

`verify-tracing` also failed once (run 722) on a path this PR doesn't touch, and passed on a
plain re-run of the same commit. Tempo logged `pusher failed to consume trace data` /
`distributor_pool failing healthcheck` — it dropped spans under runner load rather than the trace
chain being broken. Filed as **#156** rather than absorbed here.

**Correction to the #152 PR notes:** I wrote there that celery concurrency was "the next knob" if
verify-stack got tight. It isn't — `CELERY_WORKER_CONCURRENCY` already defaults to 1 in the Maykin
image, so `objecten-celery` is already a single-process worker. Noted in #156.

**Possible follow-up, deliberately not done here:** an `openzaak.local` network alias mirroring
`objecten.local` would remove the ACL-repoint dance from both run-domain-check.sh and
run-projection-check.sh. It changes the host in every zaak URL the system produces, which is too
broad a ripple to land inside an unrelated slice — worth its own issue.

**Known costs, all in the ADR:** submission is now two writes across two modules and eventually
consistent (same posture ADR-0028 accepted for approval); projecting now depends on the ACL being
reachable on the main path, not just for enrichment (NRC retries, so it converges); and OpenZaak
still publishes to `zaken` with nothing in the product listening — kept because `verify-nrc`
asserts that path.Reviewed-on: #155
This commit was merged in pull request #155.
This commit is contained in:
not
2026-09-01 07:26:33 +00:00
parent 2125fb0cfd
commit 94742a261f
30 changed files with 821 additions and 251 deletions
@@ -1,19 +1,28 @@
# language: en
# Drives S-06 (#7). On a zaak-created notification from NRC the Event Subscriber writes a
# rebuildable read-projection row (PRD §8.4). This scenario exercises the use case against an
# in-memory stand-in for the projection store and notification log; real OpenZaak → NRC →
# subscriber delivery is verified by the live-stack check (verify-projection, ADR-0007/#58).
Feature: Register-projectie bijwerken op een zaaknotificatie
Als openbaar register wil ik dat een aangemaakte zaak in de projectie verschijnt
zodat het register de ingediende registratie kan tonen.
# Drives S-19b-2 (#153), re-sourcing S-06 (#7). The read projection is derived from the
# RegisterRecord in Objecten (ADR-0030), not from ZGW zaak events: the ACL records a registration
# in the register, Objecten notifies, and the Event Subscriber projects the record that
# notification points at. This scenario exercises the use case against in-memory stand-ins for the
# register, the projection store and the notification log; real Objecten → NRC → subscriber
# delivery is verified by the live-stack check (verify-projection, ADR-0007/0030).
Feature: Register-projectie bijwerken op een registerwijziging
Als openbaar register wil ik dat een registratie in de projectie verschijnt zodra zij
in het register is vastgelegd, zodat het register haar actuele status kan tonen.
Scenario: Een zaaknotificatie levert een rij met status INGEDIEND
Given a zaak is created in OpenZaak with id "11111111-1111-1111-1111-111111111111"
When the NRC notification for that zaak is delivered to the event subscriber
Scenario: Een ingediende registratie levert een rij met status INGEDIEND
Given registration "11111111-1111-1111-1111-111111111111" is recorded in the register with status "INGEDIEND"
When the register notification is delivered to the event subscriber
Then the register projection contains a row for "11111111-1111-1111-1111-111111111111" with status "INGEDIEND"
Scenario: Een goedgekeurde registratie werkt dezelfde rij bij
Given registration "33333333-3333-3333-3333-333333333333" is recorded in the register with status "INGEDIEND"
And the register notification is delivered to the event subscriber
When registration "33333333-3333-3333-3333-333333333333" is recorded in the register with status "INGESCHREVEN"
And the register notification is delivered to the event subscriber
Then the register projection contains a row for "33333333-3333-3333-3333-333333333333" with status "INGESCHREVEN"
Scenario: Dezelfde notificatie tweemaal levert geen duplicaat
Given a zaak is created in OpenZaak with id "22222222-2222-2222-2222-222222222222"
When the NRC notification for that zaak is delivered to the event subscriber
And the same NRC notification is delivered again
Given registration "22222222-2222-2222-2222-222222222222" is recorded in the register with status "INGEDIEND"
When the register notification is delivered to the event subscriber
And the same register notification is delivered again
Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222"
@@ -5,31 +5,39 @@ using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06). Reqnroll creates
/// one instance per scenario, so instance fields hold scenario-scoped state.</summary>
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06, re-sourced by S-19b-2).
/// Reqnroll creates one instance per scenario, so instance fields hold scenario-scoped state.</summary>
[Binding]
public sealed class RegisterProjectieBijwerkenSteps
{
private const string ZaakBase = "http://openzaak:8000/zaken/api/v1/zaken/";
private const string ObjectBase = "http://objecten.local:8000/api/v2/objects/";
private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new();
private readonly InMemoryRegisterRecordClient _register = new();
private readonly NotificationProjector _projector;
private Notification? _notification;
public RegisterProjectieBijwerkenSteps()
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient());
=> _projector = new NotificationProjector(_log, _store, _register);
[Given("a zaak is created in OpenZaak with id \"(.*)\"")]
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
=> _notification = new Notification("zaken", "zaak", "create", new Uri(ZaakBase + id));
[Given("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
[When("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
public void RegistrationIsRecorded(string id, string status)
{
// The ACL upserts one object per registration, so submit and approval share an object URL.
var objectUrl = ObjectBase + id;
_register.Records[objectUrl] = new RegisterRecord(id, status, "REG-" + id);
_notification = new Notification("objecten", "object", "create", new Uri(objectUrl));
}
[When("the NRC notification for that zaak is delivered to the event subscriber")]
public Task WhenTheNotificationIsDelivered()
[Given("the register notification is delivered to the event subscriber")]
[When("the register notification is delivered to the event subscriber")]
public Task TheNotificationIsDelivered()
=> _projector.HandleAsync(_notification!);
[When("the same NRC notification is delivered again")]
public Task WhenTheSameNotificationIsDeliveredAgain()
[When("the same register notification is delivered again")]
public Task TheSameNotificationIsDeliveredAgain()
=> _projector.HandleAsync(_notification!);
[Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")]
@@ -39,10 +39,12 @@ public sealed class InMemoryProjectionStore : IProjectionStore
=> [.. _byId.Values.Where(e => e.Id == id)];
}
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary>
public sealed class InMemoryAclReferenceClient : IAclClient
/// <summary>An in-memory stand-in for the register the ACL reads back for the projector, so the
/// scenario runs without a running ACL or Objecten (S-19b-2, ADR-0030).</summary>
public sealed class InMemoryRegisterRecordClient : IAclClient
{
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
public Dictionary<string, RegisterRecord> Records { get; } = [];
public Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
=> Task.FromResult(Records.TryGetValue(objectUrl.ToString(), out var record) ? record : null);
}
@@ -65,4 +65,8 @@ public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway
Upserted.Add(record);
return Task.CompletedTask;
}
/// <summary>The most recently written record — scenarios never read one back by object URL.</summary>
public Task<RegisterRecord?> GetAsync(Uri objectUrl, CancellationToken ct = default)
=> Task.FromResult(Upserted.Count == 0 ? null : Upserted[^1]);
}
+11 -6
View File
@@ -1,11 +1,16 @@
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
// openbaar register as INGEDIEND; the citizen supplies the documents the process is waiting for
// (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in the werkbak,
// and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and flows via the
// ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
// appears in the openbaar register as INGEDIEND; the citizen supplies the documents the process is
// waiting for (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
// flows via the ACL → Objecten → NRC → event-subscriber → projection, and the openbaar register
// shows INGESCHREVEN.
//
// Since ADR-0030 both public statuses come from the register in Objecten, not from ZGW zaak events:
// the ACL writes the record on submit (INGEDIEND) and upserts it on approval (INGESCHREVEN), so the
// INGEDIEND assertion below is itself proof of the re-sourced path.
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
page,
context,