diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
index 0b287f11..49394bf0 100644
--- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
+++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
@@ -27,7 +27,7 @@
{
"id": "T4",
"subject": "GREEN: implement ServerHistorianOptionsValidator (OptionsValidatorBase, ctor IConfiguration, consumed = Enabled || AlarmHistorian:Enabled, fail on empty/non-absolute/non-http(s) Endpoint)",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"T3"
]
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs
new file mode 100644
index 00000000..ca727a5f
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs
@@ -0,0 +1,66 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Configuration;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Fail-fast startup validator for (archreview 06/S-11),
+/// built on the shared ZB.MOM.WW.Configuration .
+/// Wired via AddValidatedOptions so a provably-crashing historian config fails host startup
+/// with a named, aggregated OptionsValidationException instead of the raw
+/// the gateway client factory's new Uri(...) would
+/// otherwise throw at first resolution (a crash-loop under a service/docker restart policy).
+///
+///
+///
+/// Consumer-gated, not section-gated. The load-bearing question is not "is
+/// ServerHistorian enabled?" but "is its gateway connection consumed by anything
+/// enabled?". The alarm-history sink (AlarmHistorian:Enabled=true) sources its
+/// endpoint/key/TLS from the ServerHistorian section independently of
+/// , so it must also gate this check — otherwise the
+/// AlarmHistorian:Enabled=true / ServerHistorian:Enabled=false corner crashes with
+/// zero warning. Continuous historization is already co-gated on
+/// in the Host, so Enabled covers it.
+///
+///
+/// Fail tier = provably-crashing configs only. Only an empty / non-absolute / non-http(s)
+/// Endpoint fails here (it throws in the factory). Empty ApiKey and non-positive
+/// MaxTieClusterOverfetch degrade rather than crash (the gateway rejects calls / the node
+/// manager surfaces a Bad read), so they stay operator warnings in
+/// . The Endpoint value is not a secret and
+/// is echoed to make the error actionable; the ApiKey is never surfaced.
+///
+///
+public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase
+{
+ private readonly IConfiguration _configuration;
+
+ /// Creates the validator over the app configuration (read to resolve the consumer gate).
+ /// The app configuration; used to read AlarmHistorian:Enabled.
+ public ServerHistorianOptionsValidator(IConfiguration configuration) =>
+ _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+
+ ///
+ protected override void Validate(ValidationBuilder builder, ServerHistorianOptions options)
+ {
+ var alarmHistorianEnabled = _configuration.GetValue("AlarmHistorian:Enabled");
+ var consumed = options.Enabled || alarmHistorianEnabled;
+
+ // A section nobody consumes may legitimately be empty (the docker-dev default) — no checks.
+ if (!consumed) return;
+
+ var endpointValid = Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+
+ // Name the DRIVING section(s) so the operator knows why an empty/malformed endpoint fails —
+ // especially in the alarm-only corner where ServerHistorian itself is disabled.
+ var reason = options.Enabled
+ ? "required because ServerHistorian:Enabled=true"
+ : "required because AlarmHistorian:Enabled=true sources its gateway connection (endpoint/key/TLS) from the ServerHistorian section";
+
+ builder.RequireThat(
+ endpointValid,
+ $"ServerHistorian:Endpoint is empty or not an absolute http(s) URI ('{options.Endpoint}') — {reason}.");
+ }
+}