fix(virtualtags): a disabled VirtualTag keeps its node and reads BadOutOfService (lmxopcua#787, owner decision)
v2-ci / unit-tests (push) Has been cancelled

Revises 963b0d66, which dropped a disabled VirtualTag from the runtime plan so a
bound client got BadNodeIdUnknown. Now the plan carries Enabled (part of value
equality): AddressSpaceComposer.Compose and DeploymentArtifact.BuildEquipmentVirtualTagPlans
keep the row with Enabled=false (byte-parity), the applier materialises the node as
normal, and VirtualTagHostActor spawns no VirtualTagActor for it but publishes
BadOutOfService (new OpcUaStatus constant) with a null value stamped at deploy, on
every apply. Enabled flips are node-irrelevant to the change classifier; enabled->disabled
stops the child, disabled->enabled resumes evaluation. Validator exemption and name
uniqueness unchanged. Scripted alarms keep their not-materialised convention.
Tests updated; new wire test DisabledVirtualTagWireTests. docs/VirtualTags.md updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 04:48:56 -05:00
parent 8582ce91bc
commit f201f16fd4
10 changed files with 238 additions and 30 deletions
@@ -1405,9 +1405,10 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|| string.IsNullOrWhiteSpace(name)) continue;
// Enabled (lmxopcua#787): a disabled VirtualTag is not deployed or evaluated — byte-parity with
// AddressSpaceComposer.Compose's `.Where(v => v.Enabled)`. Absent/non-bool => the entity default (true).
if (el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False) continue;
// Enabled (lmxopcua#787): a disabled VirtualTag keeps its plan (node materialised, never evaluated,
// BadOutOfService) — byte-parity with AddressSpaceComposer.Compose's `Enabled: v.Enabled`.
// Absent/non-bool => the entity default (true).
var enabled = !(el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False);
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
? src : string.Empty;
@@ -1442,7 +1443,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: historize,
ChangeTriggered: changeTriggered,
TimerIntervalMs: timerMs));
TimerIntervalMs: timerMs,
Enabled: enabled));
}
result.Sort((a, b) =>