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.
This commit is contained in:
not
2026-08-28 12:32:42 +02:00
parent 142ed454aa
commit 8af09b2c92
@@ -8,7 +8,8 @@ namespace EventSubscriber.Application;
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl) public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
{ {
/// <summary>Handle one inbound notification. Reacts to a register record being written to /// <summary>Handle one inbound notification. Reacts to a register record being written to
/// Objecten (S-19b-2, ADR-0030) and ignores everything else.</summary> /// 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.</summary>
public async Task HandleAsync(Notification notification, CancellationToken ct = default) public async Task HandleAsync(Notification notification, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(notification); ArgumentNullException.ThrowIfNull(notification);
@@ -16,11 +17,36 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
if (!notification.IsRegisterRecordWritten) if (!notification.IsRegisterRecordWritten)
return; return;
// S-19b-2: reading the record back through the ACL and projecting it lands with the var record = await acl.GetRegisterRecordAsync(notification.ObjectUrl, ct);
// implementation; today nothing reaches the store. // The object is gone, or holds no register record — nothing to project (§8.6).
await Task.CompletedTask; 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);
} }
/// <summary>
/// A deterministic dedup key: the object, plus the state that write puts in the projection.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static string KeyFor(Uri objectUrl, RegisterRecord record)
=> $"objecten:object:{objectUrl}:{record.Status}:{record.Reference}";
/// <summary>Rebuild the projection from the durable notification log (PRD §8.4).</summary> /// <summary>Rebuild the projection from the durable notification log (PRD §8.4).</summary>
public async Task RebuildAsync(CancellationToken ct = default) public async Task RebuildAsync(CancellationToken ct = default)
{ {