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
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
{
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param>
/// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
/// routing: a raw <c>s=&lt;RawPath&gt;</c> and a UNS <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
/// operator write route to the WRONG driver ref.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task resolving to the device-write outcome.</returns>
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct);
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
}
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
public static readonly NullOpcUaNodeWriteGateway Instance = new();
private NullOpcUaNodeWriteGateway() { }
/// <inheritdoc />
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) =>
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
}
@@ -349,7 +349,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated();
@@ -386,7 +386,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
@@ -678,7 +678,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.</para>
/// </remarks>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1000,7 +1000,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
// task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
// re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
_ = gateway.WriteAsync(nodeKey, optimisticValue, CancellationToken.None)
_ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None)
.ContinueWith(
t =>
{
@@ -1206,7 +1206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
internal void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
string? clientUserId, AddressSpaceRealm realm)
{
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
AuditWriteUpdateEventState? auditEvent = null;
@@ -1398,7 +1398,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="folderNodeId">The node identifier of the folder.</param>
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1443,7 +1443,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1485,7 +1485,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1583,7 +1583,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType)
@@ -1712,7 +1712,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </para>
/// </summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(affectedNodeId);
GeneralModelChangeEventState e;
@@ -2004,7 +2004,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
EnsureAddressSpaceCreated();
@@ -2028,7 +2028,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
@@ -2053,7 +2053,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
EnsureAddressSpaceCreated();
@@ -30,7 +30,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm);
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
@@ -38,7 +38,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm);
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
@@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
}
/// <inheritdoc />
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct)
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{
var driverHost = _resolveDriverHost();
if (driverHost is null)
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
try
{
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>(
new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false);
new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false);
if (!result.Success)
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
return new NodeWriteOutcome(result.Success, result.Reason);
@@ -128,19 +128,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
/// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>materialised value NodeId (BARE <c>s=</c> id) →
/// (DriverInstanceId, RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS
/// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by
/// <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// <c>UnsReferenceVariables</c>, and resolved by <see cref="HandleRouteNodeWrite"/> so an inbound
/// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref
/// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id
/// (<c>node.NodeId.ToString()</c>); <see cref="HandleRouteNodeWrite"/> normalises it to the bare id
/// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it —
/// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible).
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
/// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
/// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
/// (the single value source). Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
/// composition's <c>RawTags</c> <c>UnsReferenceVariables</c>, and resolved by
/// <see cref="HandleRouteNodeWrite"/> so an inbound operator write to EITHER NodeId is forwarded to the
/// owning driver child as a write of its wire-ref RawPath.
/// <para><b>The realm is part of the key</b> (Wave B review H1): a raw <c>s=&lt;RawPath&gt;</c> and a UNS
/// <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare strings (folder/driver/device names and
/// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route
/// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full
/// ns-qualified id PLUS the realm it resolved (<c>RealmOf</c>); <see cref="HandleRouteNodeWrite"/>
/// normalises the id to the bare form and looks up <c>(realm, bareId)</c>, preserving exactly the
/// namespace disambiguation the ns-qualified id carries.</para>
/// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
new(StringComparer.Ordinal);
private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
@@ -241,7 +244,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary>
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
public sealed record RouteNodeWrite(string NodeId, object? Value);
public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm);
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
/// (or a gate/lookup failure that never reached the driver).</summary>
@@ -654,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary>
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
{
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
// is a separate follow-up.
_log.Debug(
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
_localNode, msg.DriverInstanceId);
return;
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
if (_lastComposition is null)
{
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
@@ -728,6 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
#pragma warning restore CS0162
}
/// <summary>
@@ -933,7 +952,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
// through the Raw realm.
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[nodeId] = key;
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
}
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
equipmentId, plan.Folders, plan.Variables));
@@ -1101,15 +1120,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return;
}
// v3 Batch 4: the node manager's write hook passes the FULL ns-qualified NodeId string
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>"). The routing map is keyed by the BARE s= identifier
// (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a
// referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing
// RawPath IS the raw node's, so there is no cross-realm ambiguity).
// v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>") PLUS the realm it resolved from the namespace index.
// The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look
// up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a
// bare string, so a write always routes to its OWN driver ref (never a colliding sibling's).
var bareNodeId = BareNodeId(msg.NodeId);
if (!_driverRefByNodeId.TryGetValue(bareNodeId, out var target))
if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target))
{
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}"));
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})"));
return;
}
@@ -1564,14 +1583,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[t.NodeId] = key;
_driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key;
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
{
foreach (var v in refs)
{
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
_driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref
// A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never
// collide with a raw NodeId that shares its bare string.
_driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key;
}
}
}
@@ -45,7 +45,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
@@ -57,7 +57,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
@@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
// Scripted alarms are per-equipment condition nodes in the UNS realm.
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: e.AlarmId,
State: ToSnapshot(e),
TimestampUtc: e.TimestampUtc));
TimestampUtc: e.TimestampUtc,
Realm: AddressSpaceRealm.Uns));
// Publish the transition to the cluster `alerts` topic — the single historization + live
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
@@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
///
/// <para>
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also
/// materialises against — so the value always lands on a NodeId that exists.
/// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
/// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
/// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
/// always lands on a NodeId that exists.
/// </para>
/// </summary>
public sealed class VirtualTagHostActor : ReceiveActor
@@ -192,8 +193,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
// 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.
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, result.Quality, result.TimestampUtc));
nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
// 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