diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
index 7061801..78328b1 100644
--- a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
+++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
@@ -22,7 +22,13 @@ namespace ScadaLink.AuditLog.Payload;
///
/// Uses (not )
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
-/// singleton.
+/// singleton. reads
+/// on every call, and the regex cache is keyed by pattern string — patterns
+/// added via a live config change compile on first use of the next event;
+/// patterns removed simply stop being looked up. No OnChange subscription
+/// or explicit cache invalidation is required (the
+/// AuditLogOptionsBindingTests fixture in ScadaLink.AuditLog.Tests
+/// pins this behaviour).
///
///
/// "Error row" = NOT IN (Delivered,
diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs
new file mode 100644
index 0000000..f9829cd
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs
@@ -0,0 +1,220 @@
+using System.Text;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+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.Configuration;
+
+///
+/// Bundle D (M5-T8) tests for hot-reloadable
+/// binding. The first test pins the JSON-realistic binding shape end-to-end
+/// (scalars, lists, per-target overrides) so accidental drift in the section
+/// layout breaks the build. The second test exercises the live hot-reload
+/// path: a backed by a mutable
+/// must respond to config changes on
+/// the very next event, with both cap-bytes and the regex-cache invalidation
+/// flowing through without a restart.
+///
+///
+/// Distinct from (M1-T9) which covered
+/// section binding + validator failures via single-key in-memory config — those
+/// tests exist; these add (a) end-to-end binding from a realistic JSON literal
+/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out.
+///
+public class AuditLogOptionsBindingTests
+{
+ [Fact]
+ public void AuditLog_Section_Binds_AllFields()
+ {
+ const string json = """
+ {
+ "AuditLog": {
+ "DefaultCapBytes": 4096,
+ "ErrorCapBytes": 32768,
+ "HeaderRedactList": ["Authorization", "Custom-Token"],
+ "GlobalBodyRedactors": ["\"password\":\\s*\"[^\"]*\""],
+ "PerTargetOverrides": {
+ "myconnection": {
+ "CapBytes": 16384,
+ "AdditionalBodyRedactors": [],
+ "RedactSqlParamsMatching": "@token|@secret"
+ }
+ },
+ "RetentionDays": 180
+ }
+ }
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
+ var configuration = new ConfigurationBuilder()
+ .AddJsonStream(stream)
+ .Build();
+ var services = new ServiceCollection();
+ services.AddAuditLog(configuration);
+ using var provider = services.BuildServiceProvider();
+
+ var opts = provider.GetRequiredService>().Value;
+
+ // Scalars.
+ Assert.Equal(4096, opts.DefaultCapBytes);
+ Assert.Equal(32768, opts.ErrorCapBytes);
+ Assert.Equal(180, opts.RetentionDays);
+
+ // HeaderRedactList: the Microsoft.Extensions.Configuration list binder
+ // APPENDS to the default list, so we assert containment rather than
+ // exact equality (see M1-T9 AuditLogOptionsTests for the rationale).
+ Assert.Contains("Authorization", opts.HeaderRedactList);
+ Assert.Contains("Custom-Token", opts.HeaderRedactList);
+
+ // GlobalBodyRedactors: pattern arrived intact, regex-escape sequences
+ // and all.
+ Assert.Contains("\"password\":\\s*\"[^\"]*\"", opts.GlobalBodyRedactors);
+
+ // PerTargetOverrides: keyed by connection name, each field bound.
+ Assert.True(opts.PerTargetOverrides.ContainsKey("myconnection"));
+ var ov = opts.PerTargetOverrides["myconnection"];
+ Assert.Equal(16384, ov.CapBytes);
+ // Microsoft.Extensions.Configuration JSON binder leaves an empty array
+ // null on a nullable List; either null or empty is acceptable as
+ // "no additional redactors" — both result in zero patterns at use.
+ Assert.True(ov.AdditionalBodyRedactors is null || ov.AdditionalBodyRedactors.Count == 0);
+ Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
+ }
+
+ [Fact]
+ public void Filter_Behavior_Updates_OnConfigReload()
+ {
+ // Start at the default cap (4096). A 5 KB body should be truncated;
+ // PayloadTruncated flips to true.
+ var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
+ var monitor = new TestOptionsMonitor(initial);
+ var filter = new DefaultAuditPayloadFilter(
+ monitor,
+ NullLogger.Instance);
+
+ var body = new string('x', 5 * 1024);
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ Status = AuditStatus.Delivered,
+ RequestSummary = body,
+ };
+
+ var resultBefore = filter.Apply(evt);
+ Assert.True(resultBefore.PayloadTruncated, "5KB body at 4096 cap must be truncated");
+ Assert.NotNull(resultBefore.RequestSummary);
+ Assert.True(Encoding.UTF8.GetByteCount(resultBefore.RequestSummary!) <= 4096);
+
+ // Reload: cap raised to 16384 — next event must NOT truncate. This is
+ // the M5-T8 contract: the filter sees the new value on the very next
+ // Apply, without process restart.
+ monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
+
+ var resultAfter = filter.Apply(evt);
+ Assert.False(resultAfter.PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
+ Assert.Equal(body, resultAfter.RequestSummary);
+ }
+
+ [Fact]
+ public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
+ {
+ // The regex cache is keyed by pattern string — a redactor added via
+ // config reload must compile + apply on the very next event without a
+ // process restart. Pre-reload: no redactor, hunter2 survives. After
+ // reload: hunter2 redacted.
+ var monitor = new TestOptionsMonitor(new AuditLogOptions());
+ var filter = new DefaultAuditPayloadFilter(
+ monitor,
+ NullLogger.Instance);
+
+ const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ Status = AuditStatus.Delivered,
+ RequestSummary = body,
+ };
+
+ var before = filter.Apply(evt);
+ Assert.Contains("hunter2", before.RequestSummary!);
+
+ monitor.Set(new AuditLogOptions
+ {
+ GlobalBodyRedactors = new List { "\"password\":\\s*\"[^\"]*\"" },
+ });
+
+ var after = filter.Apply(evt);
+ Assert.DoesNotContain("hunter2", after.RequestSummary!);
+ Assert.Contains("", after.RequestSummary!);
+ }
+
+ ///
+ /// IOptionsMonitor test double — exposes a method that
+ /// updates the current value and fires registered OnChange callbacks.
+ /// Avoids depending on Microsoft.Extensions.Configuration's reload-token
+ /// plumbing, which is awkward to drive deterministically from xUnit.
+ ///
+ private sealed class TestOptionsMonitor : IOptionsMonitor
+ {
+ private T _current;
+ private readonly List> _listeners = new();
+
+ public TestOptionsMonitor(T initial) => _current = initial;
+
+ public T CurrentValue => _current;
+
+ public T Get(string? name) => _current;
+
+ public IDisposable? OnChange(Action listener)
+ {
+ lock (_listeners)
+ {
+ _listeners.Add(listener);
+ }
+ return new Unsubscribe(_listeners, listener);
+ }
+
+ public void Set(T value)
+ {
+ _current = value;
+ Action[] snapshot;
+ lock (_listeners)
+ {
+ snapshot = _listeners.ToArray();
+ }
+ foreach (var l in snapshot)
+ {
+ l(_current, Options.DefaultName);
+ }
+ }
+
+ private sealed class Unsubscribe : IDisposable
+ {
+ private readonly List> _listeners;
+ private readonly Action _listener;
+ public Unsubscribe(List> listeners, Action listener)
+ {
+ _listeners = listeners;
+ _listener = listener;
+ }
+ public void Dispose()
+ {
+ lock (_listeners)
+ {
+ _listeners.Remove(_listener);
+ }
+ }
+ }
+ }
+}