diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
index 757977c..89cfe9b 100644
--- a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
+++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
@@ -1,10 +1,36 @@
namespace ScadaLink.AuditLog.Configuration;
///
-/// Configuration for Audit Log (#23). Bound from the "AuditLog" section of
-/// appsettings.json. Bundle E (M1) ships the type so the DI scaffold can
-/// register it; the full property set + validator are added by Task 9.
+/// Configuration for Audit Log (#23). Bound from the AuditLog section of
+/// appsettings.json. Defaults reflect the design (alog.md §6, §10): an
+/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
+/// retention window with monthly partition-switch purge. The default
+/// header-redact list covers HTTP auth headers; per-target overrides extend
+/// (never replace) the global redactor set.
///
public sealed class AuditLogOptions
{
+ /// Default payload-summary cap in bytes (default 8 KiB).
+ public int DefaultCapBytes { get; set; } = 8192;
+
+ /// Payload-summary cap on error rows in bytes (default 64 KiB).
+ public int ErrorCapBytes { get; set; } = 65536;
+
+ /// HTTP headers redacted by default before persistence.
+ public List HeaderRedactList { get; set; } = new()
+ {
+ "Authorization",
+ "X-Api-Key",
+ "Cookie",
+ "Set-Cookie",
+ };
+
+ /// Body-content redactors applied globally (regex patterns).
+ public List GlobalBodyRedactors { get; set; } = new();
+
+ /// Per-target redaction overrides keyed by target identifier.
+ public Dictionary PerTargetOverrides { get; set; } = new();
+
+ /// Central retention window in days (default 365, range [30, 3650]).
+ public int RetentionDays { get; set; } = 365;
}
diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs
new file mode 100644
index 0000000..59785c3
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Options;
+
+namespace ScadaLink.AuditLog.Configuration;
+
+///
+/// Validates on startup. The caps drive payload
+/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily
+/// large blobs into the central AuditLog table.
+/// must be at least as large as
+/// because the error cap is meant to capture more detail than the
+/// happy-path summary, not less. is
+/// bounded to [30, 3650] to keep purge windows sane: too short would
+/// drop in-flight investigations, too long would defeat the partition-switch
+/// purge's purpose.
+///
+public sealed class AuditLogOptionsValidator : IValidateOptions
+{
+ /// Inclusive lower bound for .
+ public const int MinRetentionDays = 30;
+
+ /// Inclusive upper bound for .
+ public const int MaxRetentionDays = 3650;
+
+ ///
+ public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var failures = new List();
+
+ if (options.DefaultCapBytes <= 0)
+ {
+ failures.Add(
+ $"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
+ "must be > 0; it drives payload-summary truncation in audit writers.");
+ }
+
+ if (options.ErrorCapBytes < options.DefaultCapBytes)
+ {
+ failures.Add(
+ $"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
+ $"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
+ "the error-row cap is intended to capture more detail than the happy-path summary.");
+ }
+
+ if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
+ {
+ failures.Add(
+ $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
+ $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
+ }
+
+ return failures.Count == 0
+ ? ValidateOptionsResult.Success
+ : ValidateOptionsResult.Fail(failures);
+ }
+}
diff --git a/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
new file mode 100644
index 0000000..739ee07
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
@@ -0,0 +1,17 @@
+namespace ScadaLink.AuditLog.Configuration;
+
+///
+/// Per-target redaction override applied additively on top of
+/// and the
+/// /
+/// caps. Targets are identified by the script-facing external-system /
+/// database / notification-list / inbound-API-key name.
+///
+public sealed class PerTargetRedactionOverride
+{
+ /// Optional payload cap override (bytes); null inherits the global cap.
+ public int? CapBytes { get; set; }
+
+ /// Additional body redactor regex patterns (appended to the global list).
+ public List? AdditionalBodyRedactors { get; set; }
+}
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
index 7c93919..7e010e6 100644
--- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -1,14 +1,16 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
namespace ScadaLink.AuditLog;
///
/// Composition root for the Audit Log (#23) component. M1 registers
-/// ; later milestones extend this method to wire
-/// up writers, telemetry actors, and the central ingest pipeline. Audit Log
-/// (#23) sits alongside Notification Outbox (#21) and Site Call Audit (#22).
+/// and its validator; later milestones extend
+/// this method to wire up writers, telemetry actors, and the central ingest
+/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
+/// Site Call Audit (#22).
///
public static class ServiceCollectionExtensions
{
@@ -17,9 +19,13 @@ public static class ServiceCollectionExtensions
///
/// Binds from the
- /// section of .
- /// M2+ will register writers, telemetry actors, and the central ingest
- /// pipeline here. IAuditLogRepository is registered by
+ /// section of
+ /// and registers so a misconfigured
+ /// AuditLog section is rejected with a key-naming message when the
+ /// options are first resolved (or at startup when consumers wire in
+ /// ValidateOnStart()). M2+ will register writers, telemetry actors,
+ /// and the central ingest pipeline here. IAuditLogRepository is
+ /// registered by
/// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase,
/// so the caller (the Host on the central node) must also call that.
///
@@ -29,7 +35,9 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(config);
services.AddOptions()
- .Bind(config.GetSection(ConfigSectionName));
+ .Bind(config.GetSection(ConfigSectionName))
+ .ValidateOnStart();
+ services.AddSingleton, AuditLogOptionsValidator>();
return services;
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs
new file mode 100644
index 0000000..4e68e41
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs
@@ -0,0 +1,167 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Configuration;
+
+namespace ScadaLink.AuditLog.Tests.Configuration;
+
+///
+/// Task 9 (Bundle E): binding + validator
+/// behavior. The validator enforces invariants used by M2+ writers
+/// (per docs/plans/2026-05-20-auditlog-m1-foundation.md):
+/// DefaultCapBytes > 0, ErrorCapBytes >= DefaultCapBytes,
+/// RetentionDays in [30, 3650]. Header-redact defaults match the
+/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie.
+///
+public class AuditLogOptionsTests
+{
+ private static IOptions BuildOptions(Dictionary config)
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(config)
+ .Build();
+ var services = new ServiceCollection();
+ services.AddAuditLog(configuration);
+ return services.BuildServiceProvider().GetRequiredService>();
+ }
+
+ [Fact]
+ public void ValidBinding_PopulatesAllScalarFields()
+ {
+ var opts = BuildOptions(new Dictionary
+ {
+ ["AuditLog:DefaultCapBytes"] = "4096",
+ ["AuditLog:ErrorCapBytes"] = "32768",
+ ["AuditLog:RetentionDays"] = "180",
+ }).Value;
+
+ Assert.Equal(4096, opts.DefaultCapBytes);
+ Assert.Equal(32768, opts.ErrorCapBytes);
+ Assert.Equal(180, opts.RetentionDays);
+ }
+
+ [Fact]
+ public void DefaultsAreReasonable_WhenSectionEmpty()
+ {
+ var opts = BuildOptions(new Dictionary()).Value;
+
+ Assert.Equal(8192, opts.DefaultCapBytes);
+ Assert.Equal(65536, opts.ErrorCapBytes);
+ Assert.Equal(365, opts.RetentionDays);
+ Assert.Contains("Authorization", opts.HeaderRedactList);
+ Assert.Contains("X-Api-Key", opts.HeaderRedactList);
+ Assert.Contains("Cookie", opts.HeaderRedactList);
+ Assert.Contains("Set-Cookie", opts.HeaderRedactList);
+ Assert.Empty(opts.GlobalBodyRedactors);
+ Assert.Empty(opts.PerTargetOverrides);
+ }
+
+ [Fact]
+ public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
+ {
+ // Microsoft.Extensions.Configuration's collection binder appends to a
+ // defaulted list (it does not replace it), so config-supplied entries
+ // augment the built-in redact list rather than overriding it. The
+ // built-in entries are the safety-net defaults documented on
+ // AuditLogOptions; supplying additional headers is the supported
+ // extension point.
+ var opts = BuildOptions(new Dictionary
+ {
+ ["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth",
+ ["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id",
+ }).Value;
+
+ Assert.Contains("X-Custom-Auth", opts.HeaderRedactList);
+ Assert.Contains("X-Tenant-Id", opts.HeaderRedactList);
+ Assert.Contains("Authorization", opts.HeaderRedactList);
+ Assert.Contains("X-Api-Key", opts.HeaderRedactList);
+ }
+
+ [Fact]
+ public void PerTargetOverrides_BindsFromConfig()
+ {
+ var opts = BuildOptions(new Dictionary
+ {
+ ["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384",
+ ["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}",
+ }).Value;
+
+ Assert.True(opts.PerTargetOverrides.ContainsKey("CRM"));
+ var crm = opts.PerTargetOverrides["CRM"];
+ Assert.Equal(16384, crm.CapBytes);
+ Assert.NotNull(crm.AdditionalBodyRedactors);
+ Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
+ }
+
+ [Fact]
+ public void InvalidDefaultCapBytes_FailsValidation()
+ {
+ var result = new AuditLogOptionsValidator().Validate(
+ Options.DefaultName,
+ new AuditLogOptions { DefaultCapBytes = 0 });
+
+ Assert.True(result.Failed);
+ Assert.Contains("DefaultCapBytes", result.FailureMessage);
+ }
+
+ [Fact]
+ public void InvalidErrorCapBytes_FailsValidation()
+ {
+ var result = new AuditLogOptionsValidator().Validate(
+ Options.DefaultName,
+ new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 });
+
+ Assert.True(result.Failed);
+ Assert.Contains("ErrorCapBytes", result.FailureMessage);
+ }
+
+ [Fact]
+ public void RetentionDaysBelowMinimum_FailsValidation()
+ {
+ var result = new AuditLogOptionsValidator().Validate(
+ Options.DefaultName,
+ new AuditLogOptions { RetentionDays = 0 });
+
+ Assert.True(result.Failed);
+ Assert.Contains("RetentionDays", result.FailureMessage);
+ }
+
+ [Fact]
+ public void RetentionDaysAboveMaximum_FailsValidation()
+ {
+ var result = new AuditLogOptionsValidator().Validate(
+ Options.DefaultName,
+ new AuditLogOptions { RetentionDays = 3651 });
+
+ Assert.True(result.Failed);
+ Assert.Contains("RetentionDays", result.FailureMessage);
+ }
+
+ [Fact]
+ public void DefaultOptions_PassValidation()
+ {
+ var result = new AuditLogOptionsValidator().Validate(
+ Options.DefaultName,
+ new AuditLogOptions());
+
+ Assert.True(result.Succeeded, result.FailureMessage);
+ }
+
+ [Fact]
+ public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["AuditLog:RetentionDays"] = "0",
+ })
+ .Build();
+ var services = new ServiceCollection();
+ services.AddAuditLog(configuration);
+ var provider = services.BuildServiceProvider();
+ var opts = provider.GetRequiredService>();
+
+ var ex = Assert.Throws(() => _ = opts.Value);
+ Assert.Contains("RetentionDays", ex.Message);
+ }
+}