From 8af09b2c92256b1f3f0f65a45d5d7650414a58a8 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 12:32:42 +0200 Subject: [PATCH] feat(event-subscriber): project register records read back through the ACL (refs #153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../NotificationProjector.cs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs index 90098c1..73aa6f2 100644 --- a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs +++ b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs @@ -8,7 +8,8 @@ namespace EventSubscriber.Application; public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl) { /// Handle one inbound notification. Reacts to a register record being written to - /// Objecten (S-19b-2, ADR-0030) and ignores everything else. + /// Objecten (S-19b-2, ADR-0030) and ignores everything else. The notification carries only the + /// object URL, so the record is read back through the ACL (§8.1) and becomes the row verbatim. public async Task HandleAsync(Notification notification, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(notification); @@ -16,11 +17,36 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore if (!notification.IsRegisterRecordWritten) return; - // S-19b-2: reading the record back through the ACL and projecting it lands with the - // implementation; today nothing reaches the store. - await Task.CompletedTask; + var record = await acl.GetRegisterRecordAsync(notification.ObjectUrl, ct); + // The object is gone, or holds no register record — nothing to project (§8.6). + if (record is null) + return; + + var recorded = new RecordedNotification( + KeyFor(notification.ObjectUrl, record), record.Id, record.Status, record.Reference); + + // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped + // before it touches the projection, so the projection stays a faithful derived artefact. + if (!await log.TryRecordAsync(recorded, ct)) + return; + + await store.UpsertAsync(ToEntry(recorded), ct); } + /// + /// A deterministic dedup key: the object, plus the state that write puts in the projection. + /// + /// + /// Open Notificaties carries no notification id and may redeliver, so the key is derived from + /// content. It cannot be the object URL alone — the ACL upserts one object per registration, so + /// submit and approval both notify about the *same* URL and the approval would be swallowed as a + /// duplicate. Nor can it include the actie: a retried approval would be a second `update`. Keying + /// on the projected row means a redelivery collapses and a genuine state change does not, which + /// is exactly the property §8.6 asks for. + /// + private static string KeyFor(Uri objectUrl, RegisterRecord record) + => $"objecten:object:{objectUrl}:{record.Status}:{record.Reference}"; + /// Rebuild the projection from the durable notification log (PRD §8.4). public async Task RebuildAsync(CancellationToken ct = default) {