diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index bc7f4f6f..4b3e65ae 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -59,7 +59,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
private readonly Phase7Applier? _applier;
private readonly IActorRef? _dbHealthProbe;
private readonly TimeSpan _staleWindow;
- private TimeSpan _probeFreshnessWindow;
+ private readonly TimeSpan _probeFreshnessWindow;
private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System);
private readonly ILoggingAdapter _log = Context.GetLogger();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
index b14b6c23..4dc5d16e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
@@ -355,6 +355,50 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
duration: TimeSpan.FromMilliseconds(500));
}
+ /// Verifies branch (3) of OpcUaProbeOk(): a peer's NEGATIVE verdict about this node
+ /// that has aged past _probeFreshnessWindow is given the benefit of the doubt (the peer's
+ /// verdict aged out → don't demote), so the node still publishes the HEALTHY level. With a 1ms
+ /// freshness window and a 30ms wait before the recompute, the cached Ok==false verdict is
+ /// reliably stale by the time the level is computed, so OpcUaProbeOk() returns true and a
+ /// healthy primary-leader computes inputs (true, true, false) → 240, +10 leader → 250 — NOT the 0
+ /// that a still-fresh negative verdict would produce (cf. ).
+ [Fact]
+ public void Stale_probe_verdict_is_not_demoted_publishes_healthy()
+ {
+ var publisher = new RecordingPublisher();
+ var local = NodeId.Parse("primary-node");
+ var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
+ new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
+ var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
+ serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
+ staleWindow: TimeSpan.FromSeconds(30),
+ probeFreshnessWindow: TimeSpan.FromMilliseconds(1)));
+
+ // Seed a healthy DB sample and a NEGATIVE peer verdict about me. (No RedundancyStateChanged yet,
+ // so no level is computed from these — the verdict just gets stamped with the receive time.)
+ actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
+ actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
+
+ // Let the verdict age WELL past the 1ms freshness window before any level is computed. The 30ms
+ // wait is >> 1ms, so the verdict is reliably stale by the time OpcUaProbeOk() runs — no reliance
+ // on sub-ms clock ties.
+ ExpectNoMsg(TimeSpan.FromMilliseconds(30));
+
+ // Now trigger the recompute with a fresh healthy primary-leader snapshot. By now _probeAboutMe is
+ // >1ms old, so branch (3) ignores the negative verdict and OpcUaProbeOk() returns true.
+ actor.Tell(new RedundancyStateChanged(
+ Nodes: new[]
+ {
+ new NodeRedundancyState(local, RedundancyRole.Primary,
+ IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow),
+ },
+ CorrelationId.NewId()));
+
+ // Healthy (250), NOT 0 — proves an aged negative verdict does not demote.
+ AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
+ duration: TimeSpan.FromMilliseconds(500));
+ }
+
/// Verifies that with no peer probe result ever received, OpcUaProbeOk() defaults
/// to true (benefit of the doubt / single-node). A healthy primary-leader thus computes
/// inputs (true, true, false) → 240, +10 leader → 250.