diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
new file mode 100644
index 0000000..fd0972d
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
@@ -0,0 +1,82 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Interfaces.Services;
+
+namespace ScadaLink.AuditLog.Central;
+
+///
+/// Central-only direct-write implementation of .
+/// Wraps as a best-effort
+/// audit emission path for components that originate audit events ON the central
+/// node (Notification Outbox dispatch, Inbound API) — NOT for site telemetry
+/// ingest (that path is the SiteAudit → AuditLogIngestActor batched flow).
+///
+///
+///
+/// Best-effort contract. Audit-write failures NEVER abort the user-facing
+/// action (alog.md §13). The writer catches every exception thrown by repository
+/// resolution or the insert call, logs at warning, and returns successfully.
+/// Callers may still wrap the call in their own try/catch (defensive — the writer
+/// is supposed to swallow).
+///
+///
+/// Scope-per-call resolution. is a SCOPED
+/// EF Core service (registered by ScadaLink.ConfigurationDatabase). The
+/// writer itself is registered as a singleton (so all callers share one instance),
+/// so it cannot hold a scope across calls — it opens a fresh
+/// per invocation, mirroring
+/// the per-message scope pattern used by AuditLogIngestActor and
+/// NotificationOutboxActor.
+///
+///
+/// Idempotency. Persistence is via InsertIfNotExistsAsync, so a
+/// double-emitted event (same ) is a silent
+/// no-op — the writer is safe to call from any number of dispatch paths.
+///
+///
+public sealed class CentralAuditWriter : ICentralAuditWriter
+{
+ private readonly IServiceProvider _services;
+ private readonly ILogger _logger;
+
+ public CentralAuditWriter(IServiceProvider services, ILogger logger)
+ {
+ _services = services ?? throw new ArgumentNullException(nameof(services));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Persists into the central AuditLog table
+ /// idempotently on . Stamps
+ /// from the central-side clock.
+ /// Internal failures are logged and swallowed — never thrown.
+ ///
+ public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ if (evt is null)
+ {
+ // Defensive — a null event is a programming bug at the caller and
+ // produces no meaningful audit row. Log and return.
+ _logger.LogWarning("CentralAuditWriter.WriteAsync received null event; ignoring.");
+ return;
+ }
+
+ try
+ {
+ await using var scope = _services.CreateAsyncScope();
+ var repo = scope.ServiceProvider.GetRequiredService();
+ var stamped = evt with { IngestedAtUtc = DateTime.UtcNow };
+ await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Audit failure NEVER aborts the user-facing action — swallow and log.
+ _logger.LogWarning(
+ ex,
+ "CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
+ evt.EventId, evt.Kind, evt.Status);
+ }
+ }
+}
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
index e420f4f..346ea0f 100644
--- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
@@ -129,6 +130,17 @@ public static class ServiceCollectionExtensions
services.AddSingleton(
sp => sp.GetRequiredService());
+ // M4 Bundle B: central direct-write audit writer used by
+ // NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to
+ // emit AuditLog rows that originate ON central, not via site telemetry.
+ // Singleton — the writer is stateless; its per-call scope opens a fresh
+ // IAuditLogRepository (a SCOPED EF Core service registered by
+ // ScadaLink.ConfigurationDatabase). The interface (ICentralAuditWriter)
+ // is intentionally distinct from IAuditWriter so site composition roots
+ // do not accidentally bind it; central composition roots that include
+ // AddConfigurationDatabase get a working implementation transparently.
+ services.AddSingleton();
+
return services;
}
diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs
index fe4fe15..b8c5171 100644
--- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs
+++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs
@@ -275,11 +275,18 @@ akka {{
.GetRequiredService>().Value;
var outboxLogger = _serviceProvider.GetRequiredService()
.CreateLogger();
+ // M4 Bundle B: central direct-write audit writer for dispatcher attempt
+ // + terminal events. Resolved once from the root provider — the writer
+ // is a singleton and stateless, opening per-call DI scopes internally
+ // to resolve the scoped IAuditLogRepository.
+ var outboxAuditWriter = _serviceProvider
+ .GetRequiredService();
var outboxSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor(
_serviceProvider,
outboxOptions,
+ outboxAuditWriter,
outboxLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs
index bc17053..9c0d02d 100644
--- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs
+++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs
@@ -1,8 +1,10 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Notifications;
@@ -30,6 +32,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private readonly IServiceProvider _serviceProvider;
private readonly NotificationOutboxOptions _options;
+ private readonly ICentralAuditWriter _auditWriter;
private readonly ILogger _logger;
///
@@ -45,11 +48,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
public NotificationOutboxActor(
IServiceProvider serviceProvider,
NotificationOutboxOptions options,
+ ICentralAuditWriter auditWriter,
ILogger logger)
{
- _serviceProvider = serviceProvider;
- _options = options;
- _logger = logger;
+ _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
Receive(HandleSubmit);
Receive(HandleIngestPersisted);
diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
index 03d337a..61d0031 100644
--- a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
@@ -155,6 +156,34 @@ public class AddAuditLogTests
Assert.IsType(client);
}
+ // -- M4 Bundle B (B1) central direct-write audit writer -----------------
+
+ [Fact]
+ public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var writer = provider.GetService();
+ Assert.NotNull(writer);
+ Assert.IsType(writer);
+ }
+
+ [Fact]
+ public void AddAuditLog_ICentralAuditWriter_IsSingleton()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var w1 = provider.GetService();
+ var w2 = provider.GetService();
+ Assert.Same(w1, w2);
+ }
+
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
{
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs
new file mode 100644
index 0000000..1a9ca3b
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs
@@ -0,0 +1,127 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using ScadaLink.AuditLog.Central;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.AuditLog.Tests.Central;
+
+///
+/// M4 Bundle B1 — unit tests for , the
+/// central-only direct-write implementation of .
+/// The writer is a thin wrapper around
+/// : it stamps
+/// , resolves the (scoped) repository
+/// from a fresh DI scope per call, and swallows any thrown exception —
+/// audit-write failures NEVER abort the user-facing action (alog.md §13).
+///
+public class CentralAuditWriterTests
+{
+ private static AuditEvent NewEvent(Guid? eventId = null) => new()
+ {
+ EventId = eventId ?? Guid.NewGuid(),
+ OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ Channel = AuditChannel.Notification,
+ Kind = AuditKind.NotifyDeliver,
+ Status = AuditStatus.Attempted,
+ CorrelationId = Guid.NewGuid(),
+ Target = "ops-team",
+ };
+
+ private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
+ {
+ var repo = Substitute.For();
+ var services = new ServiceCollection();
+ services.AddScoped(_ => repo);
+ var provider = services.BuildServiceProvider();
+ return (new CentralAuditWriter(provider, NullLogger.Instance), repo);
+ }
+
+ [Fact]
+ public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
+ {
+ var (writer, repo) = BuildWriter();
+ var evt = NewEvent();
+
+ await writer.WriteAsync(evt);
+
+ await repo.Received(1).InsertIfNotExistsAsync(
+ Arg.Is(e => e.EventId == evt.EventId),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
+ {
+ var (writer, repo) = BuildWriter();
+ var before = DateTime.UtcNow;
+
+ await writer.WriteAsync(NewEvent());
+
+ var after = DateTime.UtcNow;
+ await repo.Received(1).InsertIfNotExistsAsync(
+ Arg.Is(e =>
+ e.IngestedAtUtc != null &&
+ e.IngestedAtUtc >= before &&
+ e.IngestedAtUtc <= after),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
+ {
+ var repo = Substitute.For();
+ repo.InsertIfNotExistsAsync(Arg.Any(), Arg.Any())
+ .ThrowsAsync(new InvalidOperationException("db down"));
+ var services = new ServiceCollection();
+ services.AddScoped(_ => repo);
+ var provider = services.BuildServiceProvider();
+ var writer = new CentralAuditWriter(provider, NullLogger.Instance);
+
+ // Must not throw — audit failure NEVER aborts the user-facing action.
+ await writer.WriteAsync(NewEvent());
+ }
+
+ [Fact]
+ public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
+ {
+ // Counting factory: every scope opening should resolve a new repo
+ // (scoped lifetime). We assert at least two distinct instances
+ // across two WriteAsync calls.
+ var instances = new List();
+ var services = new ServiceCollection();
+ services.AddScoped(_ =>
+ {
+ var r = Substitute.For();
+ instances.Add(r);
+ return r;
+ });
+ var provider = services.BuildServiceProvider();
+ var writer = new CentralAuditWriter(provider, NullLogger.Instance);
+
+ await writer.WriteAsync(NewEvent());
+ await writer.WriteAsync(NewEvent());
+
+ Assert.Equal(2, instances.Count);
+ Assert.NotSame(instances[0], instances[1]);
+ }
+
+ [Fact]
+ public void Constructor_NullServices_Throws()
+ {
+ Assert.Throws(
+ () => new CentralAuditWriter(null!, NullLogger.Instance));
+ }
+
+ [Fact]
+ public void Constructor_NullLogger_Throws()
+ {
+ var services = new ServiceCollection().BuildServiceProvider();
+ Assert.Throws(
+ () => new CentralAuditWriter(services, null!));
+ }
+}
diff --git a/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs b/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs
index a4ed61b..b639a9a 100644
--- a/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs
+++ b/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs
@@ -8,8 +8,10 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
+using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
@@ -172,7 +174,20 @@ public class NotificationOutboxFlowTests : TestKit
};
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
- services, options, NullLogger.Instance)));
+ services,
+ options,
+ (ICentralAuditWriter)new NoOpCentralAuditWriter(),
+ NullLogger.Instance)));
+ }
+
+ ///
+ /// Test-only no-op . The integration tests
+ /// in this file pre-date M4 Bundle B's audit-writer injection; they do not
+ /// assert on emission, just need a non-null collaborator.
+ ///
+ private sealed class NoOpCentralAuditWriter : ICentralAuditWriter
+ {
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
}
private static NotificationSubmit MakeSubmit(string notificationId)
diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs
new file mode 100644
index 0000000..0b6a302
--- /dev/null
+++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs
@@ -0,0 +1,79 @@
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Entities.Notifications;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Interfaces.Services;
+
+namespace ScadaLink.NotificationOutbox.Tests;
+
+///
+/// M4 Bundle B (B1) — verifies accepts an
+/// at construction so subsequent bundle tasks
+/// (B2/B3) can route attempt + terminal lifecycle events through the central
+/// direct-write audit path.
+///
+public class NotificationOutboxActorAuditInjectionTests : TestKit
+{
+ private static IServiceProvider BuildEmptyProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddScoped(_ => Substitute.For());
+ services.AddScoped(_ => Substitute.For());
+ return services.BuildServiceProvider();
+ }
+
+ ///
+ /// Inline NoOp writer that records calls — used to assert later tasks emit
+ /// events without depending on a concrete CentralAuditWriter.
+ ///
+ private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
+ {
+ public List Events { get; } = new();
+
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ lock (Events)
+ {
+ Events.Add(evt);
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ [Fact]
+ public void Actor_ConstructedWith_ICentralAuditWriter_NoException()
+ {
+ var writer = new RecordingCentralAuditWriter();
+ // Long dispatch interval so PreStart's timer never fires during the test.
+ var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
+
+ var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
+ BuildEmptyProvider(),
+ options,
+ writer,
+ NullLogger.Instance)));
+
+ Assert.NotNull(actor);
+ // No event has been emitted yet — the writer is purely injected at this stage.
+ lock (writer.Events)
+ {
+ Assert.Empty(writer.Events);
+ }
+ }
+
+ [Fact]
+ public void Actor_NullAuditWriter_Throws()
+ {
+ var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
+
+ Assert.Throws(() => new NotificationOutboxActor(
+ BuildEmptyProvider(),
+ options,
+ auditWriter: null!,
+ NullLogger.Instance));
+ }
+}
diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs
index fafdf3d..ea91625 100644
--- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs
+++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.NotificationOutbox.Delivery;
using ScadaLink.NotificationOutbox.Messages;
+using ScadaLink.NotificationOutbox.Tests.TestSupport;
namespace ScadaLink.NotificationOutbox.Tests;
@@ -81,6 +82,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(adapters),
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
+ new NoOpCentralAuditWriter(),
NullLogger.Instance)));
}
diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs
index 4c62943..123711d 100644
--- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs
+++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs
@@ -9,6 +9,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.NotificationOutbox.Delivery;
+using ScadaLink.NotificationOutbox.Tests.TestSupport;
namespace ScadaLink.NotificationOutbox.Tests;
@@ -34,6 +35,7 @@ public class NotificationOutboxActorIngestTests : TestKit
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(),
new NotificationOutboxOptions(),
+ new NoOpCentralAuditWriter(),
NullLogger.Instance)));
}
diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs
index bcd749e..b291f76 100644
--- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs
+++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.NotificationOutbox.Delivery;
using ScadaLink.NotificationOutbox.Messages;
+using ScadaLink.NotificationOutbox.Tests.TestSupport;
namespace ScadaLink.NotificationOutbox.Tests;
@@ -46,6 +47,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
},
+ new NoOpCentralAuditWriter(),
NullLogger.Instance)));
}
diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs
index 3f2a5d6..5bc9052 100644
--- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs
+++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs
@@ -10,6 +10,7 @@ using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Notifications;
using ScadaLink.NotificationOutbox.Delivery;
+using ScadaLink.NotificationOutbox.Tests.TestSupport;
namespace ScadaLink.NotificationOutbox.Tests;
@@ -36,6 +37,7 @@ public class NotificationOutboxActorQueryTests : TestKit
BuildServiceProvider(),
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
+ new NoOpCentralAuditWriter(),
NullLogger.Instance)));
}
diff --git a/tests/ScadaLink.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs b/tests/ScadaLink.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs
new file mode 100644
index 0000000..ae82341
--- /dev/null
+++ b/tests/ScadaLink.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs
@@ -0,0 +1,15 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Services;
+
+namespace ScadaLink.NotificationOutbox.Tests.TestSupport;
+
+///
+/// Test-only no-op . Used by existing
+/// NotificationOutboxActor TestKit fixtures whose tests pre-date the M4 Bundle B
+/// audit-writer injection — they don't care about audit emission, they just
+/// need a non-null collaborator so the actor's constructor succeeds.
+///
+internal sealed class NoOpCentralAuditWriter : ICentralAuditWriter
+{
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
+}