diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
index 86bfffdd..91e07e35 100644
--- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
+++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
@@ -4,7 +4,7 @@
{
"id": "R2-03-T1",
"subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
- "status": "pending",
+ "status": "completed",
"blockedBy": []
},
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
index 9b1590e8..447db41e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
@@ -365,6 +365,65 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
AwaitAssert(() => evaluator.ClearCount.ShouldBe(2));
}
+ ///
+ /// 02/S13 stale-Good repro (RED on current code): a script that succeeds once then starts failing
+ /// must degrade its node's quality to Bad — carrying the last-known value — rather than freezing on
+ /// the last Good value forever. Drives the REAL host + real child + parent-Tell bridge: apply one
+ /// plan (deps ["a"]), capture the spawned child via the mux probe's RegisterInterest sender, then
+ /// feed it two dependency changes. The first evaluates Ok(42) → a Good AttributeValueUpdate; the
+ /// second fails → the fix publishes a Bad AttributeValueUpdate carrying the stale 42. On the
+ /// unfixed tree the second publish never arrives (the second ExpectMsg times out).
+ ///
+ [Fact]
+ public void Failing_script_after_success_degrades_node_quality_to_Bad()
+ {
+ var publish = CreateTestProbe();
+ var mux = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref,
+ new FailAfterFirstSuccessEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
+
+ // The child self-registers with the mux in PreStart — capture its ref from the sender so we can
+ // drive it directly through the real evaluate → parent-Tell → bridge path.
+ mux.ExpectMsg();
+ var child = mux.LastSender;
+
+ var ts1 = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
+ var ts2 = new DateTime(2026, 7, 12, 12, 0, 1, DateTimeKind.Utc);
+
+ // First dep change → evaluator Ok(42) → Good publish.
+ child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, ts1));
+ var good = publish.ExpectMsg();
+ good.Quality.ShouldBe(OpcUaQuality.Good);
+ good.Value.ShouldBe(42.0);
+
+ // Second dep change → evaluator Failure → Bad publish carrying the last-known value 42.
+ child.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, ts2));
+ var bad = publish.ExpectMsg();
+ bad.Quality.ShouldBe(OpcUaQuality.Bad);
+ bad.Value.ShouldBe(42.0);
+ bad.TimestampUtc.ShouldBe(ts2);
+ }
+
+ /// Evaluator that returns Ok(42.0) on its first call and Failure thereafter —
+ /// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
+ /// evaluation thread.
+ private sealed class FailAfterFirstSuccessEvaluator : IVirtualTagEvaluator
+ {
+ private int _calls;
+
+ /// Returns Ok(42.0) once, then Failure("boom").
+ /// The tag identifier.
+ /// The expression string.
+ /// The dependency values.
+ /// Ok(42.0) on the first call; Failure on every subsequent call.
+ public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps)
+ => Interlocked.Increment(ref _calls) == 1
+ ? VirtualTagEvalResult.Ok(42.0)
+ : VirtualTagEvalResult.Failure("boom");
+ }
+
/// Capturing : records every Record call so H5c tests can
/// assert the host historizes (or does not) and with what path + snapshot.
private sealed class CapturingHistoryWriter : IHistoryWriter