diff --git a/ZB.MOM.WW.ScadaBridge.slnx b/ZB.MOM.WW.ScadaBridge.slnx
index c62d9096..f6ae2c0b 100644
--- a/ZB.MOM.WW.ScadaBridge.slnx
+++ b/ZB.MOM.WW.ScadaBridge.slnx
@@ -24,6 +24,7 @@
+
@@ -53,5 +54,6 @@
+
diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs
new file mode 100644
index 00000000..4f78b476
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs
@@ -0,0 +1,38 @@
+namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
+
+///
+/// Configuration for the KPI History (#26, M6) component. Bound from the
+/// ScadaBridge:KpiHistory section of appsettings.json. Defaults
+/// reflect the design: a 60-second sampling cadence for the point-in-time
+/// Notification Outbox / Site Call Audit KPI snapshots, a 90-day central
+/// retention window with a daily purge sweep, and a 200-point default ceiling
+/// on the series returned to the Central UI trend charts.
+///
+public sealed class KpiHistoryOptions
+{
+ ///
+ /// How often the recorder captures a KPI sample row (default 60 s). Must be
+ /// strictly positive.
+ ///
+ public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
+
+ ///
+ /// Central retention window in days for recorded KPI samples (default 90,
+ /// range [1, 3650]). Rows older than this are dropped by the purge
+ /// sweep.
+ ///
+ public int RetentionDays { get; set; } = 90;
+
+ ///
+ /// How often the purge sweep drops expired sample rows (default 1 day). Must
+ /// be strictly positive.
+ ///
+ public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
+
+ ///
+ /// Default ceiling on the number of points returned in a single trend series
+ /// query when the caller does not specify one (default 200, range
+ /// [2, 5000]). At least two points are required to draw a line.
+ ///
+ public int DefaultMaxSeriesPoints { get; set; } = 200;
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs
new file mode 100644
index 00000000..f8e48214
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs
@@ -0,0 +1,58 @@
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
+
+///
+/// Validates on startup. A non-positive
+/// or
+/// would stall the recorder /
+/// purge loops, so both are required to be strictly positive.
+/// is bounded to [1, 3650]
+/// to keep the purge window sane (too short drops trends still being viewed;
+/// too long defeats the retention purpose).
+/// is bounded to [2, 5000] — at least two points are needed to draw a
+/// line, and an unbounded ceiling would let a single trend query stream an
+/// arbitrarily large series to the Central UI.
+///
+public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase
+{
+ /// Inclusive lower bound for .
+ public const int MinRetentionDays = 1;
+
+ /// Inclusive upper bound for .
+ public const int MaxRetentionDays = 3650;
+
+ /// Inclusive lower bound for .
+ public const int MinDefaultMaxSeriesPoints = 2;
+
+ /// Inclusive upper bound for .
+ public const int MaxDefaultMaxSeriesPoints = 5000;
+
+ ///
+ protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
+ {
+ builder.RequireThat(options.SampleInterval > TimeSpan.Zero,
+ $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " +
+ "must be > 0; it is the recorder's sampling cadence.");
+
+ // Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
+ // guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
+ builder.RequireThat(
+ !(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
+ $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RetentionDays)} ({options.RetentionDays}) " +
+ $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
+
+ builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
+ $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.PurgeInterval)} ({options.PurgeInterval}) " +
+ "must be > 0; it is the purge-sweep cadence.");
+
+ // Valid when DefaultMaxSeriesPoints is within [Min, Max] inclusive. The
+ // De Morgan'd guard !(below Min OR above Max) is equivalent to
+ // (>= Min AND <= Max).
+ builder.RequireThat(
+ !(options.DefaultMaxSeriesPoints < MinDefaultMaxSeriesPoints
+ || options.DefaultMaxSeriesPoints > MaxDefaultMaxSeriesPoints),
+ $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.DefaultMaxSeriesPoints)} ({options.DefaultMaxSeriesPoints}) " +
+ $"must be in [{MinDefaultMaxSeriesPoints}, {MaxDefaultMaxSeriesPoints}].");
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..f2a51952
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
+
+///
+/// Composition root for the KPI History (#26, M6) component.
+///
+///
+///
+/// K3 scaffolds the component: it binds and validates
+/// . The recorder actor itself is created via
+/// Props in the Host on the active central node (K4/K5), not registered
+/// here — mirroring the Notification Outbox (#21) singleton wiring.
+///
+///
+public static class ServiceCollectionExtensions
+{
+ /// Configuration section bound to .
+ public const string OptionsSection = "ScadaBridge:KpiHistory";
+
+ ///
+ /// Registers the KPI History (#26) component services: the validated
+ /// binding. Idempotent re-registration of the
+ /// validator is handled by the shared AddValidatedOptions helper
+ /// (TryAddEnumerable); the options binding itself is call-once per
+ /// .
+ ///
+ /// The service collection to register into.
+ /// Application configuration used to bind .
+ /// The same for chaining.
+ public static IServiceCollection AddKpiHistory(this IServiceCollection services, IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ // Binds the "ScadaBridge:KpiHistory" section, registers the validator,
+ // and enables ValidateOnStart in one call — same shape as AddAuditLog.
+ services.AddValidatedOptions(configuration, OptionsSection);
+
+ return services;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj
new file mode 100644
index 00000000..77d993f3
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs
new file mode 100644
index 00000000..e3969029
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs
@@ -0,0 +1,152 @@
+using ZB.MOM.WW.ScadaBridge.KpiHistory;
+
+namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests;
+
+///
+/// K3 unit tests for : the default
+/// options must validate, and each rule (SampleInterval / RetentionDays /
+/// PurgeInterval / DefaultMaxSeriesPoints) must reject its out-of-range values.
+///
+public class KpiHistoryOptionsValidatorTests
+{
+ private static KpiHistoryOptionsValidator NewValidator() => new();
+
+ [Fact]
+ public void Validate_Defaults_Pass()
+ {
+ var result = NewValidator().Validate(null, new KpiHistoryOptions());
+ Assert.True(result.Succeeded);
+ }
+
+ // ---------------------------------------------------------------------
+ // SampleInterval > TimeSpan.Zero
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void Validate_SampleInterval_Zero_Fails()
+ {
+ var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.Zero };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Validate_SampleInterval_Negative_Fails()
+ {
+ var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.FromSeconds(-1) };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal));
+ }
+
+ // ---------------------------------------------------------------------
+ // RetentionDays in [1, 3650]
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(1)] // min
+ [InlineData(90)] // default
+ [InlineData(3650)] // max
+ public void Validate_RetentionDays_InRange_Passes(int value)
+ {
+ var opts = new KpiHistoryOptions { RetentionDays = value };
+ Assert.True(NewValidator().Validate(null, opts).Succeeded);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(3651)]
+ [InlineData(-1)]
+ public void Validate_RetentionDays_OutOfRange_Fails(int value)
+ {
+ var opts = new KpiHistoryOptions { RetentionDays = value };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains(nameof(KpiHistoryOptions.RetentionDays), StringComparison.Ordinal));
+ }
+
+ // ---------------------------------------------------------------------
+ // PurgeInterval > TimeSpan.Zero
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void Validate_PurgeInterval_Zero_Fails()
+ {
+ var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.Zero };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Validate_PurgeInterval_Negative_Fails()
+ {
+ var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.FromHours(-1) };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal));
+ }
+
+ // ---------------------------------------------------------------------
+ // DefaultMaxSeriesPoints in [2, 5000]
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(2)] // min
+ [InlineData(200)] // default
+ [InlineData(5000)] // max
+ public void Validate_DefaultMaxSeriesPoints_InRange_Passes(int value)
+ {
+ var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value };
+ Assert.True(NewValidator().Validate(null, opts).Succeeded);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(5001)]
+ [InlineData(0)]
+ public void Validate_DefaultMaxSeriesPoints_OutOfRange_Fails(int value)
+ {
+ var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains(nameof(KpiHistoryOptions.DefaultMaxSeriesPoints), StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Validate_AllRulesViolated_AccumulatesEveryFailure()
+ {
+ // The base class aggregates ALL failures, not just the first — one
+ // message per violated rule.
+ var opts = new KpiHistoryOptions
+ {
+ SampleInterval = TimeSpan.Zero,
+ RetentionDays = 0,
+ PurgeInterval = TimeSpan.Zero,
+ DefaultMaxSeriesPoints = 1,
+ };
+ var result = NewValidator().Validate(null, opts);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(4, result.Failures!.Count());
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj
new file mode 100644
index 00000000..99b85581
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+