diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
new file mode 100644
index 0000000..8682a9f
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
@@ -0,0 +1,124 @@
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Configuration;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.AuditLog.Payload;
+
+///
+/// Default . M5 Bundle A scope: payload
+/// truncation only (RequestSummary / ResponseSummary / ErrorDetail / Extra),
+/// capped at on success rows and
+/// on error rows. Bundle B layers
+/// header / body / SQL-parameter redaction on top.
+///
+///
+///
+/// Uses (not )
+/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
+/// singleton.
+///
+///
+/// "Error row" = NOT IN (Delivered,
+/// Submitted, Forwarded) — every other status, including the
+/// non-terminal Attempted, the parked/discarded terminals, and the
+/// short-circuit Skipped, receives the larger error cap so a verbose
+/// error body survives.
+///
+///
+/// Apply MUST NOT throw — on internal failure the filter over-redacts by
+/// returning the input with set and
+/// (Bundle C) increments the AuditRedactionFailure health metric.
+///
+///
+public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
+{
+ private readonly IOptionsMonitor _options;
+ private readonly ILogger _logger;
+
+ public DefaultAuditPayloadFilter(
+ IOptionsMonitor options,
+ ILogger logger)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public AuditEvent Apply(AuditEvent rawEvent)
+ {
+ try
+ {
+ var opts = _options.CurrentValue;
+ var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
+ var truncated = false;
+ var request = TruncateField(rawEvent.RequestSummary, cap, ref truncated);
+ var response = TruncateField(rawEvent.ResponseSummary, cap, ref truncated);
+ var errorDetail = TruncateField(rawEvent.ErrorDetail, cap, ref truncated);
+ var extra = TruncateField(rawEvent.Extra, cap, ref truncated);
+ return rawEvent with
+ {
+ RequestSummary = request,
+ ResponseSummary = response,
+ ErrorDetail = errorDetail,
+ Extra = extra,
+ PayloadTruncated = rawEvent.PayloadTruncated || truncated,
+ };
+ }
+ catch (Exception ex)
+ {
+ // Audit is best-effort: over-redact rather than fail the caller.
+ // Bundle C wires the AuditRedactionFailure health metric here.
+ _logger.LogWarning(
+ ex,
+ "Payload filter failed; returning raw event with PayloadTruncated=true");
+ return rawEvent with { PayloadTruncated = true };
+ }
+ }
+
+ private static string? TruncateField(string? value, int cap, ref bool truncated)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+ var result = TruncateUtf8(value, cap);
+ if (result.Length != value.Length)
+ {
+ truncated = true;
+ }
+ return result;
+ }
+
+ ///
+ /// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
+ /// the cap position until the byte is NOT a continuation byte
+ /// (byte & 0xC0 == 0x80), and decodes the resulting prefix —
+ /// guaranteeing the returned string never splits a multi-byte sequence.
+ ///
+ private static string TruncateUtf8(string value, int capBytes)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+ var bytes = Encoding.UTF8.GetBytes(value);
+ if (bytes.Length <= capBytes)
+ {
+ return value;
+ }
+ var boundary = capBytes;
+ while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
+ {
+ boundary--;
+ }
+ return Encoding.UTF8.GetString(bytes, 0, boundary);
+ }
+
+ private static bool IsErrorStatus(AuditStatus status) => status switch
+ {
+ AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
+ _ => true,
+ };
+}
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
index 346ea0f..d42f299 100644
--- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
+using ScadaLink.AuditLog.Payload;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
@@ -59,6 +60,15 @@ public static class ServiceCollectionExtensions
.ValidateOnStart();
services.AddSingleton, AuditLogOptionsValidator>();
+ // M5 Bundle A: payload filter — truncates oversized RequestSummary /
+ // ResponseSummary / ErrorDetail / Extra fields between event
+ // construction and persistence. Bundle B layers header / body /
+ // SQL-parameter redaction onto the same singleton; Bundle C wires it
+ // into the FallbackAuditWriter / CentralAuditWriter / IngestActor
+ // paths. Singleton — the filter is stateless and the IOptionsMonitor
+ // dependency picks up M5-T8 hot reloads on its own.
+ services.AddSingleton();
+
// M2 Bundle E: site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the
diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs
new file mode 100644
index 0000000..d747336
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs
@@ -0,0 +1,226 @@
+using System.Linq;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Configuration;
+using ScadaLink.AuditLog.Payload;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.AuditLog.Tests.Payload;
+
+///
+/// Bundle A (M5-T2) tests for truncation.
+/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
+/// (8 KiB) on success rows and
+/// (64 KiB) on error rows. "Error
+/// row" = NOT IN (Delivered,
+/// Submitted, Forwarded). Truncation must respect UTF-8 character
+/// boundaries (never split a multi-byte sequence mid-character) and must set
+/// true when any field is shortened.
+///
+public class TruncationTests
+{
+ private static IOptionsMonitor Monitor(AuditLogOptions? opts = null)
+ {
+ var snapshot = opts ?? new AuditLogOptions();
+ return new StaticMonitor(snapshot);
+ }
+
+ private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
+ new(Monitor(opts), NullLogger.Instance);
+
+ private static AuditEvent NewEvent(
+ AuditStatus status = AuditStatus.Delivered,
+ string? request = null,
+ string? response = null,
+ string? errorDetail = null,
+ string? extra = null,
+ bool payloadTruncated = false) => new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ Status = status,
+ RequestSummary = request,
+ ResponseSummary = response,
+ ErrorDetail = errorDetail,
+ Extra = extra,
+ PayloadTruncated = payloadTruncated,
+ };
+
+ [Fact]
+ public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
+ {
+ var input = new string('a', 10 * 1024);
+ var evt = NewEvent(AuditStatus.Delivered, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.NotNull(result.RequestSummary);
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
+ Assert.True(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ErrorRow_10KB_RequestSummary_NotTruncated_UnderErrorCap()
+ {
+ var input = new string('b', 10 * 1024);
+ var evt = NewEvent(AuditStatus.Failed, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal(input, result.RequestSummary);
+ Assert.False(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ErrorRow_70KB_RequestSummary_TruncatedTo64KB_PayloadTruncatedTrue()
+ {
+ var input = new string('c', 70 * 1024);
+ var evt = NewEvent(AuditStatus.Failed, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.NotNull(result.RequestSummary);
+ Assert.Equal(65536, Encoding.UTF8.GetByteCount(result.RequestSummary!));
+ Assert.True(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
+ {
+ // U+1F600 (grinning face) encodes to 4 UTF-8 bytes; 2000 of them = 8000 bytes,
+ // safely under the 8192 default cap so the boundary scan kicks in mid-character
+ // when we push past it. Pad with a few extra emoji so the *input* is > 8192 bytes
+ // and forces truncation.
+ var emoji = "😀"; // surrogate pair => one code point => 4 UTF-8 bytes
+ var sb = new StringBuilder();
+ for (int i = 0; i < 2100; i++)
+ {
+ sb.Append(emoji);
+ }
+ var input = sb.ToString();
+ Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
+
+ var evt = NewEvent(AuditStatus.Delivered, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.NotNull(result.RequestSummary);
+ var resultBytes = Encoding.UTF8.GetByteCount(result.RequestSummary!);
+ Assert.True(resultBytes <= 8192, $"expected <= 8192 bytes, got {resultBytes}");
+ // 4-byte emoji boundary: the kept byte length must be a multiple of 4.
+ Assert.Equal(0, resultBytes % 4);
+ // And round-tripping the result must not introduce a U+FFFD replacement char.
+ Assert.DoesNotContain('�', result.RequestSummary);
+ Assert.True(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void NullSummary_PassesThrough_AsNull()
+ {
+ var evt = NewEvent(AuditStatus.Delivered, request: null, response: null, errorDetail: null, extra: null);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Null(result.RequestSummary);
+ Assert.Null(result.ResponseSummary);
+ Assert.Null(result.ErrorDetail);
+ Assert.Null(result.Extra);
+ Assert.False(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void RawEventAlreadyTruncated_PayloadTruncatedRemainsTrue()
+ {
+ // Small payload that requires no truncation, but the caller already
+ // flagged PayloadTruncated upstream — the filter must not clear it.
+ var evt = NewEvent(AuditStatus.Delivered, request: "small", payloadTruncated: true);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal("small", result.RequestSummary);
+ Assert.True(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void StatusAttempted_TreatedAsError_UsesErrorCap()
+ {
+ // 10 KB is under the 64 KB error cap; if Attempted were a success status
+ // the value would be truncated to 8 KB. We assert it is NOT truncated.
+ var input = new string('d', 10 * 1024);
+ var evt = NewEvent(AuditStatus.Attempted, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal(input, result.RequestSummary);
+ Assert.False(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void StatusParked_TreatedAsError_UsesErrorCap()
+ {
+ var input = new string('e', 10 * 1024);
+ var evt = NewEvent(AuditStatus.Parked, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal(input, result.RequestSummary);
+ Assert.False(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void StatusSkipped_TreatedAsError_UsesErrorCap()
+ {
+ var input = new string('f', 10 * 1024);
+ var evt = NewEvent(AuditStatus.Skipped, request: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal(input, result.RequestSummary);
+ Assert.False(result.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ErrorDetail_AndExtra_Truncated_Independently()
+ {
+ // Each field is capped on its own — a 10 KB RequestSummary and a 10 KB
+ // ErrorDetail on the same Delivered row should both be cut to 8 KB and
+ // the row flagged truncated.
+ var input = new string('g', 10 * 1024);
+ var evt = NewEvent(
+ AuditStatus.Delivered,
+ request: input,
+ response: input,
+ errorDetail: input,
+ extra: input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ErrorDetail!));
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.Extra!));
+ Assert.True(result.PayloadTruncated);
+ }
+
+ ///
+ /// IOptionsMonitor test double — returns the same snapshot on every read,
+ /// no change-token plumbing required for these tests (Bundle D wires the
+ /// real hot-reload path).
+ ///
+ private sealed class StaticMonitor : IOptionsMonitor
+ {
+ private readonly AuditLogOptions _value;
+
+ public StaticMonitor(AuditLogOptions value) => _value = value;
+
+ public AuditLogOptions CurrentValue => _value;
+
+ public AuditLogOptions Get(string? name) => _value;
+
+ public IDisposable? OnChange(Action listener) => null;
+ }
+}