diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs
index 598a58b8..391a961a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs
@@ -112,13 +112,34 @@ public class SiteRuntimeOptions
public int ConfigFetchTimeoutSeconds { get; set; } = 30;
///
- /// Fixed interval (ms) at which an Instance Actor re-sends a tag-subscribe
- /// request that either failed or whose response was lost (S4/UA6). The retry is
- /// armed on every send and cancelled on the first Success reply, so it closes
- /// both the failed-response and the lost-response gaps. Default: 5000ms.
+ /// Floor interval (ms) at which an Instance Actor re-sends a tag-subscribe request
+ /// that either failed or whose response was lost (S4/UA6). The retry is armed on
+ /// every send and cancelled on the first Success reply. As of Lever 1 (2026-08-28,
+ /// MxGateway subscribe-storm prevention) it is the FLOOR of an exponential backoff:
+ /// each failed/lost attempt doubles the interval (±20 % jitter) up to
+ /// , and a Success or a connection
+ /// returning to Good quality resets it here. Default: 5000ms.
///
public int TagSubscribeRetryIntervalMs { get; set; } = 5000;
+ ///
+ /// Ceiling (ms) of the tag-subscribe retry backoff. Must be >=
+ /// ; equal makes the interval fixed
+ /// (pre-Lever-1 behaviour). Default: 60000ms.
+ ///
+ public int TagSubscribeRetryMaxIntervalMs { get; set; } = 60000;
+
+ ///
+ /// How long (ms) an outstanding tag-subscribe request blocks a new one for the
+ /// same connection. While a request is in flight and younger than this, a retry
+ /// tick only re-arms the watchdog instead of issuing a duplicate subscribe — this
+ /// is what stops a slow gateway from receiving a fresh N-tag subscribe every
+ /// interval on top of the one it is still processing. Once older than this the
+ /// reply is presumed lost and a new request is allowed. Size it above the
+ /// gateway's per-command deadline (MxGateway default 30 s). Default: 45000ms.
+ ///
+ public int TagSubscribeInFlightCeilingMs { get; set; } = 45000;
+
///
/// Grace period (ms) after a script's execution timeout elapses before the
/// stuck-script watchdog declares the script's dedicated thread blocked and
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs
index 53e81c69..fac982c8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs
@@ -79,6 +79,16 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase= options.TagSubscribeRetryIntervalMs,
+ $"ScadaBridge:SiteRuntime:TagSubscribeRetryMaxIntervalMs must be >= TagSubscribeRetryIntervalMs " +
+ $"(was {options.TagSubscribeRetryMaxIntervalMs} vs {options.TagSubscribeRetryIntervalMs}); " +
+ "it is the ceiling of the tag-subscribe retry backoff.");
+
+ builder.RequireThat(options.TagSubscribeInFlightCeilingMs > 0,
+ $"ScadaBridge:SiteRuntime:TagSubscribeInFlightCeilingMs must be greater than 0 " +
+ $"(was {options.TagSubscribeInFlightCeilingMs}); it bounds how long an unanswered tag-subscribe " +
+ "blocks a duplicate for the same connection.");
+
builder.RequireThat(options.StuckScriptGraceMs >= 0,
$"ScadaBridge:SiteRuntime:StuckScriptGraceMs must be >= 0 " +
$"(was {options.StuckScriptGraceMs}); a negative grace throws inside the stuck-script watchdog's delay.");
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs
index cb6b0356..e9f130eb 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs
@@ -144,4 +144,38 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentRunsPerScript", result.FailureMessage);
}
+ // --- Lever 1 (2026-08-28): tag-subscribe backoff + in-flight ceiling ---
+
+ [Fact]
+ public void TagSubscribeRetryMaxIntervalMs_BelowFloor_IsRejected()
+ {
+ var result = Validate(new SiteRuntimeOptions
+ {
+ TagSubscribeRetryIntervalMs = 5000,
+ TagSubscribeRetryMaxIntervalMs = 4999,
+ });
+
+ Assert.True(result.Failed);
+ Assert.Contains("TagSubscribeRetryMaxIntervalMs", result.FailureMessage);
+ }
+
+ [Fact]
+ public void TagSubscribeRetryMaxIntervalMs_EqualToFloor_IsValid()
+ {
+ var result = Validate(new SiteRuntimeOptions
+ {
+ TagSubscribeRetryIntervalMs = 5000,
+ TagSubscribeRetryMaxIntervalMs = 5000,
+ });
+ Assert.True(result.Succeeded, result.FailureMessage);
+ }
+
+ [Fact]
+ public void ZeroTagSubscribeInFlightCeilingMs_IsRejected()
+ {
+ var result = Validate(new SiteRuntimeOptions { TagSubscribeInFlightCeilingMs = 0 });
+
+ Assert.True(result.Failed);
+ Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage);
+ }
}