diff --git a/archreview/plans/R3-02-stale-good-family-plan.md b/archreview/plans/R3-02-stale-good-family-plan.md
index 2fbebca4..ffcff20e 100644
--- a/archreview/plans/R3-02-stale-good-family-plan.md
+++ b/archreview/plans/R3-02-stale-good-family-plan.md
@@ -780,3 +780,29 @@ test could only pass once the production change landed.
overload. A test pins that fallback explicitly (`ValueOnlyEvaluator`).
- `RoslynVirtualTagEvaluator.BuildReadCache` is **deleted**; the snapshots are the read cache. The
value-only overload now projects up into the snapshot one, so there is a single evaluation body.
+
+### T16 — `VirtualTagActor` honours input quality
+
+- RED: `--filter "…VirtualTagActorInputQualityTests"` ⇒ **7 failed**, incl. `bad.StatusCode should be
+ 2150694912u`, `result.StatusCode should be 1073741824u` (Uncertain), `reassert.StatusCode should be
+ 2150694912u`, and `Expected no messages … instead we received EvaluationResult { … StatusCode = 0 }`
+ for the cold-start case — i.e. a Bad input produced a fresh Good result.
+- GREEN: 58 passed across `VirtualTag*` (the 7 new + the R2-03 transition suites + the host bridge's
+ new historize-parity case), all pre-existing cases unchanged.
+- **Vacuity check performed** (the plan requires this one explicitly): reverting the dedup key to
+ value-only ⇒ `An_identical_value_is_republished_when_recovering_from_a_bad_input` **FAILED** with
+ `Timeout 00:00:15 while waiting for a message of type …EvaluationResult` — the recovery was swallowed
+ entirely. Restored, green.
+- ⚠️ **Deviation from the plan's design, deliberate and load-bearing.** The plan says the worst-of-input
+ walk should treat a **never-pushed** dependency as `BadWaitingForInitialData` and hold. Implemented
+ literally, that HOLDS evaluation during ordinary warm-up — but this actor (unlike the Calculation
+ driver) has always evaluated on partial arrival and published the partial result, and two existing
+ tests assert exactly that (`VirtualTagActorTests.Evaluator_result_flows_to_parent_as_EvaluationResult`
+ and `DependencyMuxActorTests.VirtualTagActor_PreStart_registers_deps_…_end_to_end`, which both went red
+ under the literal reading). The worst-of walk therefore covers **arrived** deps only — the same skip
+ `ScriptedAlarmEngine.WorstInputStatus` has always made — and cold start stays governed by R2-03's
+ existing `AllDependenciesArrived` gate on the publish tails. The plan's own risk note ("every Good-path
+ assertion in the existing suites stays byte-identical") is what this preserves.
+- `_lastPublishedBad` became `_hasPublished` + `_lastPublishedStatus`, which is also what makes
+ `ReassertValue` re-emit `BadNoCommunication` after a deploy instead of guessing Good.
+- Docs: `docs/ScriptEditor.md` §"Input quality: what a script sees, and when it runs at all".
diff --git a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json
index 2bd803a6..25cc7158 100644
--- a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json
+++ b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json
@@ -116,7 +116,7 @@
{
"id": "T16",
"subject": "VirtualTagActor honours input quality: snapshot map, hold on Bad, worst-of status, (value,status) dedup key, reassert carries status; host-bridge historize parity test",
- "status": "pending",
+ "status": "done",
"blockedBy": [
"T1",
"T15"
diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md
index 1241db9d..88f7c54c 100644
--- a/docs/ScriptEditor.md
+++ b/docs/ScriptEditor.md
@@ -399,6 +399,29 @@ reference map identically, so their plans stay byte-parity.
---
+## Input quality: what a script sees, and when it runs at all
+
+The rule is stated once, in
+[`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract), and it
+applies identically to virtual-tag scripts and to `Calculation` driver scripts — they share
+`VirtualTagContext` and this editor, so one policy keeps this page true for every `ctx.GetTag` call
+site.
+
+- **`ctx.GetTag(path).StatusCode` and `.SourceTimestampUtc` are real.** The read cache used to stamp
+ every dependency `Good` with an evaluation-time timestamp, so a quality-aware script could not be
+ written at all — it was told Good even for an input whose driver had stopped communicating.
+- **A script is NOT run while any of its declared inputs is Bad.** The tag instead reports that
+ input's own status (`BadNoCommunication`, `BadWaitingForInitialData`, …) carrying its last known
+ value. So the `(double)ctx.GetTag("x").Value` idiom is safe against a dead input: the script simply
+ does not run, rather than throwing on every trigger and filling `/script-log`.
+- **An Uncertain input IS passed to the script**, and the output inherits that Uncertain sub-code. An
+ Uncertain value is present and a script can make a qualified decision from it; that is the alarm
+ engine's long-standing rule, now shared.
+- **The output never reports better than its worst input.** A Good result over an Uncertain input is
+ published Uncertain; nothing is published Good while an input is Bad.
+- ⚠️ **If you wrote a script that deliberately handled `null` for a dead input, it no longer runs
+ while that input is Bad.** Assume your inputs are usable — the runtime has already decided they are.
+
## Known Limitations / Follow-ups
- **Tag-path completion shows resolvable keys only.** Surfacing the UNS browse
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 5520c282..9b33d846 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
@@ -6,6 +6,7 @@ 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;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -31,9 +32,13 @@ public sealed class VirtualTagActor : ReceiveActor
public const string ScriptLogsTopic = "script-logs";
/// A dependency's value changed. (#478) carries the source
- /// driver's OPC UA status code so the scripted-alarm host can derive a condition's worst-of-input
- /// quality; it defaults to , and the virtual-tag engine ignores it.
- /// Since #519 it carries the driver's sub-code, so a worst-of-input winner keeps the reason it won.
+ /// driver's OPC UA status code — since #519 including its sub-code, so a worst-of-input winner keeps
+ /// the reason it won — and it defaults to .
+ /// The virtual-tag engine consumes it (archreview 02/S14). This doc used to say the
+ /// engine ignored it, which was true and became the defect: a Bad input was stored as a bare value,
+ /// the script ran against it, and the tag published a fresh-Good result over a dead source. The actor
+ /// now holds evaluation while any declared input is Bad and inherits the worst input's status
+ /// otherwise. The Calculation driver reads it through the same mux (05/STAB-19).
public sealed record DependencyValueChanged(
string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good);
@@ -72,13 +77,22 @@ public sealed class VirtualTagActor : ReceiveActor
private readonly IActorRef? _mux;
private readonly ITelemetryLocalHub? _telemetryHub;
private readonly ILoggingAdapter _log = Context.GetLogger();
- private readonly Dictionary _dependencies = new(StringComparer.Ordinal);
+ // Dependency SNAPSHOTS, not bare values (archreview 02/S14): the status code is what distinguishes a
+ // comms-lost input from a genuine Good null, and it is what the script reads through
+ // ctx.GetTag(path).StatusCode.
+ private readonly Dictionary _dependencies = new(StringComparer.Ordinal);
private bool _hasLastValue;
private object? _lastValue;
- // 02/S13: true once a Bad EvaluationResult has been published and not yet cleared by a Good
- // recovery. Gates Bad publishes to once-per-transition and force-publishes the recovery value.
- private bool _lastPublishedBad;
+
+ /// Whether anything has been published for this tag yet — distinct from
+ /// , because an inputs-Bad publish carries no value of its own.
+ private bool _hasPublished;
+
+ /// The status last published. Replaces 02/S13's two-state _lastPublishedBad flag
+ /// (02/S14): the dedup key is (value, status), a Bad may now carry the failing INPUT's sub-code rather
+ /// than a fixed BadInternalError, and a reassert re-emits this rather than guessing Good.
+ private uint _lastPublishedStatus = OpcUaStatus.Good;
/// Factory method to create Props for a VirtualTagActor.
/// Unique identifier for the virtual tag.
@@ -178,15 +192,17 @@ public sealed class VirtualTagActor : ReceiveActor
///
private void OnReassertValue()
{
- if (!_hasLastValue && !_lastPublishedBad) return;
- var statusCode = _lastPublishedBad ? OpcUaStatus.BadInternalError : OpcUaStatus.Good;
+ if (!_hasLastValue && !_hasPublished) return;
+ // The status LAST PUBLISHED, not a guess (02/S14). A VT whose input is comms-lost re-asserts
+ // BadNoCommunication onto the freshly-materialised node; re-asserting Good here would put the
+ // stale-Good lie back at every deploy.
Context.Parent.Tell(new EvaluationResult(
- _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), statusCode, IsReassert: true));
+ _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), _lastPublishedStatus, IsReassert: true));
}
private void OnDependencyChanged(DependencyValueChanged msg)
{
- _dependencies[msg.TagId] = msg.Value;
+ _dependencies[msg.TagId] = new DataValueSnapshot(msg.Value, msg.StatusCode, msg.TimestampUtc, msg.TimestampUtc);
Evaluate(msg.TimestampUtc);
}
@@ -210,7 +226,8 @@ public sealed class VirtualTagActor : ReceiveActor
for (var i = 0; i < count; i++)
{
var item = items[i];
- _dependencies[item.TagId] = item.Value;
+ _dependencies[item.TagId] =
+ new DataValueSnapshot(item.Value, item.StatusCode, item.TimestampUtc, item.TimestampUtc);
}
Evaluate(items[count - 1].TimestampUtc);
@@ -222,6 +239,22 @@ public sealed class VirtualTagActor : ReceiveActor
/// The triggering dependency change's timestamp, stamped on the result.
private void Evaluate(DateTime timestampUtc)
{
+ // Worst-of-input FIRST (02/S14), before the evaluator is even considered.
+ //
+ // Over the deps that have ARRIVED, deliberately — a never-pushed dep is cold start, not a fault,
+ // and it is already handled by the inputs-ready gate in the two publish tails (which suppress the
+ // warm-up flash). Counting it as Bad here would instead HOLD evaluation during warm-up, which is
+ // a behaviour change this finding does not ask for: a partially-warmed-up VT evaluates today and
+ // must keep doing so, or every Good-path assertion in the existing suites changes meaning. Same
+ // skip ScriptedAlarmEngine.WorstInputStatus has always made.
+ var worst = InputQuality.WorstOf(
+ _dependencyRefs.Select(r => _dependencies.GetValueOrDefault(r)).Where(s => s is not null));
+ if (!InputQuality.IsUsable(worst))
+ {
+ OnInputsBad(worst, timestampUtc);
+ return;
+ }
+
VirtualTagEvalResult result;
try
{
@@ -249,9 +282,10 @@ public sealed class VirtualTagActor : ReceiveActor
return;
}
- // Value dedup — but bypass it while recovering from Bad, so a recovery whose value equals the
- // pre-failure value still republishes Good (otherwise the node would stay Bad forever).
- if (!_lastPublishedBad && _hasLastValue && Equals(_lastValue, result.Value))
+ // Dedup on (value, STATUS) — 02/S14 generalising 02/S13's recover-from-Bad bypass. Keying on
+ // value alone swallows every recovery whose value is unchanged, which is what a device coming
+ // back usually produces, and the node then stays Bad forever.
+ if (_hasPublished && _lastPublishedStatus == worst && _hasLastValue && Equals(_lastValue, result.Value))
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair("outcome", "skip"));
return;
@@ -259,12 +293,52 @@ public sealed class VirtualTagActor : ReceiveActor
_hasLastValue = true;
_lastValue = result.Value;
- _lastPublishedBad = false;
+ _hasPublished = true;
+ // The output inherits the worst input's quality: Good, or an Uncertain sub-code (Bad never
+ // reaches here — it was held above).
+ _lastPublishedStatus = worst;
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair("outcome", "ok"));
- var evalResult = new EvaluationResult(_virtualTagId, result.Value, timestampUtc, CorrelationId.NewId());
+ var evalResult = new EvaluationResult(_virtualTagId, result.Value, timestampUtc, CorrelationId.NewId(), worst);
Context.Parent.Tell(evalResult);
}
+ ///
+ /// 02/S14 — this tag's INPUTS are Bad, so the script is not run and the tag reports the worst
+ /// input's status, carrying its last known value.
+ ///
+ ///
+ ///
+ /// Holding rather than evaluating-and-degrading is what removes the
+ /// (double)ctx.GetTag(x).Value NRE storm at source: a comms-lost input used to arrive as
+ /// Good/null and the script ran against it on every trigger. It is also the alarm engine's
+ /// long-standing policy (AreInputsReady), so all three script seams now agree.
+ ///
+ ///
+ /// No script-log entry, deliberately: an input dying is not a script fault, and the
+ /// script log is 02/S13's per-failure SCRIPT diagnostic. Here the status code is the
+ /// diagnostic, and it names which failure the operator is looking at.
+ ///
+ ///
+ /// Published once per change of the published status, and only once every declared
+ /// dependency has arrived — R2-03's inputs-ready gate, kept as-is, so a cold start or an actor
+ /// respawn does not flash Bad while the mux is still filling the map.
+ ///
+ ///
+ /// The worst status among this tag's declared dependencies.
+ /// The triggering change's timestamp, stamped on the result.
+ private void OnInputsBad(uint worstInputStatus, DateTime timestampUtc)
+ {
+ OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair("outcome", "inputs-bad"));
+
+ if (!AllDependenciesArrived()) return;
+ if (_hasPublished && _lastPublishedStatus == worstInputStatus) return;
+
+ _hasPublished = true;
+ _lastPublishedStatus = worstInputStatus;
+ Context.Parent.Tell(new EvaluationResult(
+ _virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), worstInputStatus));
+ }
+
/// 02/S13 failure tail shared by the evaluator-threw catch and the !Success branch.
/// Always meters + publishes the per-failure script-log entry (the pre-existing operator
/// diagnostic); additionally publishes a Bad — carrying the
@@ -278,12 +352,13 @@ public sealed class VirtualTagActor : ReceiveActor
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair("outcome", "fail"));
PublishLog(level, reason);
- if (_lastPublishedBad || !AllDependenciesArrived())
+ if ((_hasPublished && _lastPublishedStatus == OpcUaStatus.BadInternalError) || !AllDependenciesArrived())
{
return;
}
- _lastPublishedBad = true;
+ _hasPublished = true;
+ _lastPublishedStatus = OpcUaStatus.BadInternalError;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs
new file mode 100644
index 00000000..039c380e
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs
@@ -0,0 +1,241 @@
+using Akka.Actor;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
+
+///
+/// Archreview 02/S14 — what a virtual tag does with its dependencies' quality.
+///
+/// The defect. The actor stored dependency values only, evaluated unconditionally, and
+/// the Roslyn read cache stamped every input Good — so a VT over a comms-lost input kept publishing
+/// fresh-Good results, and a script author could not detect it because
+/// ctx.GetTag(path).StatusCode read the fabricated Good. 02/S13 fixed "broken script looks
+/// Good"; this is the same lie through the other door.
+///
+/// The policy is the Calculation driver's and the alarm engine's, stated once in
+/// InputQuality: hold on Bad and publish the worst input's sub-code; evaluate through
+/// Uncertain and stamp the output with it. The R2-03 decisions are kept, not re-litigated —
+/// transition-once, the inputs-ready gate, and force-republish on recovery.
+///
+/// ⚠️ Vacuity rule: the recovery case computes the identical value it had before
+/// the outage. A recovery with a different value passes against a value-only dedup key and is not the
+/// guard — that dedup is exactly the "Bad forever" trap.
+///
+public sealed class VirtualTagActorInputQualityTests : RuntimeActorTestBase
+{
+ private const uint Uncertain = 0x40000000u;
+
+ ///
+ /// A Bad input holds the evaluator and publishes that input's own sub-code, once, carrying the
+ /// last known value. The evaluator-call count is the load-bearing half: "the result is Bad" could
+ /// also be produced by running the script against a null and failing, which is the NRE storm this
+ /// replaces.
+ ///
+ [Fact]
+ public void A_bad_input_holds_evaluation_and_publishes_the_inputs_sub_code()
+ {
+ var parent = CreateTestProbe();
+ var evaluator = new CountingSumEvaluator();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a + b", evaluator: evaluator, dependencyRefs: ["a", "b"]));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 32, DateTime.UtcNow));
+ parent.ExpectMsg().Value.ShouldBe(10);
+ parent.ExpectMsg().Value.ShouldBe(42);
+ var callsBefore = evaluator.Calls;
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
+
+ var bad = parent.ExpectMsg(PresenceBudget);
+ bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ bad.StatusCode.ShouldNotBe(OpcUaStatus.BadInternalError, "the script did not fail — its input did");
+ bad.Value.ShouldBe(42, "the last known value rides out with the Bad status");
+ evaluator.Calls.ShouldBe(callsBefore, "the script must NOT be run against a Bad input");
+
+ // Transition-once: the same Bad again changes nothing.
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
+ parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ /// A different Bad sub-code republishes — the sub-code is the diagnostic.
+ [Fact]
+ public void A_different_bad_sub_code_is_republished()
+ {
+ var parent = CreateTestProbe();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
+ parent.ExpectMsg();
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
+ parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadWaitingForInitialData));
+ parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
+ }
+
+ ///
+ /// The vacuity-sensitive assertion. The input recovers holding the value it had before, so
+ /// the computed result is identical to the pre-outage one — and it must still be published Good,
+ /// because the node is currently Bad and only a publish can clear it.
+ ///
+ [Fact]
+ public void An_identical_value_is_republished_when_recovering_from_a_bad_input()
+ {
+ var parent = CreateTestProbe();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
+ parent.ExpectMsg().Value.ShouldBe(10);
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
+ parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+
+ // Same value as before the outage.
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
+
+ var recovered = parent.ExpectMsg(PresenceBudget);
+ recovered.Value.ShouldBe(10, "the same value as before — that is the point");
+ recovered.StatusCode.ShouldBe(OpcUaStatus.Good);
+ }
+
+ ///
+ /// An Uncertain input is usable: the script runs and the output inherits the Uncertain code. The
+ /// script also sees the real code — a capturing evaluator asserts it, because a VT author writing
+ /// a quality-aware script is the reason the read cache had to stop fabricating Good.
+ ///
+ [Fact]
+ public void An_uncertain_input_is_evaluated_and_stamps_the_output_uncertain()
+ {
+ var parent = CreateTestProbe();
+ var evaluator = new CountingSumEvaluator();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a", evaluator: evaluator, dependencyRefs: ["a"]));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
+ parent.ExpectMsg();
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 11, DateTime.UtcNow, Uncertain));
+
+ var result = parent.ExpectMsg(PresenceBudget);
+ result.Value.ShouldBe(11, "the script DID run — Uncertain is usable");
+ result.StatusCode.ShouldBe(Uncertain);
+ evaluator.LastSeenStatus.ShouldBe(Uncertain, "the script must observe the input's REAL status");
+ }
+
+ ///
+ /// The R2-03 inputs-ready gate is preserved: with one declared dependency never pushed, a Bad
+ /// arrival on the other publishes nothing — cold start is not a fault.
+ ///
+ [Fact]
+ public void Nothing_is_published_while_a_declared_dependency_has_never_arrived()
+ {
+ var parent = CreateTestProbe();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a", "b"]));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
+
+ parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
+ }
+
+ ///
+ /// A deploy's ReassertValue re-emits the LAST PUBLISHED status, so a VT whose input is
+ /// comms-lost re-asserts BadNoCommunication onto the freshly-materialised node rather than
+ /// Good. Re-asserting Good here would re-introduce the stale-Good lie at every deploy.
+ ///
+ [Fact]
+ public void Reassert_carries_the_last_published_status()
+ {
+ var parent = CreateTestProbe();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
+ parent.ExpectMsg();
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
+ parent.ExpectMsg();
+
+ actor.Tell(VirtualTagActor.ReassertValue.Instance);
+
+ var reassert = parent.ExpectMsg(PresenceBudget);
+ reassert.IsReassert.ShouldBeTrue();
+ reassert.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ reassert.Value.ShouldBe(10);
+ }
+
+ ///
+ /// The batched mux delivery goes through the same gate: a burst whose worst item is Bad holds the
+ /// script and publishes that sub-code. A fix applied only to the single-value path would be inert
+ /// in production, where the mux batches.
+ ///
+ [Fact]
+ public void A_batch_containing_a_bad_item_holds_evaluation_too()
+ {
+ var parent = CreateTestProbe();
+ var evaluator = new CountingSumEvaluator();
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "a + b", evaluator: evaluator, dependencyRefs: ["a", "b"]));
+ var ts = DateTime.UtcNow;
+
+ actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
+ {
+ new VirtualTagActor.DependencyValueChanged("a", 10, ts),
+ new VirtualTagActor.DependencyValueChanged("b", 32, ts),
+ }));
+ parent.ExpectMsg().Value.ShouldBe(42);
+ var callsBefore = evaluator.Calls;
+
+ actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
+ {
+ new VirtualTagActor.DependencyValueChanged("a", null, ts, OpcUaStatus.BadNoCommunication),
+ new VirtualTagActor.DependencyValueChanged("b", 33, ts),
+ }));
+
+ parent.ExpectMsg(PresenceBudget)
+ .StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ evaluator.Calls.ShouldBe(callsBefore);
+ }
+
+ ///
+ /// Sums the dependency values, counts its invocations, and records the status code the actor
+ /// handed it for "a" — so a test can assert both that the script did NOT run and that, when it
+ /// did, it saw the input's real quality.
+ ///
+ private sealed class CountingSumEvaluator : IVirtualTagEvaluator
+ {
+ private int _calls;
+
+ public int Calls => Volatile.Read(ref _calls);
+
+ public uint? LastSeenStatus { get; private set; }
+
+ public VirtualTagEvalResult Evaluate(
+ string virtualTagId, string expression, IReadOnlyDictionary dependencies)
+ {
+ Interlocked.Increment(ref _calls);
+ return VirtualTagEvalResult.Ok(Sum(dependencies.Values));
+ }
+
+ public VirtualTagEvalResult Evaluate(
+ string virtualTagId, string expression, IReadOnlyDictionary dependencies)
+ {
+ Interlocked.Increment(ref _calls);
+ LastSeenStatus = dependencies.TryGetValue("a", out var a) ? a.StatusCode : null;
+ return VirtualTagEvalResult.Ok(Sum(dependencies.Values.Select(v => v.Value)));
+ }
+
+ private static object Sum(IEnumerable