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 91e07e35..5c69ef1e 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
@@ -10,26 +10,34 @@
{
"id": "R2-03-T2",
"subject": "Add OpcUaQuality Quality (default Good) to VirtualTagActor.EvaluationResult; bridge passes result.Quality into AttributeValueUpdate; historize maps Bad to 0x80020000u BadInternalError (RED bridge tests then GREEN)",
- "status": "pending",
- "blockedBy": ["R2-03-T1"]
+ "status": "completed",
+ "blockedBy": [
+ "R2-03-T1"
+ ]
},
{
"id": "R2-03-T3",
"subject": "VirtualTagActor failure/throw publishes Bad EvaluationResult once per transition, gated on all dependencyRefs arrived; recovery bypasses value-dedup; update Evaluator_failure_publishes_ScriptLogEntry_warning expectation (turns T1 green)",
"status": "pending",
- "blockedBy": ["R2-03-T2"]
+ "blockedBy": [
+ "R2-03-T2"
+ ]
},
{
"id": "R2-03-T4",
"subject": "S13 transition-semantics unit guards: second consecutive failure publishes nothing; same-value recovery republishes Good; evaluator-threw path also degrades",
"status": "pending",
- "blockedBy": ["R2-03-T3"]
+ "blockedBy": [
+ "R2-03-T3"
+ ]
},
{
"id": "R2-03-T5",
"subject": "S13 cold-start suppression guards: missing-dep failure publishes no Bad; failure after all deps arrived publishes Bad (inputs-ready gate)",
"status": "pending",
- "blockedBy": ["R2-03-T3"]
+ "blockedBy": [
+ "R2-03-T3"
+ ]
},
{
"id": "R2-03-T6",
@@ -47,31 +55,45 @@
"id": "R2-03-T8",
"subject": "S12 fix: retry-once on ObjectDisposedException in RoslynVirtualTagEvaluator.Evaluate (re-fetch via GetOrCompile; no retry when _disposed or second hit); update xmldoc (turns T7 green)",
"status": "pending",
- "blockedBy": ["R2-03-T6", "R2-03-T7"]
+ "blockedBy": [
+ "R2-03-T6",
+ "R2-03-T7"
+ ]
},
{
"id": "R2-03-T9",
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
"status": "pending",
- "blockedBy": ["R2-03-T3"]
+ "blockedBy": [
+ "R2-03-T3"
+ ]
},
{
"id": "R2-03-T10",
"subject": "P7 fix: gate the OnApply ClearCompiledScripts on HashSet.SetEquals of desired vs _planByVtag expression sets (ordinal), with explanatory comment (turns T9 green)",
"status": "pending",
- "blockedBy": ["R2-03-T9"]
+ "blockedBy": [
+ "R2-03-T9"
+ ]
},
{
"id": "R2-03-T11",
"subject": "Full-suite sweep (Runtime.Tests whole project + RoslynVirtualTagEvaluatorTests + solution build) and STATUS.md / tasks.json bookkeeping",
"status": "pending",
- "blockedBy": ["R2-03-T4", "R2-03-T5", "R2-03-T8", "R2-03-T10"]
+ "blockedBy": [
+ "R2-03-T4",
+ "R2-03-T5",
+ "R2-03-T8",
+ "R2-03-T10"
+ ]
},
{
"id": "R2-03-T12",
"subject": "Live-/run gate on docker-dev (rebuild BOTH centrals): author working VT, break it, Client.CLI read shows StatusCode Bad with last value, fix and read Good; P7 no-recompile spot-check on identical redeploy",
"status": "pending",
- "blockedBy": ["R2-03-T11"]
+ "blockedBy": [
+ "R2-03-T11"
+ ]
}
],
"lastUpdated": "2026-07-12"
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
index 29d1c909..4e1f6b95 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
@@ -4,6 +4,7 @@ using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -20,7 +21,13 @@ public sealed class VirtualTagActor : ReceiveActor
public const string ScriptLogsTopic = "script-logs";
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
- public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation);
+
+ /// Result emitted to the parent (the host bridge). carries the
+ /// OPC UA quality the node should read: for a fresh computed value,
+ /// when the script failed/timed out (02/S13) — in which case
+ /// is the last-known value (or null if none). Defaulting to Good keeps
+ /// every existing construction site source-compatible.
+ public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);
private readonly string _virtualTagId;
private readonly string _scriptId;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
index 002f1870..4f63c9ae 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
@@ -176,16 +176,22 @@ public sealed class VirtualTagHostActor : ReceiveActor
return;
}
+ // 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
+ // failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
+ // sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
- nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc));
+ nodeId, result.Value, result.Quality, result.TimestampUtc));
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
- // to. For a computed value source == server, so both timestamps are the evaluation time.
+ // to. For a computed value source == server, so both timestamps are the evaluation time. Bad
+ // results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot
+ // status — so the historian trend can distinguish a degraded sample from a Good one.
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
{
+ var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
_history.Record(nodeId, new DataValueSnapshot(
- result.Value, 0u /* StatusCodes.Good */, result.TimestampUtc, result.TimestampUtc));
+ result.Value, status, result.TimestampUtc, result.TimestampUtc));
}
}
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 447db41e..1741028f 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
@@ -406,6 +406,48 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
bad.TimestampUtc.ShouldBe(ts2);
}
+ /// 02/S13 bridge: a Bad-quality must bridge to
+ /// an carrying
+ /// (not the old hard-coded Good).
+ [Fact]
+ public void Bad_quality_EvaluationResult_is_bridged_as_Bad()
+ {
+ var publish = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
+
+ var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
+ host.Tell(new VirtualTagActor.EvaluationResult(
+ "vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));
+
+ var update = publish.ExpectMsg();
+ update.Quality.ShouldBe(OpcUaQuality.Bad);
+ update.Value.ShouldBe(42.0);
+ }
+
+ /// 02/S13 historize parity: a Bad result on a historized plan must record the dormant
+ /// engine's BadInternalError status (0x80020000u) in the snapshot, not the Good 0u.
+ [Fact]
+ public void Historized_Bad_result_is_recorded_with_BadInternalError_status()
+ {
+ var publish = CreateTestProbe();
+ var writer = new CapturingHistoryWriter();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
+ new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));
+
+ var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
+ host.Tell(new VirtualTagActor.EvaluationResult(
+ "vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));
+
+ publish.ExpectMsg();
+ AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
+ writer.Calls.TryPeek(out var captured).ShouldBeTrue();
+ captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
+ }
+
/// 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.