fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)

H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 11:30:13 -04:00
parent 77c39bf02d
commit 2e0743ad25
34 changed files with 616 additions and 265 deletions
@@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-1");
condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull();
@@ -66,8 +66,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-2");
condition.ShouldNotBeNull();
@@ -91,8 +91,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-3");
condition.ShouldNotBeNull();
@@ -115,8 +115,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C");
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-c");
condition.ShouldNotBeNull();
condition!.OnConfirm.ShouldNotBeNull();
@@ -142,8 +142,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC");
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ac");
condition.ShouldNotBeNull();
condition!.OnAddComment.ShouldNotBeNull();
@@ -168,8 +168,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1");
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s1");
condition.ShouldNotBeNull();
condition!.OnShelve.ShouldNotBeNull();
@@ -196,8 +196,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2");
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s2");
condition.ShouldNotBeNull();
@@ -228,8 +228,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3");
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s3");
condition.ShouldNotBeNull();
@@ -254,8 +254,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4");
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s4");
condition.ShouldNotBeNull();
@@ -287,8 +287,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU");
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-tu");
condition.ShouldNotBeNull();
condition!.OnTimedUnshelve.ShouldNotBeNull();
@@ -322,8 +322,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1");
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed1");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
@@ -351,8 +351,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2");
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed2");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
@@ -379,8 +379,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3");
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed3");
condition.ShouldNotBeNull();
@@ -405,8 +405,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4");
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed4");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
@@ -436,8 +436,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1");
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak1");
condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull();
@@ -469,8 +469,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2");
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak2");
condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull();
@@ -503,8 +503,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4");
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak4");
condition.ShouldNotBeNull();
@@ -531,8 +531,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3");
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak3");
condition.ShouldNotBeNull();
@@ -555,8 +555,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var nm = server.NodeManager!;
// No router set (default null).
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR");
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nr");
condition.ShouldNotBeNull();
@@ -576,8 +576,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
@@ -591,8 +591,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a2").ShouldBeFalse();
@@ -611,15 +611,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
// RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
// before the same id can be re-materialised (ResolveParentFolder needs the parent back).
nm.RebuildAddressSpace();
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
@@ -636,13 +636,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
nm.RebuildAddressSpace();
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue();