diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs
new file mode 100644
index 0000000..102b6d9
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Central/CentralAuditRedactionFailureCounter.cs
@@ -0,0 +1,57 @@
+using ScadaLink.AuditLog.Payload;
+
+namespace ScadaLink.AuditLog.Central;
+
+///
+/// Audit Log (#23) M6 Bundle E (T9) — bridges
+/// (incremented by
+/// every time a header / body / SQL
+/// parameter redactor stage throws and the filter has to over-redact the
+/// offending field) into so the
+/// failure surfaces on the central health surface as
+/// AuditCentralHealthSnapshot.AuditRedactionFailure.
+///
+///
+///
+/// Site vs central. M5 Bundle C wired the SITE-side bridge
+/// (),
+/// which routes increments into the site health report payload's
+/// AuditRedactionFailure field. That handles redactor failures on the
+/// site SQLite hot-path (FallbackAuditWriter). M6 Bundle E (T9) adds the
+/// MIRROR bridge here so the same payload filter — when it runs on the
+/// central /
+/// paths — surfaces its failures on the
+/// central dashboard rather than disappearing into a NoOp.
+///
+///
+/// Registration shape. Site composition roots call
+/// ,
+/// which overrides the binding with the site bridge. Central composition
+/// roots call ,
+/// which overrides with this central bridge. A node never wears both hats —
+/// site and central are distinct host roles — so the two bridges never
+/// fight over the same binding at runtime.
+///
+///
+/// Why not a thin wrapper around the snapshot directly? The snapshot
+/// itself could be the bound implementation (it already implements
+/// ), but a dedicated class makes
+/// the central-vs-site asymmetry explicit at the DI boundary — readers of
+///
+/// see "site → site bridge, central → central bridge", matching the
+///
+/// shape one-for-one.
+///
+///
+public sealed class CentralAuditRedactionFailureCounter : IAuditRedactionFailureCounter
+{
+ private readonly AuditCentralHealthSnapshot _snapshot;
+
+ public CentralAuditRedactionFailureCounter(AuditCentralHealthSnapshot snapshot)
+ {
+ _snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
+ }
+
+ ///
+ public void Increment() => ((IAuditRedactionFailureCounter)_snapshot).Increment();
+}
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
index 2fe18ea..6460b1a 100644
--- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -295,14 +295,18 @@ public static class ServiceCollectionExtensions
services.Replace(ServiceDescriptor.Singleton(
sp => sp.GetRequiredService()));
// M6 Bundle E (T9): override the NoOp IAuditRedactionFailureCounter
- // (registered by AddAuditLog) with the central snapshot binding so
- // payload-filter throws on CentralAuditWriter / AuditLogIngestActor
- // paths surface on the central dashboard. The site composition root
+ // (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter
+ // bridge so payload-filter throws on CentralAuditWriter /
+ // AuditLogIngestActor paths surface on the central dashboard. The
+ // bridge is a thin wrapper around the AuditCentralHealthSnapshot
+ // singleton so all central redactor failures route into the same
+ // counter as CentralAuditWriteFailures. The site composition root
// overrides this binding AGAIN via AddAuditLogHealthMetricsBridge —
// central nodes do not call that bridge, so this is the final
- // binding on a central host.
- services.Replace(ServiceDescriptor.Singleton(
- sp => sp.GetRequiredService()));
+ // binding on a central host. Mirrors the M5 Bundle C
+ // HealthMetricsAuditRedactionFailureCounter shape one-for-one.
+ services.Replace(ServiceDescriptor.Singleton());
return services;
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs
new file mode 100644
index 0000000..7fbfebf
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs
@@ -0,0 +1,99 @@
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ScadaLink.AuditLog;
+using ScadaLink.AuditLog.Central;
+using ScadaLink.AuditLog.Payload;
+
+namespace ScadaLink.AuditLog.Tests.Central;
+
+///
+/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor
+/// failure bridge. M5 wired the SITE bridge
+/// (HealthMetricsAuditRedactionFailureCounter) that pushes increments
+/// into the site health report; M6 mirrors that with
+/// so the same payload
+/// filter — when it runs on the central writer paths — surfaces failures on
+/// the central .
+///
+public class CentralAuditRedactionFailureCounterTests : TestKit
+{
+ [Fact]
+ public void Increment_Routes_To_Snapshot()
+ {
+ using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
+ var snapshot = new AuditCentralHealthSnapshot(tracker);
+ var counter = new CentralAuditRedactionFailureCounter(snapshot);
+
+ counter.Increment();
+ counter.Increment();
+ counter.Increment();
+
+ Assert.Equal(3, snapshot.AuditRedactionFailure);
+ }
+
+ [Fact]
+ public void Construction_With_Null_Snapshot_Throws()
+ {
+ Assert.Throws(
+ () => new CentralAuditRedactionFailureCounter(null!));
+ }
+
+ [Fact]
+ public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
+ {
+ // AddAuditLog registers NoOp; AddAuditLogCentralMaintenance is the
+ // override path. The replaced binding MUST resolve to the central
+ // bridge — a site host that wires AddAuditLogHealthMetricsBridge
+ // instead would resolve to the site bridge (covered in
+ // AddAuditLogTests).
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ })
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
+ // The AuditCentralHealthSnapshot ctor takes the stalled tracker
+ // which itself needs an ActorSystem — register a real system
+ // (test-kit's Sys) so the DI graph composes.
+ services.AddSingleton(Sys);
+ services.AddAuditLog(config);
+ services.AddAuditLogCentralMaintenance(config);
+ using var provider = services.BuildServiceProvider();
+
+ var counter = provider.GetRequiredService();
+
+ Assert.IsType(counter);
+ }
+
+ [Fact]
+ public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
+ {
+ // Sanity check: without AddAuditLogCentralMaintenance the default
+ // remains the NoOp from M5 — the central bridge only takes effect
+ // when the central-only registration runs.
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ })
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
+ services.AddAuditLog(config);
+ using var provider = services.BuildServiceProvider();
+
+ var counter = provider.GetRequiredService();
+
+ Assert.IsType(counter);
+ }
+}