diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index e0a3aec5..d73356ca 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -1224,6 +1224,26 @@ public class InstanceActor : ReceiveActor
SendTagSubscribe(connectionName);
}
+ ///
+ /// Pure backoff step for the tag-subscribe retry (Lever 1, 2026-08-28): double the
+ /// interval just used, apply ±20 % jitter from (a
+ /// uniform sample in [0,1]; 0.5 = no jitter) so N instances on one connection do not
+ /// re-storm in lockstep, then clamp to [floor, ceiling]. A ceiling below the floor
+ /// degrades to a fixed floor interval rather than shrinking below it — same contract
+ /// as DataConnectionActor.NextTagResolutionInterval.
+ ///
+ internal static TimeSpan NextTagSubscribeInterval(TimeSpan current, TimeSpan floor, TimeSpan max, double jitterUnit)
+ {
+ var ceiling = max < floor ? floor : max;
+ if (current < floor)
+ current = floor;
+
+ var doubledMs = current.TotalMilliseconds * 2;
+ var jittered = doubledMs * (0.8 + 0.4 * Math.Clamp(jitterUnit, 0.0, 1.0));
+ var clamped = Math.Clamp(jittered, floor.TotalMilliseconds, ceiling.TotalMilliseconds);
+ return TimeSpan.FromMilliseconds(Math.Round(clamped));
+ }
+
///
/// Sends (or re-sends) the tag-subscribe request for one connection and arms a
/// per-connection retry timer (S4/UA6). Arming on send means the retry fires
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs
new file mode 100644
index 00000000..aaebb22e
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs
@@ -0,0 +1,51 @@
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
+
+///
+/// Lever 1 (2026-08-28): the pure backoff step behind the Instance Actor's tag-subscribe
+/// retry. Doubles with jitter, never below the floor, never above the ceiling; a ceiling
+/// below the floor degrades to a fixed interval rather than shrinking.
+///
+public class InstanceActorTagSubscribeBackoffTests
+{
+ private static readonly TimeSpan Floor = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan Max = TimeSpan.FromSeconds(60);
+
+ [Fact]
+ public void Doubles_WithNoJitter()
+ {
+ var next = InstanceActor.NextTagSubscribeInterval(Floor, Floor, Max, jitterUnit: 0.5);
+ Assert.Equal(TimeSpan.FromSeconds(10), next);
+ }
+
+ [Fact]
+ public void Jitter_SpansPlusMinusTwentyPercent()
+ {
+ var low = InstanceActor.NextTagSubscribeInterval(Floor, Floor, Max, jitterUnit: 0.0);
+ var high = InstanceActor.NextTagSubscribeInterval(Floor, Floor, Max, jitterUnit: 1.0);
+ Assert.Equal(TimeSpan.FromSeconds(8), low); // 10 s × 0.8
+ Assert.Equal(TimeSpan.FromSeconds(12), high); // 10 s × 1.2
+ }
+
+ [Fact]
+ public void NeverExceedsCeiling_EvenWithPositiveJitter()
+ {
+ var next = InstanceActor.NextTagSubscribeInterval(TimeSpan.FromSeconds(40), Floor, Max, jitterUnit: 1.0);
+ Assert.Equal(Max, next);
+ }
+
+ [Fact]
+ public void NeverBelowFloor_EvenWithNegativeJitter()
+ {
+ var next = InstanceActor.NextTagSubscribeInterval(TimeSpan.FromSeconds(1), Floor, Max, jitterUnit: 0.0);
+ Assert.True(next >= Floor, $"{next} < floor");
+ }
+
+ [Fact]
+ public void CeilingBelowFloor_DegradesToFixedFloor()
+ {
+ var next = InstanceActor.NextTagSubscribeInterval(Floor, Floor, TimeSpan.FromSeconds(1), jitterUnit: 0.5);
+ Assert.Equal(Floor, next);
+ }
+}