diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs
new file mode 100644
index 00000000..d101741c
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs
@@ -0,0 +1,69 @@
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.ScadaBridge.Communication;
+
+///
+/// Validates at startup (ValidateOnStart) so a
+/// malformed "Communication" appsettings section fails fast at boot with a
+/// key-naming message instead of surfacing later at first Ask/gRPC use. Every
+/// timeout feeds a per-pattern Ask deadline or a gRPC keepalive/stream-lifetime
+/// setting, and a zero/negative value there produces an opaque runtime failure
+/// far from the offending config key.
+///
+public sealed class CommunicationOptionsValidator : OptionsValidatorBase
+{
+ ///
+ protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
+ {
+ builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero,
+ $"Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout}).");
+
+ builder.RequireThat(options.LifecycleTimeout > TimeSpan.Zero,
+ $"Communication:LifecycleTimeout must be a positive duration (was {options.LifecycleTimeout}).");
+
+ builder.RequireThat(options.ArtifactDeploymentTimeout > TimeSpan.Zero,
+ $"Communication:ArtifactDeploymentTimeout must be a positive duration (was {options.ArtifactDeploymentTimeout}).");
+
+ builder.RequireThat(options.QueryTimeout > TimeSpan.Zero,
+ $"Communication:QueryTimeout must be a positive duration (was {options.QueryTimeout}).");
+
+ builder.RequireThat(options.IntegrationTimeout > TimeSpan.Zero,
+ $"Communication:IntegrationTimeout must be a positive duration (was {options.IntegrationTimeout}).");
+
+ builder.RequireThat(options.DebugViewTimeout > TimeSpan.Zero,
+ $"Communication:DebugViewTimeout must be a positive duration (was {options.DebugViewTimeout}).");
+
+ builder.RequireThat(options.HealthReportTimeout > TimeSpan.Zero,
+ $"Communication:HealthReportTimeout must be a positive duration (was {options.HealthReportTimeout}).");
+
+ builder.RequireThat(options.NotificationForwardTimeout > TimeSpan.Zero,
+ $"Communication:NotificationForwardTimeout must be a positive duration (was {options.NotificationForwardTimeout}).");
+
+ builder.RequireThat(options.GrpcKeepAlivePingDelay > TimeSpan.Zero,
+ $"Communication:GrpcKeepAlivePingDelay must be a positive duration (was {options.GrpcKeepAlivePingDelay}).");
+
+ builder.RequireThat(options.GrpcKeepAlivePingTimeout > TimeSpan.Zero,
+ $"Communication:GrpcKeepAlivePingTimeout must be a positive duration (was {options.GrpcKeepAlivePingTimeout}).");
+
+ builder.RequireThat(options.GrpcMaxStreamLifetime > TimeSpan.Zero,
+ $"Communication:GrpcMaxStreamLifetime must be a positive duration (was {options.GrpcMaxStreamLifetime}).");
+
+ builder.RequireThat(options.TransportHeartbeatInterval > TimeSpan.Zero,
+ $"Communication:TransportHeartbeatInterval must be a positive duration (was {options.TransportHeartbeatInterval}).");
+
+ builder.RequireThat(options.ApplicationHeartbeatInterval > TimeSpan.Zero,
+ $"Communication:ApplicationHeartbeatInterval must be a positive duration (was {options.ApplicationHeartbeatInterval}).");
+
+ builder.RequireThat(options.TransportFailureThreshold > TimeSpan.Zero,
+ $"Communication:TransportFailureThreshold must be a positive duration (was {options.TransportFailureThreshold}).");
+
+ builder.RequireThat(options.PendingDeploymentTtl > TimeSpan.Zero,
+ $"Communication:PendingDeploymentTtl must be a positive duration (was {options.PendingDeploymentTtl}).");
+
+ builder.RequireThat(options.PendingDeploymentPurgeInterval > TimeSpan.Zero,
+ $"Communication:PendingDeploymentPurgeInterval must be a positive duration (was {options.PendingDeploymentPurgeInterval}).");
+
+ builder.RequireThat(options.GrpcMaxConcurrentStreams > 0,
+ $"Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs
index 353ced49..dc9577f2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication;
@@ -11,7 +13,10 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddCommunication(this IServiceCollection services)
{
services.AddOptions()
- .BindConfiguration("Communication");
+ .BindConfiguration("Communication")
+ .ValidateOnStart();
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton, CommunicationOptionsValidator>());
services.AddSingleton();
services.AddSingleton();
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj b/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj
index 9deff0b9..69074da1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj
@@ -24,6 +24,7 @@
+
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs
new file mode 100644
index 00000000..1379bddd
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs
@@ -0,0 +1,39 @@
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+
+///
+/// Validates at startup (ValidateOnStart) so a
+/// malformed "DataConnectionLayer" appsettings section fails fast at boot with a
+/// key-naming message instead of surfacing later at runtime. The intervals drive
+/// the reconnect/tag-resolution timers and the seed-read retry loop; a
+/// zero/negative duration (or a non-positive SeedReadMaxAttempts) would
+/// otherwise produce an opaque failure far from the offending config key.
+///
+public sealed class DataConnectionOptionsValidator : OptionsValidatorBase
+{
+ ///
+ protected override void Validate(ValidationBuilder builder, DataConnectionOptions options)
+ {
+ builder.RequireThat(options.ReconnectInterval > TimeSpan.Zero,
+ $"DataConnectionLayer:ReconnectInterval must be a positive duration (was {options.ReconnectInterval}).");
+
+ builder.RequireThat(options.TagResolutionRetryInterval > TimeSpan.Zero,
+ $"DataConnectionLayer:TagResolutionRetryInterval must be a positive duration (was {options.TagResolutionRetryInterval}).");
+
+ builder.RequireThat(options.WriteTimeout > TimeSpan.Zero,
+ $"DataConnectionLayer:WriteTimeout must be a positive duration (was {options.WriteTimeout}).");
+
+ builder.RequireThat(options.SeedReadTimeout > TimeSpan.Zero,
+ $"DataConnectionLayer:SeedReadTimeout must be a positive duration (was {options.SeedReadTimeout}).");
+
+ builder.RequireThat(options.StableConnectionThreshold > TimeSpan.Zero,
+ $"DataConnectionLayer:StableConnectionThreshold must be a positive duration (was {options.StableConnectionThreshold}).");
+
+ builder.RequireThat(options.SeedReadRetryDelay > TimeSpan.Zero,
+ $"DataConnectionLayer:SeedReadRetryDelay must be a positive duration (was {options.SeedReadRetryDelay}).");
+
+ builder.RequireThat(options.SeedReadMaxAttempts > 0,
+ $"DataConnectionLayer:SeedReadMaxAttempts must be positive (was {options.SeedReadMaxAttempts}).");
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs
index 9954eb27..5f175071 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
@@ -13,8 +15,14 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services)
{
services.AddOptions()
- .BindConfiguration("DataConnectionLayer");
+ .BindConfiguration("DataConnectionLayer")
+ .ValidateOnStart();
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton, DataConnectionOptionsValidator>());
+ // OpcUaGlobalOptions / MxGatewayGlobalOptions carry only string identity/path
+ // fields (no numeric or duration knobs), so there is nothing to range-validate
+ // — no dedicated validator is registered for them.
services.AddOptions()
.BindConfiguration("OpcUa");
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
index 751f2fb1..775ace0d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
@@ -17,6 +17,7 @@
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs
new file mode 100644
index 00000000..2ce5f0ff
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
+
+///
+/// Regression: timeouts feed per-pattern Ask
+/// deadlines and gRPC keepalive/stream-lifetime settings; a zero/negative value
+/// (or a non-positive GrpcMaxConcurrentStreams) must be rejected at startup
+/// by an with a clear, key-naming message
+/// rather than surfacing at first Ask/gRPC use.
+///
+public class CommunicationOptionsValidatorTests
+{
+ private static ValidateOptionsResult Validate(CommunicationOptions options) =>
+ new CommunicationOptionsValidator().Validate(Options.DefaultName, options);
+
+ [Fact]
+ public void DefaultOptions_AreValid()
+ {
+ var result = Validate(new CommunicationOptions());
+ Assert.True(result.Succeeded, result.FailureMessage);
+ }
+
+ [Fact]
+ public void ZeroDeploymentTimeout_IsRejected()
+ {
+ var result = Validate(new CommunicationOptions { DeploymentTimeout = TimeSpan.Zero });
+ Assert.True(result.Failed);
+ Assert.Contains("DeploymentTimeout", result.FailureMessage);
+ }
+
+ [Fact]
+ public void NonPositiveGrpcMaxConcurrentStreams_IsRejected()
+ {
+ var result = Validate(new CommunicationOptions { GrpcMaxConcurrentStreams = 0 });
+ Assert.True(result.Failed);
+ Assert.Contains("GrpcMaxConcurrentStreams", result.FailureMessage);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs
new file mode 100644
index 00000000..2fbe3c1a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
+
+///
+/// Regression: intervals/timeouts drive the
+/// DCL reconnect/tag-resolution timers and the seed-read retry loop; a
+/// zero/negative duration (or a non-positive SeedReadMaxAttempts) must be
+/// rejected at startup by an with a
+/// clear, key-naming message rather than surfacing later at runtime.
+///
+public class DataConnectionOptionsValidatorTests
+{
+ private static ValidateOptionsResult Validate(DataConnectionOptions options) =>
+ new DataConnectionOptionsValidator().Validate(Options.DefaultName, options);
+
+ [Fact]
+ public void DefaultOptions_AreValid()
+ {
+ var result = Validate(new DataConnectionOptions());
+ Assert.True(result.Succeeded, result.FailureMessage);
+ }
+
+ [Fact]
+ public void ZeroReconnectInterval_IsRejected()
+ {
+ var result = Validate(new DataConnectionOptions { ReconnectInterval = TimeSpan.Zero });
+ Assert.True(result.Failed);
+ Assert.Contains("ReconnectInterval", result.FailureMessage);
+ }
+
+ [Fact]
+ public void NonPositiveSeedReadMaxAttempts_IsRejected()
+ {
+ var result = Validate(new DataConnectionOptions { SeedReadMaxAttempts = 0 });
+ Assert.True(result.Failed);
+ Assert.Contains("SeedReadMaxAttempts", result.FailureMessage);
+ }
+}