diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
new file mode 100644
index 0000000..6cb271d
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
@@ -0,0 +1,28 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Fans an event out to several writers. Best-effort: a failing writer does not stop the others.
+/// A failing writer's exception is swallowed so the fan-out drains and the caller is never
+/// aborted — but is re-thrown so cancellation is honored.
+public sealed class CompositeAuditWriter : IAuditWriter
+{
+ private readonly IReadOnlyList _inner;
+
+ /// Creates a composite over the given writers.
+ public CompositeAuditWriter(IEnumerable inner)
+ {
+ ArgumentNullException.ThrowIfNull(inner);
+ _inner = inner.ToArray();
+ }
+
+ ///
+ public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ foreach (var writer in _inner)
+ {
+ try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
+ catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs
new file mode 100644
index 0000000..b737d9f
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Writer that discards events. Default when audit is disabled, and useful in tests.
+public sealed class NoOpAuditWriter : IAuditWriter
+{
+ /// Shared singleton instance.
+ public static readonly NoOpAuditWriter Instance = new();
+ private NoOpAuditWriter() { }
+
+ ///
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs
new file mode 100644
index 0000000..d688b3a
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Identity redactor — returns the event unchanged. The default when no policy is configured.
+public sealed class NullAuditRedactor : IAuditRedactor
+{
+ /// Shared singleton instance.
+ public static readonly NullAuditRedactor Instance = new();
+ private NullAuditRedactor() { }
+
+ ///
+ public AuditEvent Apply(AuditEvent rawEvent) => rawEvent;
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs
new file mode 100644
index 0000000..4ff4794
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs
@@ -0,0 +1,24 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Decorator: applies an , then delegates to an inner .
+public sealed class RedactingAuditWriter : IAuditWriter
+{
+ private readonly IAuditRedactor _redactor;
+ private readonly IAuditWriter _inner;
+
+ /// Creates the decorator around using .
+ public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner)
+ {
+ ArgumentNullException.ThrowIfNull(redactor);
+ ArgumentNullException.ThrowIfNull(inner);
+ _redactor = redactor;
+ _inner = inner;
+ }
+
+ ///
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ return _inner.WriteAsync(_redactor.Apply(evt), ct);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
new file mode 100644
index 0000000..4ca6cbd
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
@@ -0,0 +1,41 @@
+namespace ZB.MOM.WW.Audit;
+
+///
+/// Redactor that caps oversized and .
+/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
+/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
+///
+public sealed class TruncatingAuditRedactor : IAuditRedactor
+{
+ private readonly TruncatingAuditRedactorOptions _options;
+
+ /// Creates the redactor with the given options (defaults when null).
+ public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null)
+ => _options = options ?? new TruncatingAuditRedactorOptions();
+
+ ///
+ public AuditEvent Apply(AuditEvent rawEvent)
+ {
+ try
+ {
+ return rawEvent with
+ {
+ Target = Truncate(rawEvent.Target, _options.MaxTargetLength),
+ DetailsJson = Truncate(rawEvent.DetailsJson, _options.MaxDetailsJsonLength),
+ };
+ }
+ catch
+ {
+ // Hard contract: never throw. Over-redact on internal failure.
+ return rawEvent with { DetailsJson = null };
+ }
+ }
+
+ private string? Truncate(string? value, int max)
+ {
+ if (value is null || value.Length <= max) return value;
+ var marker = _options.TruncationMarker;
+ if (marker.Length >= max) return marker[..max];
+ return string.Concat(value.AsSpan(0, max - marker.Length), marker);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
new file mode 100644
index 0000000..0c44aba
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Caps for .
+public sealed class TruncatingAuditRedactorOptions
+{
+ /// Max length of before truncation. Default 4096.
+ public int MaxDetailsJsonLength { get; set; } = 4096;
+ /// Max length of before truncation. Default 512.
+ public int MaxTargetLength { get; set; } = 512;
+ /// Marker appended to a truncated value. Default "…[truncated]".
+ public string TruncationMarker { get; set; } = "…[truncated]";
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
new file mode 100644
index 0000000..1faef9c
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
@@ -0,0 +1,48 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class CompositeAuditWriterTests
+{
+ private sealed class RecordingWriter : IAuditWriter
+ {
+ public int Count;
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; }
+ }
+ private sealed class ThrowingWriter : IAuditWriter
+ {
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom");
+ }
+ private sealed class CancellingWriter : IAuditWriter
+ {
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException();
+ }
+
+ private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
+
+ [Fact]
+ public async Task Fans_out_to_all_writers()
+ {
+ var a = new RecordingWriter(); var b = new RecordingWriter();
+ await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt());
+ Assert.Equal(1, a.Count);
+ Assert.Equal(1, b.Count);
+ }
+
+ [Fact]
+ public async Task One_failing_writer_does_not_stop_the_others()
+ {
+ var after = new RecordingWriter();
+ var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after });
+ await sut.WriteAsync(Evt()); // must not throw
+ Assert.Equal(1, after.Count);
+ }
+
+ [Fact]
+ public async Task Cancellation_is_propagated_not_swallowed()
+ {
+ // OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
+ var after = new RecordingWriter();
+ var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
+ await Assert.ThrowsAsync(() => sut.WriteAsync(Evt()));
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs
new file mode 100644
index 0000000..f44fea1
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class NoOpAuditWriterTests
+{
+ [Fact]
+ public async Task WriteAsync_completes_without_error()
+ {
+ var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
+ await NoOpAuditWriter.Instance.WriteAsync(evt);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs
new file mode 100644
index 0000000..4bca581
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class NullAuditRedactorTests
+{
+ [Fact]
+ public void Apply_returns_input_unchanged()
+ {
+ var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" };
+ Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt));
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs
new file mode 100644
index 0000000..34dc53d
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class RedactingAuditWriterTests
+{
+ private sealed class CapturingWriter : IAuditWriter
+ {
+ public AuditEvent? Last;
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; }
+ }
+ private sealed class StampRedactor : IAuditRedactor
+ {
+ public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" };
+ }
+
+ private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" };
+
+ [Fact]
+ public async Task Inner_writer_receives_the_redacted_event()
+ {
+ var inner = new CapturingWriter();
+ var sut = new RedactingAuditWriter(new StampRedactor(), inner);
+ await sut.WriteAsync(Evt());
+ Assert.Equal("redacted", inner.Last!.DetailsJson);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
new file mode 100644
index 0000000..02ba387
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
@@ -0,0 +1,56 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class TruncatingAuditRedactorTests
+{
+ private static AuditEvent Evt(string? details, string? target = null) => new()
+ {
+ EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success,
+ DetailsJson = details, Target = target,
+ };
+
+ [Fact]
+ public void Short_values_pass_through_unchanged()
+ {
+ var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 });
+ var evt = Evt("small");
+ Assert.Equal("small", r.Apply(evt).DetailsJson);
+ }
+
+ [Fact]
+ public void Oversized_details_are_truncated_with_marker()
+ {
+ var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" };
+ var r = new TruncatingAuditRedactor(opts);
+ var result = r.Apply(Evt(new string('x', 50)));
+ Assert.Equal(10, result.DetailsJson!.Length);
+ Assert.EndsWith("~", result.DetailsJson);
+ }
+
+ [Fact]
+ public void Oversized_target_is_truncated()
+ {
+ var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" });
+ var result = r.Apply(Evt(null, target: "abcdefghij"));
+ Assert.Equal(5, result.Target!.Length);
+ }
+
+ [Fact]
+ public void Null_fields_are_left_null()
+ {
+ var r = new TruncatingAuditRedactor();
+ var result = r.Apply(Evt(null));
+ Assert.Null(result.DetailsJson);
+ Assert.Null(result.Target);
+ }
+
+ [Fact]
+ public void Marker_longer_than_max_clips_the_marker_itself()
+ {
+ // Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars.
+ var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" };
+ var r = new TruncatingAuditRedactor(opts);
+ var result = r.Apply(Evt(new string('x', 20)));
+ Assert.Equal(3, result.DetailsJson!.Length);
+ }
+}