using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
/// Custom OPC UA that owns the writable address space for
/// the OtOpcUa server. Variable nodes are created lazily on first
/// under the manager's namespace; subsequent writes update the existing node's Value +
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
/// ClearChangeMasks path.
///
/// This is the F10b production wiring behind the v2
/// seam — once a is bound, OpcUaPublishActor's writes
/// materialise as real OPC UA Variable updates that clients can browse + subscribe to.
///
/// Node-id encoding uses the manager's default namespace + the caller-supplied string id
/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). Beyond lazily-created flat
/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy
/// (), typed equipment-tag variables with the correct built-in
/// DataType / array shape / access levels (), historized
/// nodes (the HistoryRead access bit + HistoryRead overrides), and real Part 9
/// nodes (). The
/// AddressSpaceApplier drives these passes from the composed deployment artifact;
/// the legacy EquipmentNodeWalker server-side integration was retired in favour of the
/// (composer → applier → sink → node-manager) chain.
///
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
/// v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
/// registered with the SDK so nodes can be minted in either. is
/// registered FIRST (namespace index [0]) and
/// SECOND ([1]); maps a realm
/// to its index. Which realm a node belongs to travels with each sink call as an
/// — the namespace is never parsed back out of the id string.
private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri };
/// Transitional alias kept so the pre-v3 single-namespace integration tests
/// (SubscriptionSurvivalTests) still resolve a namespace index. It points at
/// — the same realm as the transitional default of every sink
/// method — so those tests keep resolving the namespace the applier's default-realm nodes live in. The two
/// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly.
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary _variables = new();
private readonly ConcurrentDictionary _folders = new();
private readonly ConcurrentDictionary _alarmConditions = new();
/// H6a: the subset of node ids materialised as NATIVE
/// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native
/// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager
/// must know which conditions are native. Maintained in lock-step with :
/// a native re-materialise adds, and clears it alongside
/// so a re-materialise as the other kind is correct.
private readonly HashSet _nativeAlarmNodeIds = new();
/// Gitea #641: every NATIVE condition's live state, captured by
/// just before it clears , waiting for
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
/// runs is discarded. Guarded by Lock.
private readonly Dictionary _rebuildCarryOver = new();
/// Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
/// event at (deferred until then so every notifier folder the
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
/// not reported. Guarded by Lock.
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
/// Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// .
private readonly ConcurrentDictionary _historizedTagnames = new();
/// Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
/// SCALAR tag (Modbus / S7 — DriverArrayWriteSupport.RefusesArrayValueOnScalarTag), so an array write to
/// one while it is scalar-shaped is refused inline. Every other scalar node keeps the pre-#610 behaviour (the
/// write is dispatched and the driver decides). Set by ; cleared alongside
/// .
private readonly ConcurrentDictionary _arrayOnScalarRefused = new();
/// Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual so can
/// pass the folder to RemoveRootNotifier on teardown.
private readonly Dictionary _notifierFolders = new();
/// v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
/// the equipment folders wired as EXTRA event-notifier roots for it (via ).
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
/// ReportEvent fans one event to every referencing equipment's UNS folder through the SDK notifier
/// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
/// (matches ' keys). On rebuild / condition-removal / equipment-subtree-removal
/// each wired pair is torn down bidirectionally (RemoveNotifier(bidirectional:true)) so an
/// inverse-notifier entry never leaks across redeploys. Guarded by the same Lock as
/// / .
private readonly Dictionary _alarmNotifierWiring = new();
/// Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to . The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
/// only when a real historian is wired at promotion time,
/// and the override resolves an inbound request's notifier NodeId
/// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on
/// .
private readonly ConcurrentDictionary _eventNotifierSources = new();
private FolderState? _root;
private readonly ILogger _logger;
/// Initializes a new instance of the class with the OPC UA server and configuration.
/// The OPC UA server instance.
/// The application configuration.
/// Logger for the manager's swallowed-fault and skipped-wiring diagnostics;
/// null (tests) falls back to .
public OtOpcUaNodeManager(
IServerInternal server, ApplicationConfiguration configuration, ILogger? logger = null)
: base(server, configuration, RegisteredNamespaceUris)
{
_logger = logger ?? NullLogger.Instance;
// SystemContext is initialised by the base ctor; both namespace URIs are now registered and their
// indexes captured (in the order passed) in NamespaceIndexes.
}
/// Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered
/// first ([0]), UNS second ([1]) — see .
/// The address-space realm.
/// The namespace index for the realm.
internal ushort NamespaceIndexForRealm(AddressSpaceRealm realm) => realm switch
{
AddressSpaceRealm.Raw => NamespaceIndexes[0],
AddressSpaceRealm.Uns => NamespaceIndexes[1],
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
};
/// Recover the realm of an already-resolved SDK from its namespace index —
/// used at the SDK-facing seams (inbound write, HistoryRead) where the request carries a full NodeId.
/// Any index that is not the Raw namespace is treated as UNS (the two registered indexes are the only
/// ones this manager mints into).
/// The resolved SDK node id.
/// The realm the node belongs to.
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
/// Zero-allocation realm-qualified map key. Replaces the formatted "ns=N;s=id" string key —
/// the (realm, id) pair is the same identity without the per-write NodeId + string allocations
/// (the id string is already in hand at every call site).
/// The realm the id lives in.
/// The bare s= identifier.
private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id);
/// Build the internal map key for a realm-qualified string id. The key pairs the realm with the
/// bare s= identifier so a Raw node and a UNS node that share the same identifier are distinct
/// keys — a bare id is not globally unique across the two namespaces. Matches
/// for the same node.
/// The realm the id lives in.
/// The bare s= identifier.
/// The realm-qualified map key.
private static RealmKey MapKey(AddressSpaceRealm realm, string id) => new(realm, id);
/// Build the internal map key for an already-resolved SDK (the SDK-facing
/// seams). Equal to for the same node. The SDK-facing
/// seams only ever pass nodes this manager minted (string s= identifiers); a non-string
/// identifier falls back to its string form rather than throwing — and says so loudly, because such a
/// key cannot match anything the manager stored, so the lookup would silently miss.
/// The resolved SDK node id.
/// The realm-qualified map key.
private RealmKey MapKey(NodeId nodeId)
{
if (nodeId.Identifier is string s) return new RealmKey(RealmOf(nodeId), s);
// Unreachable by construction — every call site passes a node this manager minted with a string
// `s=` identifier. If it ever fires, the derived key resolves to nothing and the caller sees an
// unexplained miss, so name the seam rather than collapsing quietly.
_logger.LogWarning(
"OtOpcUaNodeManager.MapKey: non-string NodeId identifier reached the map-key seam (node {NodeId}, identifier type {IdentifierType}) — the derived key will not resolve",
nodeId, nodeId.Identifier?.GetType().Name ?? "null");
return new RealmKey(RealmOf(nodeId), nodeId.Identifier?.ToString() ?? string.Empty);
}
/// Gets the count of variable nodes currently managed.
public int VariableCount => _variables.Count;
/// Gets the count of folder nodes currently managed.
public int FolderCount => _folders.Count;
/// Gets the count of real Part 9 nodes currently managed.
public int AlarmConditionCount => _alarmConditions.Count;
///
/// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
/// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
/// handler (wired in ) gates on the caller's
/// AlarmAck role and, when allowed, builds an and invokes this
/// delegate. The host sets it at boot to a non-blocking mediator.Tell onto the
/// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it.
///
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
/// (no Akka / IActorRef / DI handle). The handler
/// delegates run under the manager's Lock; the invoked action MUST be non-blocking
/// (a fire-and-forget Tell) so there is no deadlock. Null (the default) makes every
/// handler a safe no-op — it still gates + returns, just routes nowhere.
///
///
public Action? AlarmCommandRouter { get; set; }
///
/// H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy)
/// Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client
/// Acknowledges one, the condition's OnAcknowledge handler (wired in
/// ) branches on and — after
/// the same AlarmAck role gate as — invokes THIS delegate
/// with a instead of routing an to the
/// scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver
/// (a later task wires the driver linkage).
///
/// Like , the handler delegate runs under the manager's
/// Lock, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default)
/// makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere.
/// Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a
/// native condition stay on the scripted path (H6c scope is the
/// Acknowledge → driver path only).
///
///
public Action? NativeAlarmAckRouter { get; set; }
///
/// Gitea #597 — whether this node may accept an OPC UA Acknowledge of a NATIVE (driver-fed) condition
/// right now: the redundancy data-plane verdict (PrimaryGatePolicy.ShouldServiceAsPrimary, as
/// published to IDriverPrimaryRoleSource) — the same verdict DriverHostActor gates the
/// upstream ack route and device writes on.
///
/// false ⇒ the Acknowledge is REFUSED with BadInvalidState before anything is routed or
/// applied. Without this the Secondary applied and reported the ack locally while
/// DriverHostActor dropped the route upstream, so the driver never acknowledged and its next
/// transition reverted the node — the client saw acked → unacked. Scripted conditions are not gated:
/// their engine runs on both nodes, so an ack against the Secondary is real.
///
///
/// Read under Lock on the SDK's Call thread, so it MUST be a cheap, non-blocking read (the
/// host passes a volatile-backed property). Null (the default) allows — the single-node posture, the
/// same default UnmanagedDriverPrimaryRoleSource answers with.
///
///
public Func? NativeAlarmAckPrimaryGate { get; set; }
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
///
/// Reverse-path gateway for inbound OPC UA operator writes to a writable equipment-tag variable node.
/// When a client writes such a node, the node's
/// handler (, attached by when the
/// variable is writable) first gates on the caller's
/// role and, when allowed, calls with the node's
/// string id + the written value to route the write to the backing driver.
///
/// This is the write-side twin of ; the gateway abstraction keeps
/// this assembly Akka-free (the host wires an ActorNodeWriteGateway that Asks the local
/// DriverHostActor). The handler delegates run under the node-manager Lock (the OPC
/// UA SDK's CustomNodeManager2.Write holds Lock while invoking OnWriteValue),
/// so the dispatch is FIRE-AND-FORGET — the handler kicks off WriteAsync and returns
/// Good immediately so the SDK applies the client value optimistically; it MUST NOT block
/// on the device round-trip. When the asynchronous comes back
/// FAILED, an off-Lock continuation self-corrects: it re-takes Lock and reverts the node to
/// its real pre-write value — but only while the node still holds the optimistic value, so a fresh
/// driver poll that has already moved the node on is not clobbered (see
/// / ).
///
///
/// Set by the host at StartAsync; the default
/// (assigning null restores it) makes every write resolve to a "writes unavailable"
/// failure. Backed by a volatile field (auto-properties can't be volatile) to make the
/// startup-write / SDK-thread-read explicit: the host assigns it once at boot on the start thread
/// and the SDK reads it on Write request threads.
///
///
public IOpcUaNodeWriteGateway NodeWriteGateway
{
get => _nodeWriteGateway;
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
}
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
///
/// Server-side read backend for the OPC UA HistoryRead service over historized variable nodes.
/// When a client issues a HistoryRead (Raw / Processed / AtTime) against a node materialised
/// Historizing (a tag with registered), the
/// HistoryRead override resolves the node's NodeId to its historian tagname and dispatches to
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
/// independent of any driver's lifecycle.
///
/// Set by the Host at StartAsync. The
/// default (assigning null restores it) means "no historian wired" → every read
/// returns empty, so a historized node's HistoryRead surfaces GoodNoData rather than
/// faulting. Backed by a volatile field (auto-properties can't be volatile) to make
/// the startup-write / SDK-read-thread handoff explicit: the Host assigns it once at boot on
/// the start thread and the SDK reads it on HistoryRead request threads. Unlike
/// , the HistoryRead override does NOT run under the
/// node-manager Lock, so the override may block-bridge to this (async) source.
///
///
public IHistorianDataSource HistorianDataSource
{
get => _historianDataSource;
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
}
///
/// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves,
/// the generation those were resolved at, and the ACL grants themselves.
///
///
///
/// This closes the fourth #520 blocker: NodeScope.ClusterId is required and
/// TriePermissionEvaluator fails closed when it disagrees with the session's, but the
/// node manager had no idea what cluster it was serving — so no NodeScope it built
/// could ever have evaluated. It is fed here rather than at construction because the SDK
/// constructs this manager during StartAsync, before any artifact has been applied,
/// and the value changes on every deploy.
///
///
/// Stage 4 only supplies it. The enforcement seams that consume it — the write gate, the alarm
/// gates, HistoryRead, and (stage 6) read/browse/subscribe — land in stages 5 and 6, so
/// nothing reads this yet and authorization behaviour is unchanged.
///
///
/// Backed by a volatile field for the same reason as :
/// written on the deploy path, read on SDK request threads. Assigning null restores the
/// empty source, which authenticates and authorizes nothing.
///
///
public IDataPlaneAuthoritySource DataPlaneAuthority
{
get => Volatile.Read(ref _dataPlaneAuthority);
set
{
var next = value ?? EmptyDataPlaneAuthoritySource.Instance;
var previous = Interlocked.Exchange(ref _dataPlaneAuthority, next);
if (ReferenceEquals(previous, next)) return;
// ⚠️ The DETACH is load-bearing, for the same reason it is on IRediscoverable: this manager is
// rebuilt across the server's lifetime while the authority source is a process singleton that
// outlives it, so a missing -= leaks a dead node manager per rebuild AND has every stale one
// walking its own dead item map on each deploy.
previous.Changed -= OnAuthorityChanged;
next.Changed += OnAuthorityChanged;
}
}
// Not `volatile`: the setter swaps it with Interlocked.Exchange (which cannot take a volatile ref), and
// the getter reads it with Volatile.Read, so both accesses are ordered explicitly.
private IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
///
/// Re-authorizes live subscriptions when a deployment installs a new authorization generation.
///
///
///
/// There are TWO triggers (arch-review 03/S19): this one, the deployment axis; and the
/// host's low-frequency AclRevalidationTimer, the clock axis — a key's
/// ExpiresUtc passes with the clock, not with a deployment, so a deploy-only trigger
/// left an expired key streaming to a live subscription indefinitely on a fleet that had been
/// deployed once and left running.
///
///
/// ⚠️ The second trigger is safe only because the memo is pass-local.
/// memoizes key revocation and expiry for the span
/// of one pass, which is sound because a pass is a momentary event whose verdicts cannot go
/// stale within it — its dictionary is constructed per call. Hoisting it across ticks, to save
/// the walk, would serve an expiry that landed between two ticks the earlier tick's allow, and
/// re-open exactly the gap the timer closes.
///
///
/// The newly published snapshot; unused — the current state is re-read per item.
private void OnAuthorityChanged(DataPlaneAuthoritySnapshot snapshot) => RevalidateMonitoredItems();
///
///
/// Detaches from the authority source. The source is a process singleton that outlives this manager,
/// so without this a disposed manager stays subscribed and re-walks its dead item map on every
/// subsequent deploy — the same leak shape the setter's swap guards against.
///
protected override void Dispose(bool disposing)
{
if (disposing)
{
Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged;
_liveItems.Clear();
_eventItems.Clear();
_allEventsItems.Clear();
lock (Lock) _allEventsMonitoredConditions.Clear();
}
base.Dispose(disposing);
}
///
/// The node-scoped ACL gate (#520 stage 5), or null when none is wired. Consulted by the
/// write, alarm-command and HistoryRead seams only while
/// is true; otherwise each keeps its
/// server-wide role check and behaviour is identical to before this stage — with one exception: the
/// two role gates consult in either
/// mode, so an expired or revoked key loses its activation-time roles (Gitea #563).
///
///
/// Null means "not wired" and is treated as not-enabled, not as deny-all. That looks like the wrong
/// direction for a security control, but the alternative denies every write on any node that has
/// not been given an authorizer — including every existing test host and the admin-only nodes — to
/// protect a feature that is off by default. The real fail-closed behaviour lives one level in:
/// once an authorizer IS wired and enabled, every unresolvable case denies.
///
public DataPlaneAuthorizer? Authorizer
{
get => _authorizer;
set => _authorizer = value;
}
private volatile DataPlaneAuthorizer? _authorizer;
///
/// True when a wired authorizer is switched on — i.e. when ACL grants, rather than the server-wide
/// role set, decide the gated operations.
///
private bool AclEnforced => _authorizer is { Enabled: true };
///
/// The upper bound on the bounded over-fetch uses to page WITHIN an
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
/// cap. When a resume read stalls on such a cluster (the boundary-tie trim empties the page), the
/// paging over-fetches up to MaxTieClusterOverfetch + 1 ties at that single timestamp (a
/// start == end read) and slices through them via .
/// A cluster strictly larger than this still surfaces BadHistoryOperationUnsupported for that
/// node (the absurd-burst backstop). Mirrors the configured
/// ServerHistorianOptions.MaxTieClusterOverfetch; the Host sets it at StartAsync. The
/// default (65536) survives when the historian section is absent.
///
public int MaxTieClusterOverfetch { get; set; } = 65536;
///
/// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3).
/// Mirrors ServerHistorianOptions.HistoryReadBatchParallelism; the Host sets it at
/// StartAsync. ≤ 1 falls back to sequential serving. The default (4) survives when the
/// ServerHistorian section is absent.
///
public int HistoryReadBatchParallelism { get; set; } = 4;
///
/// Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that
/// cannot acquire a slot within its (bounded) wait budget fast-fails every handle with
/// BadTooManyOperations. Mirrors ServerHistorianOptions.MaxConcurrentHistoryReadBatches;
/// the Host sets it at StartAsync. The default (16) survives when the section is absent.
///
public int MaxConcurrentHistoryReadBatches { get; set; } = 16;
///
/// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in
/// flight at the deadline surfaces BadTimeout. Non-positive = unbounded. Mirrors
/// ServerHistorianOptions.HistoryReadDeadline; the Host sets it at StartAsync. The
/// default (60 s) survives when the section is absent.
///
public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60);
/// Lazily-created process-wide HistoryRead-batch limiter (see ).
private volatile SemaphoreSlim? _historyReadBatchLimiter;
private readonly object _historyReadBatchLimiterLock = new();
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
///
/// The store that holds the server-side resume state behind an opaque HistoryRead continuation
/// point for the count-capped variable-history arms (Raw / Processed). The default
/// binds points to the OPC UA session — so they are
/// capped (ServerConfiguration.MaxHistoryContinuationPoints, SDK default 100, oldest-evicted)
/// and disposed when the session closes. Exposed (internal) so the session-less in-process tests can
/// inject an and exercise the full multi-page round
/// trip through the same dispatch path. Assigning null restores the session-backed default.
///
internal IHistoryContinuationStore HistoryContinuationStore
{
get => _historyContinuationStore;
set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore();
}
/// Look up a materialised Part 9 alarm-condition node by its alarm node id (the
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.
/// The alarm node identifier (== ScriptedAlarmId).
/// The cached , or null when none is registered.
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null;
/// Phase C: look up the resolved historian tagname registered for a historized variable
/// node, or null when the node is not historized. The (later) HistoryRead override resolves an
/// inbound HistoryRead request's NodeId against this map. Exposed for tests + the override.
/// The variable node identifier.
/// The resolved historian tagname when historized; otherwise null.
/// True when the node is registered as historized; otherwise false.
public bool TryGetHistorizedTagname(string nodeId, out string? tagname, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (_historizedTagnames.TryGetValue(MapKey(realm, nodeId), out var t)) { tagname = t; return true; }
tagname = null;
return false;
}
/// NodeId-typed overload used by the HistoryRead override, which holds a request's full
/// (namespace-qualified) NodeId — resolves the historian tagname without recovering the realm first.
/// A historized UNS reference node and its backing raw node register the SAME tagname under distinct
/// NodeIds, so a read against either resolves the one series.
/// The resolved SDK node id from the HistoryRead request.
/// The resolved historian tagname when historized; otherwise null.
/// True when the node is registered as historized; otherwise false.
private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname)
{
if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; }
tagname = null;
return false;
}
/// Look up a materialised variable node by its NodeId string, or null if not present.
/// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes.
/// The variable node identifier.
/// The cached , or null when none is registered.
internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null;
/// Look up a materialised folder node by its NodeId string, or null if not present.
/// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier
/// node a HistoryReadEvents request targets).
/// The folder node identifier.
/// The cached , or null when none is registered.
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
/// Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised alarm condition — for a native alarm this is the number of
/// referencing equipment folders wired via . Zero when the condition is
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
/// across redeploys.
/// The alarm condition node identifier.
/// The realm the condition lives in (Raw for native alarms).
/// The number of notifier entries on the condition.
internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
{
lock (Lock)
{
if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
var list = new List();
alarm.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised folder — for an equipment folder wired as an alarm notifier
/// this counts the inverse links back to its condition(s). Zero when the folder is absent.
/// The folder node identifier.
/// The realm the folder lives in (Uns for equipment folders).
/// The number of notifier entries on the folder.
internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
var list = new List();
folder.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// Test/diagnostic accessor (v3 Batch 4 WP4): true when is
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
/// and not yet torn down.
/// The folder node identifier.
/// The realm the folder lives in.
/// True when the folder is a registered root notifier.
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
&& _notifierFolders.ContainsKey(folder.NodeId);
}
///
/// Apply a value write from . Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
/// SourceTimestamp and call ClearChangeMasks so subscribed clients see the change.
///
/// The node identifier of the variable.
/// The new value to write.
/// The full OPC UA status code, sub-code included (#519 — passed to the SDK verbatim).
/// The timestamp of the value in UTC.
/// Which of the two v3 namespaces the lives in; the
/// namespace index is resolved from this rather than parsed out of the id string.
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, nodeId);
lock (Lock)
WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc);
}
/// The most items applies under a SINGLE Lock acquisition before
/// releasing and re-taking it.
/// WHY a cap at all: the lock take is what batching amortises, and the amortisation curve is
/// brutally front-loaded — going from one take per value to one per 512 already removes >99.8% of the
/// takes, so an unbounded hold buys essentially nothing more. What it DOES buy is a stall: the manager-wide
/// Lock is the same lock every SDK service thread (Read / Browse / subscription publish /
/// ConditionRefresh) contends for, and the first poll cycle force-raises EVERY tag (the initial-data push),
/// so batch #1 is a whole device × its UNS fan-out — thousands of CreateVariable calls, each mutating
/// the SDK address space, under one acquisition. 512 is therefore "effectively unbounded for the win,
/// bounded for the stall": chunking hands the SDK a scheduling point between chunks.
private const int MaxWritesPerLockAcquisition = 512;
/// Batched : one Lock acquisition per chunk of
/// items instead of one per value — the publish path's per-value
/// lock take was the steady-state throughput ceiling. Per-item semantics are identical to
/// (create-on-first-write, #519 verbatim status codes, ClearChangeMasks per node).
/// Items are isolated. A bad item (empty NodeId, or a throw out of the SDK) does not abandon
/// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as
/// a . Dropping the tail would be silent and durable — the poll
/// engine updates its last-value cache BEFORE raising, so a dropped item is not re-raised until that tag's
/// value changes again, leaving the node stale indefinitely.
/// The value writes to apply, in order.
/// When one or more items failed. Every other item HAS been
/// applied; the exception carries the first failing NodeId plus the failed/succeeded split so the caller
/// can log and meter honestly.
public void WriteValues(IReadOnlyList values)
{
ArgumentNullException.ThrowIfNull(values);
// Guard BEFORE the empty-batch early-return, so a pre-start call fails the same legible way every
// other mutator does regardless of what it happens to carry.
EnsureAddressSpaceCreated();
if (values.Count == 0) return;
// Pre-pass, OUTSIDE the lock: validate each item and resolve its namespace index + map key into a
// scratch array. Both jobs belong here — validating outside keeps a malformed item from ever reaching
// the SDK, and resolving outside moves the MapKey string allocation (a NodeId + ToString per item)
// off the critical section entirely.
var resolved = new ResolvedWrite[values.Count];
string? firstFailedNodeId = null;
Exception? firstFailure = null;
var failed = 0;
for (var i = 0; i < values.Count; i++)
{
var nodeId = values[i].NodeId;
if (string.IsNullOrEmpty(nodeId))
{
failed++;
firstFailedNodeId ??= nodeId ?? string.Empty;
firstFailure ??= new ArgumentException(
$"WriteValues item {i} has a null or empty NodeId.", nameof(values));
continue;
}
resolved[i] = new ResolvedWrite(
nodeId, NamespaceIndexForRealm(values[i].Realm), MapKey(values[i].Realm, nodeId), IsValid: true);
}
var succeeded = 0;
for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition)
{
var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count);
lock (Lock)
{
for (var i = start; i < end; i++)
{
if (!resolved[i].IsValid) continue;
var v = values[i];
try
{
WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key,
v.Value, v.StatusCode, v.SourceTimestampUtc);
succeeded++;
}
catch (Exception ex)
{
failed++;
firstFailedNodeId ??= v.NodeId;
firstFailure ??= ex;
}
}
}
}
if (failed > 0)
{
throw new ValueWriteBatchException(
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
}
}
/// Apply a batch of status-only writes: each node keeps its CURRENT value and only its
/// StatusCode + Timestamp change (archreview 03/S15 — the comms-loss annotation for value variables).
/// Same shape as : an outside-the-lock validate/resolve pre-pass, then
/// -sized chunks under one Lock each, per-item isolation,
/// and a aggregate.
/// It never creates a node. A status carries nothing to create one from, and the caller's
/// per-driver ref index can legitimately name a node a surgical remove has just dropped — so an unknown
/// id is skipped, not materialised, and is not counted as a failure either (it is an expected race, not
/// a fault).
/// The status writes to apply, in order.
/// When one or more items failed validation or threw. Every
/// other item HAS been applied.
public void WriteStatuses(IReadOnlyList statuses)
{
ArgumentNullException.ThrowIfNull(statuses);
EnsureAddressSpaceCreated();
if (statuses.Count == 0) return;
var resolved = new ResolvedWrite[statuses.Count];
string? firstFailedNodeId = null;
Exception? firstFailure = null;
var failed = 0;
for (var i = 0; i < statuses.Count; i++)
{
var nodeId = statuses[i].NodeId;
if (string.IsNullOrEmpty(nodeId))
{
failed++;
firstFailedNodeId ??= nodeId ?? string.Empty;
firstFailure ??= new ArgumentException(
$"WriteStatuses item {i} has a null or empty NodeId.", nameof(statuses));
continue;
}
resolved[i] = new ResolvedWrite(
nodeId, NamespaceIndexForRealm(statuses[i].Realm), MapKey(statuses[i].Realm, nodeId), IsValid: true);
}
var succeeded = 0;
var skipped = 0;
for (var start = 0; start < statuses.Count; start += MaxWritesPerLockAcquisition)
{
var end = Math.Min(start + MaxWritesPerLockAcquisition, statuses.Count);
lock (Lock)
{
for (var i = start; i < end; i++)
{
if (!resolved[i].IsValid) continue;
var s = statuses[i];
try
{
if (WriteStatusLocked(resolved[i].Key, s.StatusCode, s.SourceTimestampUtc)) succeeded++;
else skipped++;
}
catch (Exception ex)
{
failed++;
firstFailedNodeId ??= s.NodeId;
firstFailure ??= ex;
}
}
}
}
if (skipped > 0)
{
// Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver
// whose nodes were all just removed would otherwise log once per ref.
_logger.LogDebug(
"OtOpcUaNodeManager: WriteStatuses skipped {Skipped} of {Total} item(s) — node not materialised.",
skipped,
statuses.Count);
}
if (failed > 0)
{
throw new ValueWriteBatchException(
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
}
}
/// The body of a single status-only write. Caller MUST hold Lock.
/// The realm-qualified map key.
/// The full OPC UA status code, sub-code included.
/// The timestamp of the annotation in UTC.
/// when the node existed and was stamped; when
/// no such node is materialised (skipped — deliberately not a create; see ).
private bool WriteStatusLocked(RealmKey key, uint statusCode, DateTime sourceTimestampUtc)
{
if (!_variables.TryGetValue(key, out var variable)) return false;
// Value deliberately untouched — that IS the feature: the node keeps its last known value under a
// Bad status (OPC UA Part 8's last-known-value convention).
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
return true;
}
/// One item after the outside-the-lock pre-pass: its namespace index and
/// map key already resolved, or false when the item failed validation.
/// The item's bare s= identifier.
/// The namespace index resolved from the item's realm.
/// The realm-qualified map key.
/// False when the item failed the pre-pass and must be skipped.
private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid);
/// The body of a single value write. Caller MUST hold Lock — this is factored out
/// solely so can apply a whole batch under one acquisition; it is byte-identical
/// to what has always done inside its own lock.
/// The bare s= identifier of the variable.
/// The namespace index resolved from the write's realm.
/// The realm-qualified map key for .
/// The new value to write.
/// The full OPC UA status code, sub-code included.
/// The timestamp of the value in UTC.
private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc)
{
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(nodeId, ns);
_variables[key] = variable;
}
variable.Value = value;
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
// Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change.
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
///
/// Apply a full Part 9 alarm-condition write. When a real has
/// been materialised for (via ),
/// this projects the whole snapshot
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
/// node and recomputes Retain (T15 — richer state; still no event firing, that lands in T16).
/// Otherwise it falls back to the legacy two-element [Active, Acknowledged]
/// placeholder so callers whose alarm node hasn't been
/// materialised (and the existing unit tests) keep working.
///
/// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).
/// The full condition state to project onto the node.
/// The timestamp of the alarm state change in UTC.
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, alarmNodeId);
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
lock (Lock)
{
if (_alarmConditions.TryGetValue(key, out var condition))
{
// T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
// SDK mutated AckedState, then WireMethodTransitionReporting reported the one event for it —
// Gitea #587) NEVER passed through this method, so a last-written cache would be stale and
// wrongly report a delta. By the time the engine re-projects that ack here, the node already
// holds the acked state → no delta → suppress.
var current = ReadConditionDelta(condition);
var incoming = ToConditionDelta(state, condition);
bool fire = ShouldFireConditionEvent(current, incoming);
// EnabledState / AckedState / ActiveState are mandatory children — always present after
// Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
// that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
// future SDK that builds a leaner child set) may leave them null. Null-guard each optional
// child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
// no-op rather than an NRE.
condition.SetEnableState(SystemContext, state.Enabled);
condition.SetActiveState(SystemContext, state.Active);
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (condition.ConfirmedState is not null)
{
condition.SetConfirmedState(SystemContext, state.Confirmed);
}
if (condition.ShelvingState is not null)
{
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
// arms does nothing (OnTimedUnshelve is a no-op): the engine's own expiry re-projects.
condition.SetShelvingState(
SystemContext,
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0);
}
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
// #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
// it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
// null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
if (condition.Quality is not null)
{
condition.Quality.Value = new StatusCode(state.StatusCode);
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
}
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// ConditionRefresh replays it. The event firing below also depends on this Retain being
// correct (a non-retained inactive+acked condition still fires its transition event, but
// won't be replayed on a later ConditionRefresh).
condition.Retain.Value = state.Active || !state.Acknowledged;
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
// T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
// change (the delta-gate decided above, against the node's pre-projection state). A
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
// of a client method call already applied (and already reported, by the method path —
// WireMethodTransitionReporting) equals the node's current state → no delta → suppress, so
// the transition is reported exactly once. ReportConditionEvent
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
if (fire)
{
ReportConditionEvent(condition, sourceTimestampUtc);
}
return;
}
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
// un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
// the SDK address space, so it MUST run under Lock (see WriteValue).
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(alarmNodeId, ns);
_variables[key] = variable;
}
variable.Value = new[] { state.Active, state.Acknowledged };
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
///
/// Gitea #597 — the shelvingTime (ms) to hand SetShelvingState for a timed shelve expiring at
/// . 0 when the expiry is unknown (the SDK then uses
/// MaxTimeShelved, the pre-#597 behaviour); otherwise the remaining time, clamped to at least 1 ms
/// (an already-passed expiry must not read as "unknown" — the engine's own expiry is about to land) and to
/// at most int.MaxValue (the SDK timer's range).
///
/// The engine's expiry instant, or null.
/// The shelving time in milliseconds.
internal static double RemainingShelveMs(DateTime? unshelveAtUtc)
{
if (unshelveAtUtc is not { } at) return 0;
var remaining = (at - DateTime.UtcNow).TotalMilliseconds;
return Math.Clamp(Math.Ceiling(remaining), 1, int.MaxValue);
}
///
/// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
/// from any alarm transition. Used by the driver-connectivity path (comms lost → BadNoCommunication,
/// restored → Good) so a native condition whose device is unreachable stops reporting the
/// accidentally-Good default. This sets ONLY — it never touches
/// Active / Acked / Severity / Retain (a comms-lost active alarm must stay active). A change in the
/// status code is a genuine Part 9 condition change, so it fires one condition event carrying the
/// new Quality; an unchanged code suppresses (no spurious event). Unknown / unmaterialised node ⇒
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
/// #519 changed the fire test from bucket to full code. While the annotation was a
/// 3-state enum, "the bucket moved" and "the value changed" were the same predicate. Now that the
/// sub-code survives, a move from one Bad reason to another Bad reason genuinely changes what a
/// client reads, so it fires — suppressing it would leave subscribers holding a stale reason.
///
/// The condition node id (RawPath for a native alarm).
/// The source-data status code to annotate, sub-code included.
/// Timestamp of the connectivity transition in UTC.
/// The condition's address-space realm.
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, alarmNodeId);
lock (Lock)
{
// Only materialised conditions carry a Quality child; a bare value variable or a missing node is
// a no-op (the connectivity fan-out visits every condition the driver owns, some of which a
// concurrent rebuild may have just cleared).
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
return;
var newCode = new StatusCode(statusCode);
var changed = condition.Quality.Value.Code != newCode.Code;
condition.Quality.Value = newCode;
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
// Fire ONLY on a real change so a steady-state connectivity re-assert doesn't spam events.
if (changed)
{
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
ReportConditionEvent(condition, sourceTimestampUtc);
}
}
}
///
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised . The caller MUST already hold Lock and
/// have applied the new state via the Set* projection — this stamps a fresh per-event
/// EventId, ClearChangeMasks, then ReportEvent with an
/// (a frozen copy of the condition's children at fire time,
/// so a subscribing client sees the values at this instant even if the live node mutates after).
///
/// A fresh EventId per event is a Part 9 requirement: inbound Acknowledge / Confirm /
/// AddComment calls are correlated back to a specific event by this id (the SDK matches it via
/// GetEventByEventId / GetBranch), so T17's ack routing relies on it being unique
/// per emission. We use the main branch only (BranchId == NodeId.Null, set at
/// materialise) — no branch creation here.
///
///
/// Method-driven transitions (Gitea #587). An inbound client Acknowledge / Confirm /
/// AddComment / shelve / Enable / Disable goes through the SDK's own handler, which (after T18's
/// gate returns Good) applies the new state to the node, BYPASSING .
/// The SDK reports no event for it — its ReportStateChange is gated on
/// AutoReportStateChanges, which stays false — so the method path calls this method itself,
/// once, after the handler returns (). The engine then
/// re-projects that same transition through , whose delta-gate
/// compares against the NODE's CURRENT state, finds no change, and suppresses a second event. (An
/// earlier comment here claimed the SDK "auto-fires its own event (E2)"; it never did, so a client
/// ack produced no event at all.) Genuine engine-driven transitions still differ from the node's
/// current state, so they fire here as before.
///
///
/// The materialised condition whose new state has already been projected; must be non-null.
/// The source/receive timestamp (UTC) for this event.
private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
{
// Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
alarm.EventId.Value = Guid.NewGuid().ToByteArray();
// Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection
// immediately above; the assignment here is a locality repeat (same value, no behavioral change)
// so the restamp is co-located with the EventId and ClearChangeMasks in the same method.
alarm.Time.Value = ts;
alarm.ReceiveTime.Value = ts;
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
// the InstanceStateSnapshot captures the just-projected values.
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
try
{
// InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
// time. ReportEvent walks inverse notifier references up to the root-notifier folders (promoted
// in MaterialiseAlarmCondition), whose fan-outs deliver it to direct notifier subscriptions; the
// condition's own OnReportEvent hands the same snapshot to the Server-object (all-events)
// subscriptions, per item, through the ACL (Gitea #586 — see OnConditionReportEvent).
var snapshot = new InstanceStateSnapshot();
snapshot.Initialize(SystemContext, alarm);
alarm.ReportEvent(SystemContext, snapshot);
}
catch (Exception ex)
{
// A failed event report must NOT break the state projection or the calling actor: the node's
// state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
// change; only the event delivery is lost. Log through the injected ILogger instead of
// swallowing silently — a recurring failure here is then visible in the server log rather than
// invisible. T19's live Client.CLI run is the integration proof that the happy path delivers.
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", alarm.NodeId);
}
}
///
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
/// condition event AND that an can change. As a record, two
/// instances compare by value, so is a plain inequality.
///
/// Severity is stored as the value the node holds after SetSeverity — the exact
/// Part 9 severity clamped to 1..1000 (; it used to be one of five
/// buckets, see there) — so a severity change fires an event. The
/// Shelving kind is read back from / mapped to the SDK's shelving state machine so the
/// live node and the snapshot compare on the same 3-way (Unshelved/OneShot/Timed) axis.
///
///
/// Why CommentAdded is not a field here (intentional).
/// EmissionKind.CommentAdded is produced only by
/// Part9StateMachine.ApplyAddComment, which is reached only via
/// ScriptedAlarmEngine.AddCommentAsync, which is called only from
/// ScriptedAlarmHostActor's inbound AlarmCommand handler — meaning
/// CommentAdded ALWAYS originates from a client calling the condition's
/// AddComment method. On that path T18's OnAddComment delegate returns
/// ServiceResult.Good, the OPC UA SDK applies the comment to the node, and the method
/// path reports the one Part 9 comment event (,
/// Gitea #587) — BEFORE the engine re-projects via . When that
/// re-projection arrives here, the delta-gate sees no change in any compared field (the snapshot
/// carries no comments list) and correctly suppresses a second event. Force-firing for
/// CommentAdded would double-emit. There is no engine-internal or script-driven
/// comment path, so suppression never drops a needed event.
///
///
/// Why Retain is absent (intentional — safe today).
/// Retain is projected as state.Active || !state.Acknowledged in
/// . Every path that flips Retain necessarily
/// changes Active or Acknowledged (both ARE compared fields), so a
/// Retain flip always rides along with a real delta and fires correctly. If a
/// future engine were to set Retain independently — without touching
/// Active/Acknowledged — it would need to be added here.
///
///
internal readonly record struct AlarmConditionDelta(
bool Active,
bool Acknowledged,
bool Confirmed,
bool Enabled,
AlarmShelvingKind Shelving,
ushort MappedSeverity,
string Message,
// #477 — the source-data quality bucket. Included so a quality-only transition (e.g. a device
// going comms-lost: Good→Bad with no state change) is a genuine delta and fires a Part 9 event.
// StatusCode has value equality, so the record struct's == still holds.
StatusCode Quality);
/// Decide whether a projection is a genuine state change
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
/// incoming snapshot. Pure + value-based so it's unit-testable in isolation: returns true iff
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
/// == ⇒ false (suppress the re-projected
/// double-emit); a genuine engine-driven transition differs ⇒ true (fire).
/// The node's current (pre-projection) gate-relevant state.
/// The incoming snapshot's gate-relevant state.
/// true to fire a condition event; false to suppress (no delta).
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
current != incoming;
/// Read the gate-relevant slice off the LIVE condition node. Mandatory children
/// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
/// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-clamped
/// value the node stores, and shelving is mapped from the shelving
/// state machine's CurrentState so it lines up with .
private static AlarmConditionDelta ReadConditionDelta(AlarmConditionState condition) => new(
Active: condition.ActiveState?.Id?.Value ?? false,
Acknowledged: condition.AckedState?.Id?.Value ?? true,
Confirmed: condition.ConfirmedState?.Id?.Value ?? true,
Enabled: condition.EnabledState?.Id?.Value ?? true,
Shelving: ReadShelvingKind(condition),
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
Message: condition.Message?.Value?.Text ?? string.Empty,
// Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
// and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
Quality: condition.Quality?.Value ?? StatusCodes.Good);
/// Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
/// the node stores in a derived form: Severity is run through so it matches
/// the value the node holds (the projection calls SetSeverity(ToPart9Severity(...))), and an
/// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
/// node's read-back default so it never spuriously registers as a delta.
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
Active: state.Active,
Acknowledged: state.Acknowledged,
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
Enabled: state.Enabled,
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
// node's read-back default (Unshelved).
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
MappedSeverity: (ushort)ToPart9Severity(state.Severity),
Message: state.Message ?? string.Empty,
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
// node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
Quality: condition.Quality is not null ? new StatusCode(state.StatusCode) : StatusCodes.Good);
/// Map the live shelving state machine's CurrentState back to our 3-way
/// by matching its well-known Part 9 state object id. Any node without
/// a shelving sub-state machine (or an unrecognised/unset state) reads as
/// — the same value folds an
/// unsupported shelving snapshot to, so the two stay comparable.
private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
{
var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
return AlarmShelvingKind.Unshelved;
}
///
/// Materialise a real OPC UA Part 9 node under its equipment
/// folder so clients can browse it as a proper condition (and subscribe to its events). The node
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
/// calls — which target that same id — update this node.
///
/// This is the T14 production replacement for the bool[2] placeholder: it creates
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
/// no events itself.
///
/// Idempotent: a second call with the same tears down the prior
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
///
/// The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.
/// The equipment folder node id the condition parents under (null/unknown ⇒ root).
/// Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).
/// Domain alarm type — maps to the SDK condition subtype (see remarks).
/// Domain severity (an OPC UA 1..1000 severity); written verbatim, clamped to 1..1000.
/// True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.
///
/// AlarmType → SDK subtype mapping. Script-driven alarms have no OPC limit /
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
/// map: OffNormalAlarm → , DiscreteAlarm →
/// , and everything else (including AlarmCondition and
/// LimitAlarm, which has no script-supplied limits) → the base
/// . LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.
///
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var conditionKey = MapKey(realm, alarmNodeId);
lock (Lock)
{
// R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply,
// MaterialiseScriptedAlarms + the native-alarm materialise re-run over the FULL composition, so
// an existing enabled condition is re-materialised with the SAME id + kind. Skip it — KEEP the
// existing AlarmConditionState instance alive so every MonitoredItem on that condition node
// survives the deploy (the drop-and-recreate below would otherwise kill them). A genuine
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
return;
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
if (_alarmConditions.TryRemove(conditionKey, out var existing))
{
// Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no
// referencing equipment folder keeps a dangling inverse-notifier entry to the dropped
// AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no
// same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
UnwireAlarmNotifiers(conditionKey);
existing.Parent?.RemoveChild(existing);
ForgetPredefinedSubtree(existing);
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
// dropped instance's back here rather than leave it tracked until the next full reconcile.
if (_allEventsMonitoredConditions.Remove(existing))
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
}
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
// kind, so always drop the stale native flag first and only re-add it below when isNative.
_nativeAlarmNodeIds.Remove(conditionKey);
var parent = ResolveParentFolder(equipmentNodeId, realm);
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
alarm.SymbolicName = displayName;
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
// type's embedded definition; we do not hand-build them.
alarm.Create(
SystemContext,
new NodeId(alarmNodeId, ns),
new QualifiedName(displayName, ns),
new LocalizedText(displayName),
assignNodeIds: true);
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
// with NO default), and nothing downstream fills them in: the auto-filling
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
// is already carried by ConditionName below. See docs/AlarmTracking.md.
alarm.EventType.Value = alarm.TypeDefinitionId;
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
alarm.SourceName.Value = alarmNodeId;
// #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
// text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
// #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
// UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
// unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
// has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
// BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
// driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
// SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
// annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
if (alarm.Quality is not null)
{
alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
}
// Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
// supplies on every transition, rather than the SDK's default false until the first projection.
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
alarm.SetSeverity(SystemContext, ToPart9Severity(severity));
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
// TimedShelve handler rejects any ShelvingTime above it — so every client TimedShelve failed with
// BadShelvingTimeOutOfRange. The bound becomes the longest shelve the SDK's own shelving timer can
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
maxTimeShelved.Value = int.MaxValue;
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
alarm.Message.Value = new LocalizedText(displayName);
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
// SDK applies its node state. The SDK does NOT report an event for it (its ReportStateChange is
// gated on AutoReportStateChanges, which stays false) — the ONE condition event per transition
// is ours, raised after the SDK handler returns by WireMethodTransitionReporting (Gitea #587).
// The engine's later re-projection of the same transition reaches WriteAlarmCondition's
// delta-gate, which finds the node already in that state and suppresses a second event.
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the
// scripted path even for native conditions (H6c scope is Acknowledge only).
// Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the
// shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for
// them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would
// silently undo one at the next transition.
alarm.OnAcknowledge = (context, condition, _, comment) =>
IsNativeAlarmNode(alarmNodeId, realm)
? HandleNativeAlarmAck(context, condition, comment)
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
alarm.OnConfirm = (context, condition, _, comment) =>
IsNativeAlarmNode(alarmNodeId, realm)
? new ServiceResult(StatusCodes.BadNotSupported)
: HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
alarm.OnAddComment = (context, condition, _, comment) =>
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
{
if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported);
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
// shelvingTime is an OPC UA Duration (milliseconds).
var (operation, unshelveAt) =
!shelving ? ("Unshelve", (DateTime?)null)
: oneShot ? ("OneShotShelve", null)
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
};
// The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since Gitea
// #597) fires with a session-less system context. It is deliberately a NO-OP: the engine owns
// timed-shelve expiry (ScriptedAlarmEngine's shelving check → Part9StateMachine.ApplyShelvingCheck,
// recorded as "AutoUnshelve" by "system" with its "Timed shelving expired" comment), and its
// re-projection is what unshelves this node. Routing an operator-style "Unshelve" from here used to
// pre-empt that check on every expiry — from BOTH pair nodes, rewriting the audit trail as a manual
// unshelve — and a timer callback already queued when a re-shelve disposed it could unshelve the
// fresh shelve. The SDK's own OnTimerExpired changes no shelving state itself (it only refreshes
// UnshelveTime after this callback), so returning without routing leaves the node shelved until
// the engine's expiry lands. Native conditions are never timed-shelved (BadNotSupported above).
alarm.OnTimedUnshelve = (_, _) => ServiceResult.Good;
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
// engine enable/disable surface — they short-circuit to BadNotSupported.
alarm.OnEnableDisable = (context, condition, enabling) =>
{
if (IsNativeAlarmNode(alarmNodeId, realm))
return new ServiceResult(StatusCodes.BadNotSupported);
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
};
// Gitea #587: exactly one condition event per accepted method call, reported by us once the SDK's
// own handler has applied the new state.
WireMethodTransitionReporting(alarm, conditionKey);
// Gitea #586: the condition itself delivers its events to Server-object (all-events)
// subscriptions, item by item through the ACL. A condition is never an event notifier, so no
// MonitoredNode2 ever claims this single-slot delegate — every event the condition reports, ours
// or the SDK's own method-driven state changes, passes through it exactly once.
alarm.OnReportEvent = OnConditionReportEvent;
parent.AddChild(alarm);
// Promote the equipment folder to an event notifier + register it as a root notifier so
// T16's ReportEvent reaches the folder's subscribers. Guard so repeated
// materialise under the same folder doesn't double-add the root notifier.
EnsureFolderIsEventNotifier(parent);
AddPredefinedNode(SystemContext, alarm);
_alarmConditions[conditionKey] = alarm;
// Only the new condition can be missing its unit here, so raise just that one — a full reconcile
// walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object
// item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile.
RaiseAllEventsMonitoring(alarm);
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
// for a later re-add to resurrect.
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
RestoreCarryOver(alarm, conditionKey, carried);
}
}
///
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
/// .
///
/// The gate-relevant fields, read off the node as does.
/// The condition's Time — the instant its current state came about.
/// When it last changed Active, if the SDK recorded it.
/// The source timestamp of its Quality annotation, if present.
/// The condition's Comment (a client's ack / AddComment text), if any.
/// Who left that comment, if recorded.
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
LocalizedText? Comment = null, string? ClientUserId = null);
///
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
///
///
///
/// Why the NODE, not a host-side cache. The node is the only place that knows the true state:
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
/// the driver or NativeAlarmProjector. Replaying from any host-side record would un-acknowledge it.
///
///
/// Why it is needed at all. A rebuild recreates every condition inactive + acknowledged. When the
/// alarm-tag set is unchanged — the common case: adding a UnsTagReference to an alarm-bearing raw
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
/// Without this, every active alarm read normal until its next device transition.
///
///
/// The live native condition, about to be discarded.
/// Its carried state.
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
ReadConditionDelta(condition),
condition.Time?.Value ?? DateTime.UtcNow,
condition.ActiveState?.TransitionTime?.Value,
condition.Quality?.SourceTimestamp?.Value,
condition.Comment?.Value,
condition.ClientUserId?.Value);
///
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
/// under Lock, after the condition is registered.
///
///
///
/// Quality always; the rest only off-normal. Source quality is annotated only on a driver's
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
/// reading BadWaitingForInitialData until the driver next flapped; it is restored silently. A
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
/// materialise built, message and authored severity included, and reports nothing — the same rule the
/// scripted re-assert follows (#487).
///
///
/// Reported later, once. The state is applied now, so a Read or ConditionRefresh in the window
/// already sees it, but the one condition event waits for : the
/// equipment folders the condition reports through are wired AFTER this call
/// (), and reporting now would miss them. Native conditions refuse
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
///
///
/// The new condition instance.
/// Its key.
/// What the discarded instance held.
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
{
var state = carried.State;
if (alarm.Quality is not null)
{
alarm.Quality.Value = state.Quality;
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
alarm.Quality.SourceTimestamp.Value = qualityAt;
}
var retained = state.Active || !state.Acknowledged;
if (!retained) return;
alarm.SetActiveState(SystemContext, state.Active);
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
transitionTime.Value = activeAt;
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
alarm.Message.Value = new LocalizedText(state.Message);
alarm.Retain.Value = true;
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
alarm.Time.Value = carried.Time;
alarm.ReceiveTime.Value = carried.Time;
_restoredConditionReports.Add((conditionKey, alarm));
}
/// One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The list is mutated in place under Lock as
/// notifiers are wired / torn down; is the concrete
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
/// the instance changes).
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders);
///
///
/// Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any
/// previously-tracked folder whose id is no longer in
/// (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always
/// passes the COMPLETE referencing-equipment set for the condition, and a de-reference on an
/// ALARM-BEARING raw tag still arrives as a full rebuild (which clears the wiring), so the unwire leg is
/// a no-op on the current paths — but it makes this method correct for a FUTURE surgical path that
/// reaches an alarm-bearing tag. The 2026-08-15 surgical-deploys work narrowed the classifier's blanket
/// ChangedRawTags → Rebuild branch to alarm-bearing / identity-changing raw-tag deltas
/// only (see AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible, which carries the
/// matching binding guard): a NON-alarm raw tag carries no notifier wiring, so its
/// ReferencingEquipmentPaths edit is node-inert and routes surgically.
///
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
if (notifierFolderNodeIds.Count == 0) return;
EnsureAddressSpaceCreated();
var alarmKey = MapKey(alarmRealm, alarmNodeId);
lock (Lock)
{
// The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
// apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
// not fault on the notifier wiring.
if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
{
// Interpolate the key's PARTS, not the RealmKey struct — a struct dump would render a shape
// no log-grep pattern in this file matches.
_logger.LogInformation("OtOpcUaNodeManager.WireAlarmNotifiers: condition {ConditionId} (realm {Realm}) not materialised — skipping notifier wiring",
alarmKey.Id, alarmKey.Realm);
return;
}
// Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
// the entry (the old folder list referenced the discarded instance) when the instance differs.
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
{
wiring = new AlarmNotifierWiring(alarm, new List());
_alarmNotifierWiring[alarmKey] = wiring;
}
// The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller
// supplied, regardless of whether each is currently materialised. Reconcile against it below.
var desiredKeys = notifierFolderNodeIds
.Select(id => MapKey(notifierFolderRealm, id))
.ToHashSet();
// Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a
// genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the
// folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder
// (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too.
foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true);
wiring.Folders.Remove(wired);
}
foreach (var folderNodeId in notifierFolderNodeIds)
{
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
{
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
_logger.LogInformation("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {FolderId} for condition {ConditionId} (realm {Realm}) not present — skipped",
folderNodeId, alarmKey.Id, alarmKey.Realm);
continue;
}
// Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
// ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
// entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
// root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
// updates rather than duplicates.
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
EnsureFolderIsEventNotifier(folder);
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
}
}
}
/// Tear down (bidirectionally) every notifier wired for the condition at
/// and drop its tracking entry. MUST be called under Lock. Used on condition-removal + full rebuild
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.
private void UnwireAlarmNotifiers(RealmKey alarmKey)
{
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
foreach (var folder in wiring.Folders)
{
// bidirectional:true also removes the inverse entry the folder holds back to the condition.
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
}
_alarmNotifierWiring.Remove(alarmKey);
}
///
/// Wraps each Part 9 method the SDK wired on (Acknowledge, Confirm, AddComment,
/// Enable, Disable, OneShotShelve, TimedShelve, Unshelve) so an accepted call reports exactly one
/// condition event carrying the post-call state (Gitea #587).
///
/// The freshly created condition; its method children carry the SDK's handlers.
/// Its key in _alarmConditions.
///
///
/// Why not AutoReportStateChanges. The SDK's own report from a method handler
/// (ConditionState.ReportStateChange) is gated on AutoReportStateChanges — which this
/// repo had never set, so a client Acknowledge produced no condition event at all, and the engine's
/// re-projection found the node already acked and suppressed its own — and then on
/// EventsMonitored(), the AreEventsMonitored counter. That counter is imprecise in the
/// SDK: a folder subscriber's unit never reaches a condition materialised under the folder later, yet
/// the folder's unsubscribe still lowers it, which can take away the Server-object unit while an
/// i=2253 item lives (#586 review). And the SDK's shelve handlers report before any
/// shelving state is applied (they only call OnShelve), so their event would carry the old
/// state and the engine's re-projection would then report a second one. Reporting ourselves, after
/// the handler returns, depends on none of that: walks the notifier
/// graph unconditionally, and Server-object delivery goes through
/// and the ACL like every other condition event.
///
///
/// Shelving is applied here. The SDK's shelve methods never change ShelvingState
/// themselves, so an accepted shelve call applies it — with the same (shelved, oneShot) mapping
/// projects, and a TimedShelve with its requested duration so
/// UnshelveTime is real (Gitea #597) — before reporting. The engine's re-projection of the shelve
/// then finds no delta. Every other method's state the SDK has already applied.
///
///
/// Exactly once. The whole call runs under Lock. The method handlers hand the command
/// to the engine (a fire-and-forget Tell) before the SDK applies the state, so without the lock a fast
/// re-projection could take Lock first, see a delta, and report, followed by this report — two
/// events. Holding it makes the re-projection wait until the node holds the new state.
///
///
/// Native conditions support Acknowledge and AddComment only (Confirm and the shelve methods
/// are refused with BadNotSupported before they get here). Their Confirmed and Shelving stay
/// owned by the driver projection (NativeAlarmProjector: always Confirmed, Unshelved). The SDK's
/// Acknowledge sets Confirmed = false because the condition type supports Confirm, which would
/// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps
/// ConfirmedState straight into its alarm state — so a native ack re-asserts Confirmed and the
/// projection's Retain rule (Active || !Acked) before the one report.
///
///
/// Only a real change is reported. The condition's gate-relevant state is read before and after
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
/// acknowledged condition and sets Confirmed = false again; a re-ack therefore also restores the
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores an
/// uncommented re-ack) and the call is silent — unless it carried a comment, which the SDK writes and
/// which is then reported once like AddComment (Gitea #597). The scripted engine records that comment
/// too (Part9StateMachine.ApplyAcknowledge treats a commented re-ack as AddComment), so the
/// reported comment is persisted and replicated, not node-local. AddComment always reports: adding a comment is itself
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
///
///
/// A rejected call (bad , e.g. a denied gate, or a refused method on a native
/// condition) reports nothing: there was no transition.
///
///
private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey)
{
alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks)
if (alarm.Acknowledge?.OnCall is { } acknowledge)
{
// A commented acknowledge always reports (Gitea #597): the SDK writes the comment even on a re-ack,
// and a new comment is a Part 9 change in its own right — exactly as AddComment always reports. An
// uncommented re-ack still changes nothing and stays silent.
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true,
alwaysReport: !LocalizedText.IsNullOrEmpty(comment));
}
WrapComment(alarm.Confirm, alwaysReport: false);
WrapComment(alarm.AddComment, alwaysReport: true);
WrapGeneric(alarm.Enable, apply: null);
WrapGeneric(alarm.Disable, apply: null);
if (alarm.ShelvingState is { } shelving)
{
WrapGeneric(shelving.OneShotShelve, () => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: true, shelvingTime: 0));
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
{
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
// arms does nothing (OnTimedUnshelve is a no-op — the engine's AutoUnshelve owns the expiry).
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
() => timed(context, method, objectId, shelvingTime),
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));
}
}
void WrapComment(AddCommentMethodState? method, bool alwaysReport)
{
if (method?.OnCall is not { } sdk) return;
method.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport);
}
void WrapGeneric(MethodState? method, Action? apply)
{
if (method?.OnCallMethod is not { } sdk) return;
method.OnCallMethod = (context, m, inputs, outputs) =>
ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply);
}
ServiceResult ReportAfterMethod(
Func sdkHandler, Action? apply, bool isAcknowledge = false, bool alwaysReport = false)
{
lock (Lock)
{
var before = ReadConditionDelta(alarm);
var retainBefore = alarm.Retain.Value;
var result = sdkHandler();
// A rebuild may have discarded this instance mid-call; its replacement is not what was called.
if (!ServiceResult.IsGood(result)
|| !_alarmConditions.TryGetValue(conditionKey, out var live)
|| !ReferenceEquals(live, alarm))
{
return result;
}
apply?.Invoke();
if (isAcknowledge && alarm.ConfirmedState is not null)
{
if (_nativeAlarmNodeIds.Contains(conditionKey))
{
// Native: no confirm workflow — keep what the driver projection says (see remarks).
alarm.SetConfirmedState(SystemContext, true);
alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
}
else if (before.Acknowledged)
{
// Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back.
alarm.SetConfirmedState(SystemContext, before.Confirmed);
alarm.Retain.Value = retainBefore;
}
}
if (alwaysReport || ReadConditionDelta(alarm) != before)
ReportConditionEvent(alarm, DateTime.UtcNow);
return result;
}
}
}
/// H6a — true if the condition materialised at is a NATIVE
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
/// inbound Acknowledge to the driver instead of the scripted engine.
/// The alarm condition node id.
/// True when the condition is native (driver-fed); false when it is scripted or not found.
internal bool IsNativeAlarmNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
// _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
// RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read.
lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
}
///
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK , applies the AlarmAck role gate
/// (fails closed: a missing identity or a missing role is denied), and on success builds a
/// mapped and routes it through .
///
/// The SDK context the handler delegate was invoked with — a
/// ServerSystemContext (an ) carrying the session
/// identity. T17 attached the LDAP roles as a .
/// The condition the method targets; its NodeId identifier is the
/// ScriptedAlarmId (T14 aligned them), which becomes .
/// The Part 9 operation name (e.g. Acknowledge, TimedShelve).
/// The call's comment text, or null when none was supplied.
/// For TimedShelve, the computed UTC expiry; otherwise null.
/// ServiceResult.Good when allowed (the SDK then applies state, and the method path
/// reports the one condition event — Gitea #587);BadUserAccessDenied when the gate vetoes (no route, no state mutation).
private ServiceResult HandleAlarmCommand(
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
{
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gate = EvaluateAlarmCommandGate(identity, condition, operation);
if (gate is not null) return gate;
var cmd = new AlarmCommand(
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Operation: operation,
User: AlarmOperatorName(identity!),
Comment: comment?.Text,
UnshelveAtUtc: unshelveAt);
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
AlarmCommandRouter?.Invoke(cmd);
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
// condition event (the SDK reports none of its own — Gitea #587).
return ServiceResult.Good;
}
///
/// H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The
/// scripted engine does not own native conditions, so this routes a to
/// (the driver-bound seam) rather than a scripted
/// to . The calling principal is resolved
/// and the AlarmAck role gate applied EXACTLY as in
/// (fails closed: a missing identity or missing role is denied — no route, no state mutation).
///
/// The SDK context the handler delegate was invoked with — a
/// ServerSystemContext (an ) carrying the session identity
/// (T17 attached the LDAP roles as a ).
/// The condition the Acknowledge targets; its NodeId identifier is the
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.
/// The acknowledge comment text, or null when none was supplied.
/// ServiceResult.Good when allowed (the SDK then applies state; the method path reports the event);
/// BadUserAccessDenied when the gate vetoes, BadInvalidState when this node is not the redundancy
/// Primary (, Gitea #597) — in both cases no route, no state mutation.
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
{
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
// contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gate = EvaluateAlarmCommandGate(identity, condition, "Acknowledge");
if (gate is not null) return gate;
// Gitea #597 — PRIMARY GATE, after authorization (a caller who may not ack learns nothing about the
// node's role). Only the Primary routes a native ack upstream (DriverHostActor applies the same verdict
// to the route), so on any other node the ack is refused outright rather than applied locally and then
// reverted by the driver's next transition. Nothing is routed, applied or reported.
if (NativeAlarmAckPrimaryGate is { } primary && !primary())
{
return new ServiceResult(StatusCodes.BadInvalidState,
new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel)."));
}
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
// (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
// Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock.
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Comment: comment?.Text,
OperatorUser: AlarmOperatorName(identity!)));
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
// condition event (the SDK reports none of its own — Gitea #587).
return ServiceResult.Good;
}
///
/// The one authorization gate both alarm-command paths share — scripted
/// () and native ().
///
///
///
/// Extracted in #520 stage 5 because the two had a copy of the same role check and were about
/// to acquire a copy of the same ACL check. They must not drift: an ack that the scripted path
/// refuses and the native path allows is a hole whose shape depends on which kind of alarm the
/// operator happened to click.
///
///
/// With ACLs enforced, the Part 9 Acknowledge / Confirm / shelve calls map onto
/// their own members, so a grant CAN distinguish them — unlike the
/// role gate this replaces, where one AlarmAck role covered all three. Anything not
/// recognised as confirm or shelve is treated as an acknowledge, which is the most restrictive
/// reading available: AlarmAcknowledge is the permission an operator authoring "can this
/// principal act on alarms at all" grants first.
///
///
/// The role-carrying identity off the SDK context, or null.
/// The condition the method targets.
/// The Part 9 operation name.
/// null to proceed; otherwise the veto to return to the SDK.
private ServiceResult? EvaluateAlarmCommandGate(
RoleCarryingUserIdentity? identity, ConditionState condition, string operation)
{
if (AclEnforced)
{
return _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
}
// Gitea #563: the activation-time AlarmAck role is honoured only while the key that earned it is
// still usable — an expired or deployment-disabled key loses it on its open session.
var keyVeto = _authorizer?.AuthorizeKeyUsable(
identity, AlarmOperationFor(operation), condition.NodeId);
if (keyVeto is not null) return keyVeto;
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// state change and surfaces the status to the client; we never route or mutate.
return identity is null
|| !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase)
? new ServiceResult(StatusCodes.BadUserAccessDenied)
: null;
}
/// Maps a Part 9 method name onto the permission it requires.
/// The Part 9 operation name.
/// The operation to authorize.
internal static OpcUaOperation AlarmOperationFor(string operation) => operation switch
{
"Confirm" => OpcUaOperation.AlarmConfirm,
"OneShotShelve" or "TimedShelve" or "Unshelve" => OpcUaOperation.AlarmShelve,
_ => OpcUaOperation.AlarmAcknowledge,
};
///
/// The operator name recorded on an alarm command. Prefers the ACL principal over the SDK display
/// name, which for a key-bearing client is the static username every key presents.
///
/// The authenticated identity.
/// The name to record.
private static string AlarmOperatorName(RoleCarryingUserIdentity identity) =>
identity.Principal.Length > 0 ? identity.Principal : identity.DisplayName ?? string.Empty;
///
/// The attached to a writable equipment-tag variable by
/// . The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK the
/// SAME way does, gates on the
/// role + the gateway being wired
/// (fails closed: a missing identity / missing role ⇒ BadUserAccessDenied; no gateway ⇒
/// BadNotWritable) via the pure , and on pass dispatches
/// the value through .
///
/// The dispatch is FIRE-AND-FORGET: the SDK's CustomNodeManager2.Write holds the node
/// manager Lock while invoking this handler, so a blocking driver round-trip here would
/// freeze every address-space operation (reads, subscription notifications, the publish path) for
/// the duration. The gateway only kicks off the asynchronous route. Returning
/// lets the SDK apply the written value optimistically.
///
///
/// Item A — synchronous structural fail-fast. After the authz gate passes but BEFORE the
/// optimistic dispatch, a pure pre-check rejects a
/// structurally-invalid write (e.g. a null payload, or a confidently-detected built-in-type
/// mismatch) INLINE — returning Bad synchronously so the SDK never applies it, avoiding the
/// optimistic-Good-then-revert round-trip + a pointless device dispatch.
///
///
/// Write-outcome self-correction. Before returning Good (which makes the SDK overwrite the
/// node with ) we capture both the optimistic value AND the node's REAL
/// prior value/status — at handler entry the node still holds the prior value — plus the writing
/// principal's user-id (threaded to the audit event). An off-Lock continuation on the
/// then, on a FAILED outcome and ONLY while the node still holds the
/// optimistic value (so a fresh driver poll that already republished the confirmed register value is
/// not clobbered): surfaces a transient Bad-quality blip (Item B), reverts the node to its
/// prior value/status, and raises a Part 8 AuditWriteUpdateEvent (Item C) recording the
/// rejected write ( / ). On
/// success the optimistic value stands and the next poll re-confirms it via the normal
/// path.
///
///
private ServiceResult OnEquipmentTagWrite(
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gateway = _nodeWriteGateway;
var gatewayWired = gateway is not NullOpcUaNodeWriteGateway;
// #520 stage 5. With ACLs enforced the scoped decision REPLACES the server-wide WriteOperate role
// check — see AclOptions for why chaining them would leave every narrow grant inert. The
// gateway-wired check applies either way: it is an availability fact, not an authorization one.
// With the flag off, Gitea #563: the activation-time WriteOperate role is honoured only while the
// session's key is still usable — checked first, so an expired key reads as denied rather than as
// "writes unavailable" on a node with no gateway.
var gate = AclEnforced
? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId))
?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"))
: _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.WriteOperate, node.NodeId)
?? EvaluateEquipmentWriteGate(identity, gatewayWired);
if (gate is not null) return gate;
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below.
// A numeric array of another element type is converted (checked) to the node's element type here and
// the converted array replaces `value`, so the SDK stores — and the driver receives — the node's own type.
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
var range = EvaluateEquipmentWriteRange(indexRange);
if (range is not null) return range;
var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
if (structure is not null) return structure;
value = coerced!;
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
var optimisticValue = value;
// v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS
// write to nodes that share a bare id are distinguishable downstream — WP3's write gateway keys its
// (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm.
var nodeKey = node.NodeId.ToString();
var nodeRealm = RealmOf(node.NodeId);
var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty;
object? priorValue = null;
StatusCode priorStatus = StatusCodes.Good;
if (node is BaseDataVariableState variable)
{
priorValue = variable.Value;
priorStatus = variable.StatusCode;
}
// Item C: thread the writing principal's user-id string into the failure continuation so the audit
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
// practice non-null on this path, but kept defensive).
// Prefer the ACL principal: for a key-bearing client DisplayName is the static username every key
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
// sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the
// 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, nodeRealm, CancellationToken.None)
.ContinueWith(
t =>
{
var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");
RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm);
},
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
return ServiceResult.Good;
}
///
/// Pure role + availability gate for an inbound equipment-tag write, extracted off
/// so it is unit-testable without booting an SDK server. Fails closed:
/// a null identity or an identity missing the role ⇒
/// BadUserAccessDenied. When the gate passes but no real gateway is wired
/// ( is false) ⇒ BadNotWritable ("writes unavailable"). A
/// null return means "proceed" (the caller dispatches + returns Good). Role comparison is
/// case-insensitive (the role set is built with ),
/// matching the alarm gate.
///
/// The role-carrying identity extracted off the SDK context, or null when the
/// session is anonymous / carries no role-carrying identity.
/// True when a non-Null is wired; false
/// for the Null default (no route — e.g. admin-only nodes / pre-boot).
/// null to proceed (gate passed); otherwise the veto
/// (BadUserAccessDenied on a failed role gate, BadNotWritable when no gateway is wired).
internal static ServiceResult? EvaluateEquipmentWriteGate(RoleCarryingUserIdentity? identity, bool gatewayWired)
{
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
{
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// write and surfaces the status to the client; we never route.
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
if (!gatewayWired)
{
// Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
}
return null;
}
///
/// Item A — synchronous structural fail-fast. Pure structural pre-check for an inbound
/// equipment-tag write, run AFTER passes but BEFORE the
/// optimistic device dispatch in . A structurally-invalid write is
/// rejected INLINE (a Bad is returned synchronously, so the SDK never
/// applies the value) instead of being optimistically applied and later reverted — saving both the
/// phantom value-blip and a pointless device round-trip.
///
/// Interpretation / tradeoff (the item was deliberately under-specified). The minimum
/// sensible structural check is a null value write to a value variable ⇒
/// BadTypeMismatch (a value node always holds a typed scalar/array; a null payload is never a
/// valid value write here). On top of that, when the node is a
/// whose resolves to a concrete built-in type AND the
/// written value's runtime built-in type is also resolvable, a CHEAP built-in-type compatibility
/// check is applied: a clear mismatch ⇒ BadTypeMismatch. The check is intentionally
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
/// payload whose type isn't cheaply known) is allowed through.
///
///
/// ⚠️ This is the ONLY type check a writable equipment node gets (Gitea #610 review). The SDK's
/// BaseVariableState.WriteValueAttribute runs TypeInfo.IsInstanceOfDataType only when
/// the node has NO OnWriteValue handler; with one (every writable equipment node) it checks the
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
/// by shape and declared length, and its element type must either match the node's DataType or be
/// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
/// into the node's element type () — refused whole if any element is
/// out of range, fractional for an integer node, or NaN/±Infinity for an integer node — and every other
/// mismatch is refused. An array value to a scalar node is refused only when
/// says the owning driver cannot take one. A SCALAR write keeps
/// the looser numeric rule above on purpose (see ).
///
/// Pure (no SDK server / Lock needed): reads only and the node's static
/// DataType, so it is unit-testable in isolation.
///
/// The value the client wrote (the SDK's pre-coercion payload).
/// The target node (expected to be a writable ).
/// True when the node's owning driver cannot write an array value to a
/// scalar tag (Modbus / S7): an array value to a scalar node is then refused. False keeps the pre-#610
/// behaviour for that case (dispatch; the driver — e.g. OpcUaClient's pass-through — decides).
/// On a null return, the value to store and dispatch:
/// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
/// null when the write is refused.
/// null to proceed; otherwise the veto
/// (BadTypeMismatch for a null write or a confidently-detected built-in-type mismatch).
internal static ServiceResult? EvaluateEquipmentWriteStructure(
object? value, NodeState node, bool refuseArrayOnScalar, out object? coerced)
{
coerced = null;
var result = EvaluateEquipmentWriteStructureCore(value, node, refuseArrayOnScalar, ref coerced);
if (result is not null) coerced = null;
else coerced ??= value;
return result;
}
/// The verdict half of
/// for callers that only need the decision; the array-on-scalar refusal is applied as for a Modbus / S7 node.
/// Production goes through the four-argument overload, which also returns the converted value.
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) =>
EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar: true, out _);
private static ServiceResult? EvaluateEquipmentWriteStructureCore(
object? value, NodeState node, bool refuseArrayOnScalar, ref object? coerced)
{
// Minimum sensible check: a null payload is never a valid value write to a value variable.
if (value is null)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
}
// Gitea #610 — an array node (writable only when its driver encodes whole-array writes) takes a whole
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
var isArrayNode = false;
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
{
isArrayNode = true;
if (value is not Array { Rank: 1 } array)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
}
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
if (declared > 0 && array.Length != declared)
{
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array write of {array.Length} element(s) to a {declared}-element array node");
}
}
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
if (node is not BaseDataVariableState variable) return null;
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
// ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
// lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
// writing that array back worked before #610 on the pass-through drivers (OpcUaClient forwards the
// Variant; Galaxy encodes it), so those keep dispatching and the driver decides. A byte[] is exempt: on
// the wire it is a ByteString, which is a scalar (the type check below still decides whether the node
// takes one). An unresolved-DataType node deferred above, as it always has.
if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
}
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
if (!IsCheckableExpectedType(expected)) return null;
// TypeInfo.Construct(object) classifies the runtime payload (an array by its ELEMENT type); an
// unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType;
// Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
// because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
// handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
// driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
// covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
// serving an int[] under an Int16 DataType.
// • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
// classifies as ByteString) proceeds unchanged.
// • A NUMERIC array of another numeric element type is CONVERTED, checked, into the node's element type,
// and the converted array is what the SDK stores and the driver receives. This is not optional: the
// consumer #610 exists for, ScadaBridge, writes the CLR list it decoded from a List attribute, and its
// element types are only Boolean/Int32/Float/Double/String/DateTime — an exact-match rule left every
// Int16/UInt16/Byte/UInt32/Int64/UInt64 array node (Modbus's default Int16 included) and a Double list
// to a Float array permanently unwritable from it. Any element out of the target's range, fractional
// for an integer target, or NaN/±Infinity for an integer target refuses the WHOLE write — so the
// UInt32 → Int32 reinterpretation stays closed (4,000,000,000 overflows; in-range values convert).
// • Anything else — a non-numeric mismatch, or an unclassifiable element type (Variant[] / object[]) — is
// refused rather than deferred.
if (isArrayNode)
{
if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
{
if (TryConvertNumericArray(source, expected, out var converted))
{
coerced = converted;
return null;
}
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array element of type {actual} is not exactly representable as node data type {expected}");
}
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array element type {actual} does not match node data type {expected}");
}
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
if (!IsBuiltInTypeCompatible(expected, actual))
{
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"value built-in type {actual} is not compatible with node data type {expected}");
}
return null;
}
///
/// Gitea #610 — an equipment-tag write must be a WHOLE-value write. A partial (IndexRange) write to an
/// array node is refused: every array-capable driver writes the whole block, so honouring a range would
/// mean a read-modify-write of the device array the server does not hold authoritatively (the node value
/// can be stale or optimistic), and the SDK would apply the range to the node before the device answered.
/// A range on a scalar node is meaningless and refused the same way. Pure + static.
///
/// The write's parsed IndexRange ( when absent).
/// null to proceed; otherwise BadWriteNotSupported.
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
indexRange == NumericRange.Empty
? null
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
///
/// Conservative built-in-type compatibility test for 's
/// SCALAR path (array writes are matched exactly — see there). Returns true (compatible — allow through)
/// unless there is a confident mismatch. Exact matches pass; numeric-to-numeric is treated as compatible —
/// deliberately looser than the SDK, which would refuse it: consumers (ScadaBridge) write CLR-typed values
/// such as a Double to a Float node, and every driver's SCALAR encoder converts with a range check
/// (Convert.ToInt16, MxValueEncoder's uint ≤ int.MaxValue arm, …), so an out-of-range value
/// fails the device write and is reverted rather than silently reinterpreted; a
/// ↔ array nuance and any
/// on either side are treated as compatible. Only a clear cross-family
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
///
private static bool IsBuiltInTypeCompatible(BuiltInType expected, BuiltInType actual)
{
if (expected == actual) return true;
// Any side a wildcard/unclassified ⇒ defer (compatible) — the caller already filtered most of these.
if (expected is BuiltInType.Variant or BuiltInType.Null) return true;
if (actual is BuiltInType.Variant or BuiltInType.Null) return true;
// Numeric family widening/narrowing is the SDK's job; treat numeric↔numeric as compatible.
if (IsNumeric(expected) && IsNumeric(actual)) return true;
// ByteString and Byte are routinely interchangeable on the wire; don't reject that pairing.
if ((expected, actual) is (BuiltInType.ByteString, BuiltInType.Byte) or (BuiltInType.Byte, BuiltInType.ByteString)) return true;
return false;
}
/// True for the OPC UA numeric built-in types (the integer + floating families).
private static bool IsNumeric(BuiltInType t) => t is
BuiltInType.SByte or BuiltInType.Byte or BuiltInType.Int16 or BuiltInType.UInt16 or
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
BuiltInType.Float or BuiltInType.Double;
///
/// #610 review R6 — CHECKED element-wise conversion of a numeric array to the node's numeric element type.
/// Fails (nothing converted) when ANY element is out of the target's range, is fractional or NaN/±Infinity
/// for an integer target, or is finite yet overflows to ±Infinity for a
/// target. Precision loss inside the target's range (a Double to a Float, a large Int64 to a Double) is
/// accepted, as the scalar path's drivers accept it. Pure + static.
///
/// The 1-D numeric array the client wrote.
/// The node's numeric element type.
/// The converted array (element CLR type of ), or null.
/// True when every element converted exactly enough to be written.
internal static bool TryConvertNumericArray(Array source, BuiltInType target, out Array? converted)
{
converted = null;
var elementType = target switch
{
BuiltInType.SByte => typeof(sbyte), BuiltInType.Byte => typeof(byte),
BuiltInType.Int16 => typeof(short), BuiltInType.UInt16 => typeof(ushort),
BuiltInType.Int32 => typeof(int), BuiltInType.UInt32 => typeof(uint),
BuiltInType.Int64 => typeof(long), BuiltInType.UInt64 => typeof(ulong),
BuiltInType.Float => typeof(float), BuiltInType.Double => typeof(double),
_ => null,
};
if (elementType is null) return false;
var result = Array.CreateInstance(elementType, source.Length);
for (var i = 0; i < source.Length; i++)
{
if (!TryConvertNumericElement(source.GetValue(i), target, out var element)) return false;
result.SetValue(element, i);
}
converted = result;
return true;
}
private static bool TryConvertNumericElement(object? element, BuiltInType target, out object? result)
{
result = null;
if (element is null) return false;
var integerTarget = target is not (BuiltInType.Float or BuiltInType.Double);
if (element is float or double)
{
var d = Convert.ToDouble(element, CultureInfo.InvariantCulture);
// Convert.ToInt16(1.5) ROUNDS (banker's) rather than failing, so a fractional value is refused here.
if (integerTarget && (!double.IsFinite(d) || Math.Truncate(d) != d)) return false;
if (target == BuiltInType.Float && double.IsFinite(d) && float.IsInfinity((float)d)) return false;
}
try
{
result = target switch
{
BuiltInType.SByte => Convert.ToSByte(element, CultureInfo.InvariantCulture),
BuiltInType.Byte => Convert.ToByte(element, CultureInfo.InvariantCulture),
BuiltInType.Int16 => Convert.ToInt16(element, CultureInfo.InvariantCulture),
BuiltInType.UInt16 => Convert.ToUInt16(element, CultureInfo.InvariantCulture),
BuiltInType.Int32 => Convert.ToInt32(element, CultureInfo.InvariantCulture),
BuiltInType.UInt32 => Convert.ToUInt32(element, CultureInfo.InvariantCulture),
BuiltInType.Int64 => Convert.ToInt64(element, CultureInfo.InvariantCulture),
BuiltInType.UInt64 => Convert.ToUInt64(element, CultureInfo.InvariantCulture),
BuiltInType.Float => Convert.ToSingle(element, CultureInfo.InvariantCulture),
BuiltInType.Double => Convert.ToDouble(element, CultureInfo.InvariantCulture),
_ => null,
};
return result is not null;
}
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
{
return false;
}
}
/// The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types can emit for a writable equipment node
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
/// deferred to the SDK so a coercible write (Int32→Enumeration, etc.) is never false-rejected.
private static bool IsCheckableExpectedType(BuiltInType t) =>
IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString;
///
/// Pure decision for the write-outcome self-correction: revert the node to its pre-write value ONLY on
/// a FAILED outcome AND only while the node still holds the optimistic value. The
/// still-holds-the-optimistic-value check is what stops a revert from clobbering a fresh driver poll
/// that already republished the confirmed register value over the optimistic write. Pure (value
/// comparison via ) so it is unit-testable without an SDK
/// server.
///
/// The device-write outcome routed back by the gateway.
/// The node's current Value at revert time.
/// The value the SDK optimistically applied on the write.
/// true to revert (failed outcome and node unchanged since the optimistic write);
/// false on success, or when a poll has already moved the node off the optimistic value.
internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
!outcome.Success && Equals(currentNodeValue, optimisticValue);
///
/// Off-Lock continuation body for the write-outcome self-correction: re-takes Lock and, when
/// says so, surfaces the device-write rejection to subscribed clients in three
/// ways, then leaves the node holding its captured pre-write value/status (same node-update shape as
/// ). A no-op when the node was rebuilt/removed in the interim, when the outcome
/// succeeded, or when a fresh poll already moved the node off the optimistic value.
///
/// -
/// Item B — Bad-quality blip. Before restoring the prior value, the node is published
/// once holding the (still-applied) optimistic value but with StatusCode
/// BadDeviceFailure, then published again with the prior value/status restored. A
/// value-subscribed client therefore sees the rejection (a Bad quality) rather than the value
/// silently snapping back. Caveat: the two ClearChangeMasks calls happen
/// back-to-back within one server publishing interval, so the SDK's monitored-item queue may
/// COALESCE them — a slow / queue-size-1 subscriber can see only the final restored value and
/// miss the transient Bad blip. The blip is a best-effort live signal; the
/// raised below (Item C) is the reliable, durable record
/// of the rejected write.
///
/// -
/// Item C — AuditWriteUpdateEvent. A Part 8 is
/// raised through the SDK so an auditing client gets a
/// durable record of the rejected write (OldValue = prior, NewValue = the attempted optimistic
/// value, the boolean AuditEvent Status = false ⇒ failed, and the device's
/// .Reason carried in the event Message). It is reported
/// OUTSIDE Lock (the event-state is built under Lock, then reported after release) to keep
/// the lock hold short and avoid any re-entrancy risk via the server's event path. Auditing being
/// disabled / no subscribers is handled gracefully — ReportEvent simply reaches no monitored
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
///
///
/// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
/// and the SDK trace captures any audit-report failure.
/// internal (not private) so the behavioural test drives the blip-then-settle continuation
/// against a booted node manager — see NodeManagerWriteRevertTests.
///
/// The string id of the written variable node.
/// The device-write outcome routed back by the gateway.
/// The value the SDK optimistically applied on the write.
/// The node's real value captured before the optimistic write.
/// The node's real status captured before the optimistic write.
/// The writing principal's user-id string (the identity's DisplayName), threaded
/// from to populate the audit event's ClientUserId; null when unknown.
internal void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId, AddressSpaceRealm realm)
{
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
AuditWriteUpdateEventState? auditEvent = null;
var key = MapKey(realm, nodeId);
lock (Lock)
{
if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op
if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on
// Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
// the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
// node-update shape). See the method remarks for the queue-coalescing caveat.
variable.StatusCode = StatusCodes.BadDeviceFailure;
variable.Timestamp = DateTime.UtcNow;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
variable.Value = priorValue;
variable.StatusCode = priorStatus;
variable.Timestamp = DateTime.UtcNow;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
// Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet.
// Guarded like ReportAuditEvent: the revert above has ALREADY happened, so a surprise from the SDK
// child-population path (e.g. a SetChildValue on a null OldValue/NewValue) must be swallowed + logged,
// never thrown out of this fire-and-forget continuation.
try
{
auditEvent = BuildWriteFailureAuditEvent(variable, outcome, optimisticValue, priorValue, clientUserId);
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: failed to build AuditWriteUpdateEvent for {NodeId}", nodeId);
auditEvent = null;
}
}
// Report OUTSIDE Lock — keeps the hold short and sidesteps any re-entrancy through the server event path.
if (auditEvent is not null) ReportAuditEvent(auditEvent);
}
///
/// Item C — build (but do not report) a Part 8 recording a
/// rejected device write on . The caller holds Lock (the node is read
/// here); reporting happens after Lock release. The standard AuditEvent envelope (EventId, EventType,
/// Time/ReceiveTime, ServerId, Severity, Message, SourceNode/SourceName, Status, ActionTimeStamp) is
/// stamped by the SDK's helper; this method then fills the write-specific fields
/// (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId.
///
/// The written node (the audit event's SourceNode).
/// The failed device-write outcome (its Reason goes into the event Message + Status).
/// The value the client attempted (the audit NewValue).
/// The node's real pre-write value (the audit OldValue).
/// The writing principal's user-id string; null when unknown.
/// A populated, unreported .
/// internal (not private) so NodeManagerWriteRevertTests can assert the populated
/// audit fields at the nearest deterministic seam (the end-to-end Server.ReportEvent dispatch would
/// need a subscribed event monitored-item to observe).
internal AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue,
string? clientUserId)
{
var reason = string.IsNullOrEmpty(outcome.Reason) ? "device write rejected" : outcome.Reason!;
var audit = new AuditWriteUpdateEventState(null);
// Initialize stamps EventId (fresh GUID bytes), EventType, Time/ReceiveTime, ServerId, Severity, Message,
// SourceNode/SourceName (from `variable`), Status and ActionTimeStamp. The AuditEvent `Status` field is a
// PropertyState (true=succeeded / false=failed), so the failed device write is recorded as `false`;
// the device's textual Reason is carried in the Message (the StatusCode itself has no audit-field home).
audit.Initialize(
SystemContext,
source: variable,
severity: EventSeverity.Medium,
message: new LocalizedText($"Inbound write rejected by device: {reason}"),
status: false,
actionTimestamp: DateTime.UtcNow);
// Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write);
// OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate
// child PropertyStates are NOT instantiated by Initialize — SetChildValue lazily creates + sets them
// (verified against the 1.5.378 SDK; it tolerates a null value, creating the child with Value=null).
audit.SetChildValue(SystemContext, BrowseNames.AttributeId, (uint)Attributes.Value, false);
audit.SetChildValue(SystemContext, BrowseNames.IndexRange, string.Empty, false);
audit.SetChildValue(SystemContext, BrowseNames.OldValue, priorValue!, false);
audit.SetChildValue(SystemContext, BrowseNames.NewValue, optimisticValue!, false);
// Standard AuditEvent client-identity fields. ClientUserId is the writing principal (threaded from the
// handler); ClientAuditEntryId carries the SDK context's audit entry id when present.
if (clientUserId is not null) audit.SetChildValue(SystemContext, BrowseNames.ClientUserId, clientUserId, false);
var auditEntryId = SystemContext.AuditEntryId;
if (!string.IsNullOrEmpty(auditEntryId))
audit.SetChildValue(SystemContext, BrowseNames.ClientAuditEntryId, auditEntryId, false);
return audit;
}
///
/// Item C — report a built audit event through the SDK server, guarding against auditing being disabled
/// / no subscribers / a transient server-event-path failure. A failure here MUST NOT break the revert
/// that already happened, so it is swallowed and logged to the SDK trace (this node manager has no
/// ILogger) rather than propagated. Reported with the node manager's SystemContext, which
/// is what the alarm event path uses too.
///
/// The populated audit event-state to report.
private void ReportAuditEvent(AuditWriteUpdateEventState auditEvent)
{
try
{
ReportToServerObject(auditEvent);
}
catch (Exception ex)
{
// Auditing disabled / no monitored items / server shutting down ⇒ ReportEvent may no-op or throw;
// either way the revert already stands. Surface a recurring failure in the SDK trace, don't rethrow.
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report AuditWriteUpdateEvent for {NodeId}", auditEvent.SourceNode?.Value);
}
}
/// Map our domain AlarmType string to the matching SDK condition subtype. Script
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
/// (see remarks).
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
{
"OffNormalAlarm" => new OffNormalAlarmState(parent),
"DiscreteAlarm" => new DiscreteAlarmState(parent),
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
// to populate, so the limit subtypes would carry unset High/Low children.
_ => new AlarmConditionState(parent),
};
///
/// Gitea #629 — the AlarmConditionType_ShelvingState_* method instance declarations a client calls
/// the shelve methods by. records them as the methods' MethodDeclarationId
/// (the SDK's own is the ShelvedStateMachineType's), so calls by these ids keep resolving after
/// the methods move to instance-scoped NodeIds. Calls by the ShelvedStateMachineType_* ids are
/// not supported (pinned by ConditionChildNodeIdTests).
///
private static readonly HashSet ShelvingMethodDeclarationIds =
[
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
];
///
/// Gitea #629 — the hook NodeState.Create(…, assignNodeIds: true) calls
/// for the node being created and then, top-down, for every child it built from the type's definition.
/// A node that already carries an id in one of our namespaces (the condition root, whose id
/// passes to Create) keeps it; a child is given
/// {parent id}/{BrowseName} in its parent's namespace.
///
///
///
/// The SDK default (CustomNodeManager2.New) returns the node's CURRENT id, which for a child is
/// the condition type's instance-declaration id (i=9189 for ShelvingState/UnshelveTime) or
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
/// TranslateBrowsePathsToNodeIds of any child path returned BadNoMatch, and a Read of a browsed
/// child id either hit BadNodeIdUnknown or was served by the core node manager from the TYPE
/// (UnshelveTime read 0 on a condition timed-shelved for a minute). Event delivery and the Part 9
/// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its
/// declaration id.
///
///
/// The derived id is deterministic and unique per condition: the parent part is the condition's own
/// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames
/// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing
/// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also
/// slash-joined on purpose: DataPlaneAuthorizer.BuildScope splits the identifier on /,
/// so a child's scope is its condition's scope plus the browse path, and the permission trie's
/// downward inheritance governs a child exactly as it governs its condition.
///
///
/// The system context of the create.
/// The node being assigned an id.
/// The id the node is to carry.
public override NodeId New(ISystemContext context, NodeState node)
{
if (OwnsNamespace(node.NodeId)) return node.NodeId;
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
&& OwnsNamespace(parentId)
&& !QualifiedName.IsNull(node.BrowseName))
{
// A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId =
// e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR
// MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id
// already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id
// is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the
// AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before
// it is replaced, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
// lookup in the standard address space would take other node managers' locks while we hold Lock.
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
method.MethodDeclarationId = method.NodeId;
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
}
return node.NodeId;
}
///
/// Gitea #629 — drops a removed condition AND every descendant from PredefinedNodes.
/// AddPredefinedNode indexes the whole subtree, so removing only the root would leave the
/// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead
/// condition's last value. MUST be called under Lock.
///
/// The node whose subtree is forgotten.
private void ForgetPredefinedSubtree(NodeState node)
{
PredefinedNodes?.Remove(node.NodeId);
var children = new List();
node.GetChildren(SystemContext, children);
foreach (var child in children) ForgetPredefinedSubtree(child);
}
/// Promote to and
/// register it as a root notifier (idempotent — guarded by ) so the
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
/// the condition's own — see OnConditionReportEvent, Gitea #586).
///
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
/// ), the folder ALSO gets the
/// bit OR-ed in (keeping SubscribeToEvents) and registers
/// its NodeId identifier as an event-history source so the override
/// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at
/// the moment of promotion: the Host wires the source at StartAsync — BEFORE any deployment
/// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder
/// promoted while the source is still Null advertises live-event subscription but NOT event history
/// until the next re-promotes it (acceptable, documented). The
/// HistoryRead bit + source registration happen inside the same first-time
/// block so the idempotency guard covers them too.
///
private void EnsureFolderIsEventNotifier(FolderState folder)
{
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
if (_historianDataSource is not NullHistorianDataSource)
{
// A historian is wired: advertise event history on this notifier and register it as a source.
// The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
// Key by the realm-qualified RealmKey built from the NodeId so a Raw and a UNS folder that share
// the same bare id are distinct sources; the VALUE stays the bare id — that is the historian
// ReadEventsAsync sourceName (== the equipment id).
var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
_eventNotifierSources[MapKey(folder.NodeId)] = sourceName;
}
AddRootNotifier(folder);
folder.ClearChangeMasks(SystemContext, includeChildren: false);
}
///
/// The Part 9 Severity (1..1000) a condition carries for a domain severity, which is already on
/// that scale: the value itself, clamped. SetSeverity takes an , but
/// that enum merely names five points on the 1..1000 scale — any value in range is a valid Severity.
///
///
/// This used to re-bucket onto those five points (< 800 ⇒ 700, else 900), so the condition
/// node was the one seam that disagreed with every other: an MTConnect Fault (#626's exact 800) read and
/// evented as 900, a scripted alarm the engine graded 1000 published 900 on the node while its
/// /alerts row and historian event said 1000, and a bucket-only native driver's Low (200) became 300.
///
/// The domain severity.
/// The severity the node carries.
private static EventSeverity ToPart9Severity(int severity) => (EventSeverity)Math.Clamp(severity, 1, 1000);
///
/// Ensure a folder node exists at with the given display
/// name, parented under (or the namespace root when null).
/// Used by to materialise the UNS Area/Line/Equipment
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
/// folder so adding child variables under it still works.
///
/// The node identifier of the folder.
/// The node identifier of the parent folder; null to use the namespace root.
/// The display name of the folder.
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, folderNodeId);
if (_folders.ContainsKey(key)) return;
lock (Lock)
{
if (_folders.ContainsKey(key)) return;
var parent = ResolveParentFolder(parentNodeId, realm);
var folder = new FolderState(parent)
{
NodeId = new NodeId(folderNodeId, ns),
BrowseName = new QualifiedName(folderNodeId, ns),
DisplayName = displayName,
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
};
parent.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
_folders[key] = folder;
}
}
///
/// Update an EXISTING folder node's in place and notify
/// subscribers, WITHOUT a rebuild — the in-place counterpart of for a
/// UNS Area / Line rename. early-returns for an
/// already-present folder id and never touches an existing folder's display name, so a
/// rename-only deploy would otherwise be invisible until a full
/// cleared _folders. The NodeId + BrowseName are unchanged (a rename only touches the
/// friendly display name, not the logical id, so browse-path resolution + ACLs are unaffected).
/// ClearChangeMasks is called so subscribed clients see the new DisplayName immediately,
/// mirroring the surgical path. Returns false when the folder id
/// is unknown (caller falls back to a full rebuild).
///
/// The node identifier of the folder to update in place.
/// The new display name to apply.
/// True when the in-place update was applied; false when the folder id is unknown.
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
lock (Lock)
{
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false;
folder.DisplayName = displayName;
folder.ClearChangeMasks(SystemContext, includeChildren: false);
return true;
}
}
///
/// Ensure a Variable node exists at parented under
/// (or root when null). Initial value=null, quality=Bad,
/// timestamp=epoch — fills these in once driver data flows.
/// Idempotent. Materialises equipment-namespace tags so they're browseable before drivers
/// issue SubscribeBulk. Note: because of the early _variables.ContainsKey return, a
/// re-apply of an EXISTING node with a changed historize intent (e.g. non-historized →
/// historized) is silently ignored — a historize-intent change only takes effect after a
/// (which the planner triggers on an equipment-tag delta).
///
/// The node identifier of the variable.
/// The node identifier of the parent folder; null to use the namespace root.
/// The display name of the variable.
/// The OPC UA data type name (e.g., "Boolean", "Int32", "String").
/// When true the node is created CurrentReadWrite (an authored
/// ReadWrite equipment tag) and the inbound-write handler is attached
/// to its OnWriteValue so a client write gates on the WriteOperate role + routes
/// to the backing driver; when false it stays CurrentRead (read-only) with no write handler.
/// Phase C: null ⇒ the node is NOT historized (Historizing=false, no
/// HistoryRead bit, not registered). Non-null ⇒ the node is created Historizing with the
/// HistoryRead access bit OR-ed into both AccessLevel and UserAccessLevel, and the
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
/// resolves against.
/// Phase 4c: when true the node is materialised as a 1-D array
/// (ValueRank=OneDimension with ArrayDimensions=[arrayLength]); when false (default) it
/// stays scalar (ValueRank=Scalar). Array elements share the scalar's base
/// — rank + dimensions carry the array-ness.
/// Phase 4c: the declared length of the 1-D array when
/// is true; ignored for scalars. Null ⇒ length 0.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, variableNodeId);
// If already present, leave it alone (idempotent re-applies).
if (_variables.ContainsKey(key)) return;
lock (Lock)
{
if (_variables.ContainsKey(key)) return;
var parent = ResolveParentFolder(parentFolderNodeId, realm);
// Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead
// access bit (on top of the writable composite) so clients can browse + HistoryRead it.
var historized = historianTagname is not null;
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
// CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized.
var access = ComposeAccessLevel(writable, historized);
var variable = new BaseDataVariableState(parent)
{
NodeId = new NodeId(variableNodeId, ns),
BrowseName = new QualifiedName(variableNodeId, ns),
DisplayName = displayName,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = ResolveBuiltInDataType(dataType),
ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
ArrayDimensions = isArray ? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
AccessLevel = access,
UserAccessLevel = access,
Historizing = historized,
Value = null,
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
// A writable equipment tag owns an inbound-write handler. The SDK invokes
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
if (writable)
{
variable.OnWriteValue = OnEquipmentTagWrite;
}
parent.AddChild(variable);
AddPredefinedNode(SystemContext, variable);
_variables[key] = variable;
// Phase C: register the resolved historian tagname so the HistoryRead override can map this
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
if (historized) _historizedTagnames[key] = historianTagname!;
if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
}
}
/// Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable +
/// Historizing flags — the single source of truth shared by (node
/// creation) and (F10b surgical in-place update). The SDK exposes
/// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite,
/// with the HistoryRead bit OR-ed in when historized. OR-ing byte constants promotes to int, so cast back.
private static byte ComposeAccessLevel(bool writable, bool historized)
{
byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
if (historized) access = (byte)(access | AccessLevels.HistoryRead);
return access;
}
/// F10b surgical counterpart of : update an EXISTING tag variable's
/// Writable (AccessLevel + handler), Historizing (+ historian-tagname
/// binding), and presentation shape ( → DataType;
/// / → ValueRank + ArrayDimensions)
/// in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node survive.
/// Returns false when the node id is unknown (caller falls back to a full rebuild).
///
/// When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// happen. (1) The node's value is reset to BadWaitingForInitialData (Value=null) so no value
/// still typed to the OLD DataType is exposed between this update and the driver's next publish — the
/// same fresh-node state creates, which closes the "brief value-type
/// mismatch" window. (2) A Part 3 GeneralModelChangeEvent (verb=DataTypeChanged) is
/// raised from the Server object so model-aware clients re-read the node definition. A Writable /
/// Historizing-only change (DataType + array-ness unchanged) skips both — its value stays valid and
/// there is no model change to report (preserves the original surgical behaviour exactly).
///
///
/// The model-change event is built under Lock but reported AFTER the lock is released —
/// mirroring : Server.ReportEvent re-enters the
/// server's own subscription/event path, so holding the node Lock across it risks a
/// lock-order inversion.
///
/// The folder-scoped node id of the variable to update in place.
/// When true the node becomes read/write with the inbound-write handler; otherwise read-only.
/// null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.
/// The OPC UA built-in data type name to apply (e.g. "Boolean", "Int32", "Float").
/// When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.
/// The declared length of the 1-D array when is true; ignored for scalars.
/// True when the in-place update was applied; false when the node id is unknown.
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)
var key = MapKey(realm, variableNodeId);
BaseDataVariableState? shapeChangedNode = null;
lock (Lock)
{
if (!_variables.TryGetValue(key, out var v)) return false;
var historized = historianTagname is not null;
var access = ComposeAccessLevel(writable, historized);
v.AccessLevel = access;
v.UserAccessLevel = access;
v.Historizing = historized;
v.OnWriteValue = writable ? OnEquipmentTagWrite : null;
if (historized) _historizedTagnames[key] = historianTagname!;
else _historizedTagnames.TryRemove(key, out _);
// Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
// from the live node — a Writable/Historizing-only change leaves the shape untouched (no value
// reset, no model-change event), preserving the original surgical behaviour byte-for-byte.
var newDataType = ResolveBuiltInDataType(dataType);
var newValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar;
if (v.DataType != newDataType || v.ValueRank != newValueRank || ArrayLengthDiffers(v, isArray, arrayLength))
{
v.DataType = newDataType;
v.ValueRank = newValueRank;
v.ArrayDimensions = isArray
? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u }))
: null;
// Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client
// never reads a value whose runtime type contradicts the just-changed DataType.
v.Value = null;
v.StatusCode = StatusCodes.BadWaitingForInitialData;
v.Timestamp = DateTime.MinValue;
shapeChangedNode = v;
}
v.ClearChangeMasks(SystemContext, includeChildren: false);
}
// Report OUTSIDE Lock (see the method remarks) — only when the shape actually changed.
if (shapeChangedNode is not null) ReportNodeShapeChangedEvent(shapeChangedNode);
return true;
}
/// True when an array target's requested 1-D length differs from the node's current one. This is
/// the array-to-array length-edit case: the caller's ValueRank != newValueRank check already catches
/// any scalar↔array transition, so this runs to decide whether an already-array node's dimension changed
/// (and treats absent/empty current dimensions as "differs", forcing the in-place update).
private static bool ArrayLengthDiffers(BaseDataVariableState v, bool isArray, uint? arrayLength)
{
if (!isArray) return false; // scalar target ⇒ ValueRank check owns the diff
if (v.ArrayDimensions is not { Count: > 0 }) return true; // no current dimension ⇒ shape differs
return v.ArrayDimensions[0] != (arrayLength ?? 0u);
}
/// Build (but do not report) the Part 3 GeneralModelChangeEvent announcing that
/// 's definition changed (DataType / ValueRank / ArrayDimensions). Verb =
/// DataTypeChanged — the model-change verb the SDK exposes for an attribute-shape change (there is
/// no separate ValueRank verb). internal (not private) so a node-manager test can assert the
/// populated Changes structure at the nearest deterministic seam (the end-to-end Server.ReportEvent
/// dispatch would need a subscribed event monitored-item to observe).
/// The variable whose shape changed.
/// A populated, unreported .
internal GeneralModelChangeEventState BuildNodeShapeChangedEvent(BaseDataVariableState variable)
{
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Node {variable.NodeId} definition changed (DataType/ValueRank)"));
// Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
// to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
// so conformant clients that filter events by SourceNode still match this one.
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = variable.NodeId,
AffectedType = variable.TypeDefinitionId,
Verb = (byte)ModelChangeStructureVerbMask.DataTypeChanged,
};
// SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
// relies on for its child PropertyStates).
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
return e;
}
/// Report the built GeneralModelChangeEvent through the SDK server, guarding against the
/// event path being disabled / having no subscribers / a transient failure — a surprise here MUST NOT
/// break the in-place update that already happened (mirrors ).
/// The variable whose shape changed.
private void ReportNodeShapeChangedEvent(BaseDataVariableState variable)
{
try
{
ReportToServerObject(BuildNodeShapeChangedEvent(variable));
}
catch (Exception ex)
{
// Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
// no-op or throw; either way the in-place update already stands. Log to the SDK trace, don't rethrow.
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {NodeId}", variable.NodeId);
}
}
///
/// Emit a Part 3 GeneralModelChangeEvent (verb NodeAdded) announcing that one or more
/// nodes were added UNDER at runtime — so already-connected,
/// model-aware OPC UA clients re-browse the affected node and discover the new children. This is the
/// runtime-add counterpart of the shape-changed reporter ():
/// when a driver discovers FixedTree nodes AFTER the server is up and they are materialised into the
/// served Equipment address space (Tasks 4/5), an attribute notification alone is invisible to a
/// subscribed client — only a model-change event tells it the address space grew.
///
/// The event is built under Lock but reported AFTER the lock is released, mirroring
/// / :
/// Server.ReportEvent re-enters the server's own subscription/event path, so holding the node
/// Lock across it risks a lock-order inversion with a client that has event subscriptions.
/// The report is wrapped in try/catch so it is tolerant when eventing is disabled / there are no
/// monitored items / the server is shutting down — the same swallow-and-log tolerance as the
/// write-revert path (). The nodes have already been materialised, so
/// a surprise from the event path MUST NOT propagate out of this announcement.
///
///
/// The folder-scoped node id of the parent under which nodes were added.
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(affectedNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
e = BuildNodesAddedModelChange(affectedNodeId, realm);
}
// Report OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; holding
// Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
try
{
ReportToServerObject(e);
}
catch (Exception ex)
{
// Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
// no-op or throw; either way the node add already stands. Log to the SDK trace, don't rethrow.
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {NodeId}", affectedNodeId);
}
}
///
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
string targetNodeId, AddressSpaceRealm targetRealm,
string referenceType = "Organizes")
{
ArgumentException.ThrowIfNullOrEmpty(sourceNodeId);
ArgumentException.ThrowIfNullOrEmpty(targetNodeId);
EnsureAddressSpaceCreated();
var refType = ResolveReferenceType(referenceType);
var sourceKey = MapKey(sourceRealm, sourceNodeId);
var targetKey = MapKey(targetRealm, targetNodeId);
lock (Lock)
{
var source = ResolveNodeState(sourceKey);
var target = ResolveNodeState(targetKey);
// A missing endpoint is a no-op (logged) — the applier may drive the cross-tree link while a
// concurrent rebuild has torn one side down; never throw out of a deploy-time reference wire.
if (source is null || target is null)
{
_logger.LogWarning(
"OtOpcUaNodeManager: AddReference({RefType}) skipped — {Side} node missing (source '{SourceKey}' present={SourcePresent}, target '{TargetKey}' present={TargetPresent}).",
refType, source is null ? "source" : "target", sourceKey, source is not null, targetKey, target is not null);
return;
}
// Idempotent: a re-apply must not duplicate the edge. Organizes is hierarchical, so wire it
// bidirectionally — forward on the source, inverse (OrganizedBy) on the target — so browse
// resolves both ways. Only ClearChangeMasks a side we actually mutated.
var sourceChanged = false;
var targetChanged = false;
if (!source.ReferenceExists(refType, isInverse: false, target.NodeId))
{
source.AddReference(refType, isInverse: false, target.NodeId);
sourceChanged = true;
}
if (!target.ReferenceExists(refType, isInverse: true, source.NodeId))
{
target.AddReference(refType, isInverse: true, source.NodeId);
targetChanged = true;
}
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// Resolve a materialised node (variable, folder, or alarm condition) by its full
/// (namespace-qualified) map key, or null when no node is registered under that key. Used by
/// to link two already-materialised nodes regardless of their kind.
/// The namespace-qualified map key (see ).
/// The node state, or null when unknown.
private NodeState? ResolveNodeState(RealmKey key)
{
if (_variables.TryGetValue(key, out var variable)) return variable;
if (_folders.TryGetValue(key, out var folder)) return folder;
if (_alarmConditions.TryGetValue(key, out var condition)) return condition;
return null;
}
/// Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified
/// id) using the manager's own SystemContext, or an empty list when the node is unknown. Exposed
/// so SdkAddressSpaceSinkTests can assert the cross-tree Organizes edge
/// wires without needing the (protected) SDK system context.
/// The node's s= id.
/// The realm the node lives in.
/// The node's references, or an empty list when the node is not registered.
internal IReadOnlyList GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
var node = ResolveNodeState(MapKey(realm, nodeId));
if (node is null) return Array.Empty();
var list = new List();
node.GetReferences(SystemContext, list);
return list;
}
}
/// Map a hierarchical reference-type name to its SDK . Unknown names fall
/// back to (the UNS→Raw cross-tree link).
/// The reference-type name (e.g. "Organizes").
/// The reference-type NodeId.
private static NodeId ResolveReferenceType(string referenceType) => referenceType switch
{
"HasComponent" => ReferenceTypeIds.HasComponent,
"HasProperty" => ReferenceTypeIds.HasProperty,
_ => ReferenceTypeIds.Organizes,
};
/// Build (but do not report) the Part 3 GeneralModelChangeEvent announcing that nodes were
/// added under . MIRRORS exactly —
/// the only differences are Verb = NodeAdded (vs DataTypeChanged) and Affected = the
/// passed parent node id (vs the variable's own NodeId). AffectedType carries the affected node's
/// TypeDefinition resolved from the live node maps (same semantics the shape-changed builder gets from the
/// variable), defaulting to when the id is not (yet) materialised. internal
/// (not private) so a node-manager test can assert the populated Changes structure at the nearest
/// deterministic seam (the end-to-end Server.ReportEvent dispatch would need a subscribed event
/// monitored-item to observe).
/// The folder-scoped node id of the parent under which nodes were added.
/// A populated, unreported .
internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Nodes added under {affected}"));
// Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
// to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
// so conformant clients that filter events by SourceNode still match this one.
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = affected,
// The affected node is the parent the children were added under; carry its TypeDefinition (a Folder
// for an equipment parent) just as the shape-changed builder carries the variable's. Null when the
// id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless.
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
Verb = (byte)ModelChangeStructureVerbMask.NodeAdded,
};
// SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
// relies on for its child PropertyStates).
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
return e;
}
/// Build (but do not report) the Part 3 GeneralModelChangeEvent announcing that the node
/// at was DELETED. Mirrors
/// exactly — the only differences are Verb = NodeDeleted and that the affected node has already
/// been dropped from the maps, so AffectedType resolves to (a valid Part 3
/// "type not applicable"; clients re-browse the parent regardless). internal so a node-manager test
/// can assert the populated Changes structure at the nearest deterministic seam.
/// The node id of the deleted node.
/// A populated, unreported .
internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Node deleted: {affected}"));
// Part 3 §8.7.4: emitted by the Server object — set SourceNode/SourceName to Server explicitly
// (mirrors BuildNodesAddedModelChange).
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = affected,
// The node is already gone from the maps, so its TypeDefinition is not applicable (Null).
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted,
};
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
return e;
}
/// Report a pre-built OUTSIDE Lock —
/// Server.ReportEvent re-enters the server's own subscription/event path, so holding Lock
/// across it risks a lock-order inversion (mirrors /
/// ReportNodeShapeChangedEvent). Tolerant: swallow-and-log when eventing is disabled / there are no
/// monitored items / the server is shutting down — the node mutation already stands.
/// The pre-built event to report.
/// The affected node id (for the diagnostic only).
private void ReportModelChangeOutsideLock(GeneralModelChangeEventState e, string affectedNodeId)
{
try
{
ReportToServerObject(e);
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {NodeId}", affectedNodeId);
}
}
/// Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
/// model-change event's AffectedType; when the id is not registered.
/// The folder-scoped node id whose TypeDefinition is wanted.
private NodeId ResolveAffectedTypeDefinition(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
var key = MapKey(realm, nodeId);
if (_folders.TryGetValue(key, out var folder)) return folder.TypeDefinitionId;
if (_variables.TryGetValue(key, out var variable)) return variable.TypeDefinitionId;
return NodeId.Null;
}
/// Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
/// (matches CreateVariable's default for lazy-created nodes).
private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
{
"Boolean" => DataTypeIds.Boolean,
"SByte" => DataTypeIds.SByte,
"Byte" => DataTypeIds.Byte,
"Int16" => DataTypeIds.Int16,
"UInt16" => DataTypeIds.UInt16,
"Int32" => DataTypeIds.Int32,
"UInt32" => DataTypeIds.UInt32,
"Int64" => DataTypeIds.Int64,
"UInt64" => DataTypeIds.UInt64,
"Float" => DataTypeIds.Float,
"Double" => DataTypeIds.Double,
"String" => DataTypeIds.String,
"DateTime" => DataTypeIds.DateTime,
_ => DataTypeIds.BaseDataType,
};
/// Clear every registered variable + folder from the address space. AddressSpaceApplier
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
/// EnsureFolder + WriteValue on the next pass.
public void RebuildAddressSpace()
{
lock (Lock)
{
foreach (var v in _variables.Values)
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
// conditions are not captured — their engine re-asserts them after the reload (#487).
_rebuildCarryOver.Clear();
_restoredConditionReports.Clear();
foreach (var (key, alarm) in _alarmConditions)
{
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
}
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
ForgetPredefinedSubtree(alarm);
}
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
UnwireAlarmNotifiers(alarmKey);
_alarmConditions.Clear();
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
_nativeAlarmNodeIds.Clear();
foreach (var f in _folders.Values)
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
_folders.Clear();
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
// teardown above doesn't matter — but it must run under this same Lock.
foreach (var folder in _notifierFolders.Values)
{
RemoveRootNotifier(folder);
}
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
// equipment folders to event notifiers.
_notifierFolders.Clear();
// Phase C: drop the event-history source registrations alongside the notifier folders
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
_eventNotifierSources.Clear();
ReconcileAllEventsMonitoring();
}
}
///
///
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
///
public void CompleteAddressSpaceApply()
{
int reported = 0, discarded;
lock (Lock)
{
foreach (var (key, alarm) in _restoredConditionReports)
{
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
// newer instant of a report that did (a client method call on an SDK thread can land between the
// restore and here) — never roll the condition's Time back.
ReportConditionEvent(alarm, alarm.Time.Value);
reported++;
}
discarded = _rebuildCarryOver.Count;
_restoredConditionReports.Clear();
_rebuildCarryOver.Clear();
}
if (reported > 0 || discarded > 0)
{
_logger.LogInformation(
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
reported, discarded);
}
}
///
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, variableNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
// 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds
// BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a
// condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference
// variable, inverse (OrganizedBy) on the backing RAW node. The raw node SURVIVES this scoped
// remove, so without this it keeps a dangling inverse reference to a node that no longer exists —
// browsable, unresolvable, and permanent.
//
// Why it matters more now than it used to: a full RebuildAddressSpace clears every node and every
// reference with it, so these edges were swept up on the next structural deploy. Surgical applies
// made rebuilds rare, so the dangling edges now ACCUMULATE on the raw node for the life of the
// process — one per re-point or effective-name edit of any reference that ever pointed at it.
SeverOutgoingReferences(variable);
variable.Parent?.RemoveChild(variable);
PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(key, out _);
_arrayOnScalarRefused.TryRemove(key, out _);
e = BuildNodesRemovedModelChange(variableNodeId, realm);
}
ReportModelChangeOutsideLock(e, variableNodeId);
return true;
}
///
/// 2026-08-15 review I3 — remove the INVERSE half, held on the far node, of every FORWARD reference
/// carries to another node this manager owns. The exact reverse of the
/// bidirectional wiring in , and the variable-node counterpart of
/// . MUST be called under Lock.
/// Only FORWARD references are walked. A node's link to its own parent manifests as an INVERSE
/// reference (the parent owns the forward half through AddChild), and the parent's side of that
/// is severed by Parent.RemoveChild — walking inverse references too would try to unwire the
/// parent link a second time. A forward reference whose target is not one of this manager's nodes is
/// skipped: there is nothing here to mutate, and the target's own owner is responsible for it.
///
/// The node being removed, whose outgoing edges' inverse halves must be severed.
private void SeverOutgoingReferences(NodeState node)
{
var references = new List();
node.GetReferences(SystemContext, references);
foreach (var r in references)
{
if (r.IsInverse || r.TargetId is null || r.TargetId.IsAbsolute) continue;
var targetNodeId = (NodeId)r.TargetId;
if (NodeId.IsNull(targetNodeId)) continue;
if (ResolveNodeState(MapKey(targetNodeId)) is not { } target) continue;
if (!target.RemoveReference(r.ReferenceTypeId, isInverse: true, node.NodeId)) continue;
target.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
///
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, alarmNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ map drift ⇒ false (caller rebuilds). Mirrors the condition teardown inside
// RebuildAddressSpace, scoped to this one condition. The equipment folder's event-notifier
// promotion is intentionally NOT demoted here — other conditions may still hang under it, and a
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
// this they would keep a dangling inverse-notifier entry to the removed condition.
UnwireAlarmNotifiers(key);
condition.Parent?.RemoveChild(condition);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
ReconcileAllEventsMonitoring();
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
}
ReportModelChangeOutsideLock(e, alarmNodeId);
return true;
}
///
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
EnsureAddressSpaceCreated();
var equipKey = MapKey(realm, equipmentNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipKey)) return false;
// Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly
// or begins with "/" (sub-folders, variables, condition nodes); the explicit realm
// compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s="
// — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot
// each map's in-scope keys first (ToList) so we don't mutate while enumerating.
var prefix = equipmentNodeId + "/";
bool InScope(RealmKey key) => key.Realm == realm
&& (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal));
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
{
if (_variables.TryRemove(id, out var v))
{
// Gitea #599 (archreview 03/S20): sever the inverse half of each cross-tree Organizes edge
// first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a
// RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge.
SeverOutgoingReferences(v);
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
_arrayOnScalarRefused.TryRemove(id, out _);
}
// Part 9 condition nodes (+ their native flags).
foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
{
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
ForgetPredefinedSubtree(c);
}
_nativeAlarmNodeIds.Remove(id);
}
ReconcileAllEventsMonitoring();
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
foreach (var wiring in _alarmNotifierWiring.Values)
{
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
wiring.Folders.Remove(folder);
}
}
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
// _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key.
foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList())
{
RemoveRootNotifier(kvp.Value);
_notifierFolders.Remove(kvp.Key);
}
// Drop the event-history source registrations for the subtree (keyed by the folder id string).
foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
_eventNotifierSources.TryRemove(src, out _);
// Sub-folders + the equipment folder itself (children already detached above).
foreach (var id in _folders.Keys.Where(InScope).ToList())
{
if (_folders.TryRemove(id, out var f))
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
}
e = BuildNodesRemovedModelChange(equipmentNodeId, realm);
}
ReportModelChangeOutsideLock(e, equipmentNodeId);
return true;
}
private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm)
{
EnsureAddressSpaceCreated();
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
// A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment
// folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root.
return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!;
}
/// Guard the address-space mutators against a too-early call. _root
/// is only assigned in , which the SDK invokes during
/// StandardServer start; every public mutator (, ,
/// , , ,
/// ) and assume it has run.
/// If one is invoked before the server has started (a sink wired or a publish replayed before
/// StartAsync completes), _root is null and the dereference would NRE; throw a legible
/// instead. Happy-path behaviour is unchanged.
/// When the address space has not been created yet.
private void EnsureAddressSpaceCreated()
{
if (_root is null)
{
throw new InvalidOperationException(
"OPC UA address space has not been created yet (server not started).");
}
}
// ---------------------------------------------------------------------------------------------
// #520 stage 6 — Read / Browse / TranslateBrowsePath / subscribe gates.
//
// These are the request-rate seams, so every decision here goes through the authorizer's CACHED
// entry point: one Browse of a device folder asks the same question once per child, and a client
// reading a thousand nodes asks it a thousand times.
//
// The gates resolve a node purely from its NodeId — namespace index for ownership, `s=` identifier
// for the scope — and never call GetManagerHandle. That is not only cheaper: it means a DENIED node
// is never resolved, never read, and never touches a driver.
//
// ⚠️ A consequence worth knowing: a NodeId in one of our namespaces that does not exist now answers
// BadUserAccessDenied instead of BadNodeIdUnknown while enforcement is on, because the gate runs
// before resolution. That is the information-hiding direction — an ungranted client can no longer
// use error codes to enumerate which nodes exist — but it does mean "access denied" is not proof
// that the node is there.
// ---------------------------------------------------------------------------------------------
/// True when is minted in one of this manager's two namespaces.
/// The node id to test.
/// when this manager owns the node's namespace.
private bool OwnsNamespace(NodeId? nodeId) =>
nodeId is not null
&& (nodeId.NamespaceIndex == NamespaceIndexes[0] || nodeId.NamespaceIndex == NamespaceIndexes[1]);
/// Pulls the ACL identity off an SDK operation context.
/// The SDK context carrying the session identity.
/// The role-carrying identity, or null when the session has none.
private static RoleCarryingUserIdentity? IdentityOf(OperationContext context) =>
context.UserIdentity as RoleCarryingUserIdentity;
/// Pulls the ACL identity off a system context (the protected SDK seams).
/// The SDK system context.
/// The role-carrying identity, or null when the session has none.
private static RoleCarryingUserIdentity? IdentityOf(ServerSystemContext context) =>
(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
/// Whether the session may perform on .
/// The calling session's identity.
/// The operation being attempted.
/// The target node.
/// when permitted, or when the node is not ours to gate.
private bool Permits(RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId) =>
!OwnsNamespace(nodeId)
|| _authorizer!.IsAllowedCached(identity, operation, nodeId, RealmOf(nodeId));
///
///
/// Denied slots are answered and marked before the base
/// runs, so a refused read never resolves the node or samples its value. Every attribute of a node
/// is gated by the single Read permission rather than only Value: the finer split
/// would let an ungranted session harvest BrowseNames, DataTypes and descriptions of nodes it
/// cannot read, which is most of what the address space discloses.
///
public override void Read(
OperationContext context,
double maxAge,
IList nodesToRead,
IList values,
IList errors)
{
if (AclEnforced)
{
var identity = IdentityOf(context);
var denied = 0;
for (var ii = 0; ii < nodesToRead.Count; ii++)
{
var nodeToRead = nodesToRead[ii];
if (nodeToRead.Processed || !OwnsNamespace(nodeToRead.NodeId)) continue;
if (Permits(identity, OpcUaOperation.Read, nodeToRead.NodeId)) continue;
nodeToRead.Processed = true;
errors[ii] = StatusCodes.BadUserAccessDenied;
values[ii] = new DataValue(StatusCodes.BadUserAccessDenied);
denied++;
}
LogAclFiltering("Read", identity, denied, nodesToRead.Count);
}
base.Read(context, maxAge, nodesToRead, values, errors);
}
///
///
///
/// Two distinct checks: the browsed node itself must be reachable, and every reference the base
/// returns is filtered against the target's own grant. Filtering only the source would list
/// children the session may not see; refusing only the source would make every narrow grant
/// unreachable, since a client arrives at a deep node by browsing its ancestors.
///
///
/// Reachability comes from PermissionTrie.HasBrowseTransit — a node is browsable when it
/// merely leads somewhere the principal may browse. Note what this necessarily discloses: the
/// names of the folders on the path to a granted node are visible to anyone holding
/// that grant, because navigation requires them.
///
///
/// ⚠️ There is ONE shared root folder (V3NodeIds.SharedRootFolderId, minted in the raw
/// namespace by ) fronting BOTH realm trees — not one root per
/// namespace. It is referenced from the ns=0 Objects folder, whose browse is served by
/// the SDK's own node manager and is not gated here, so a session with no grants at all still
/// sees that the root exists — and is refused on entry. A session holding any Browse grant in
/// either realm enters it via the zero-segment realm-root scope
/// Security.DataPlaneAuthorizer.BuildScope maps the anchor to.
///
///
public override void Browse(
OperationContext context,
ref ContinuationPoint continuationPoint,
IList references)
{
if (!AclEnforced)
{
base.Browse(context, ref continuationPoint, references);
return;
}
var identity = IdentityOf(context);
// NodeToBrowse holds the MANAGER HANDLE the master node manager resolved — a NodeHandle for this
// manager — not a NodeState. The declared type is `object`, so casting it to the wrong thing
// compiles cleanly and silently yields null, which would skip the source gate entirely and leave
// only the reference filtering below.
var source = (continuationPoint?.NodeToBrowse as NodeHandle)?.NodeId;
if (source is not null && !Permits(identity, OpcUaOperation.Browse, source))
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
// Only the references THIS call appends are ours to filter — on a BrowseNext the list already
// carries the earlier pages, and re-examining them would both waste work and skew the summary.
var before = references.Count;
base.Browse(context, ref continuationPoint, references);
var returned = references.Count - before;
// Forward two-pointer compaction over the appended range, NOT a per-denial RemoveAt. Removing
// from the middle shifts every later element down one slot, so denying a large prefix of a wide
// browse costs O(kept × removed) element moves; copying each survivor down to a write cursor and
// trimming the tail afterwards is one pass. The tail trim removes from the END, where RemoveAt
// moves nothing.
var filtered = 0;
var write = before;
for (var ii = before; ii < references.Count; ii++)
{
// ExpandedNodeId → NodeId only makes sense for a reference into our own server; a reference
// to another server's node is left alone, as this manager has no grants that could speak to it.
var target = references[ii].NodeId;
var keep = target is null
|| target.IsAbsolute
|| ExpandedNodeId.ToNodeId(target, Server.NamespaceUris) is not { } targetId
|| !OwnsNamespace(targetId)
|| Permits(identity, OpcUaOperation.Browse, targetId);
if (!keep)
{
filtered++;
continue;
}
if (write != ii) references[write] = references[ii];
write++;
}
for (var ii = references.Count - 1; ii >= write; ii--) references.RemoveAt(ii);
LogAclFiltering("Browse", identity, filtered, returned);
}
///
///
/// Gated for the same reason Browse is, and it is not optional: without it a client denied a browse
/// simply asks TranslateBrowsePathsToNodeIds for the same path and gets the NodeId back, which
/// would make the reference filtering above decorative.
///
public override void TranslateBrowsePath(
OperationContext context,
object sourceHandle,
RelativePathElement relativePath,
IList targetIds,
IList unresolvedTargetIds)
{
if (!AclEnforced)
{
base.TranslateBrowsePath(context, sourceHandle, relativePath, targetIds, unresolvedTargetIds);
return;
}
var identity = IdentityOf(context);
if (sourceHandle is NodeHandle handle
&& !Permits(identity, OpcUaOperation.Browse, handle.NodeId))
{
// Silence rather than an error: the SDK's contract here is "add what you resolved", and a
// path that resolves to nothing is the same answer the client gets for a path that does not
// exist — which is the answer we want an ungranted session to receive.
return;
}
var before = targetIds.Count;
base.TranslateBrowsePath(context, sourceHandle, relativePath, targetIds, unresolvedTargetIds);
// Forward two-pointer compaction over the appended range — same reasoning as Browse above: a
// mid-list RemoveAt per denial is O(kept × removed) element moves, one compacting pass plus a
// tail trim is O(n).
var write = before;
for (var ii = before; ii < targetIds.Count; ii++)
{
var targetId = ExpandedNodeId.ToNodeId(targetIds[ii], Server.NamespaceUris);
var keep = targetId is null
|| !OwnsNamespace(targetId)
|| Permits(identity, OpcUaOperation.Browse, targetId);
if (!keep) continue;
if (write != ii) targetIds[write] = targetIds[ii];
write++;
}
for (var ii = targetIds.Count - 1; ii >= write; ii--) targetIds.RemoveAt(ii);
}
///
///
/// Per item, not per batch — OPC UA Part 4 gives each item its own result, and a client subscribing
/// to ten nodes of which one is off-limits should get nine subscriptions and one denial.
///
public override void CreateMonitoredItems(
OperationContext context,
uint subscriptionId,
double publishingInterval,
TimestampsToReturn timestampsToReturn,
IList itemsToCreate,
IList errors,
IList filterErrors,
IList monitoredItems,
bool createDurable,
MonitoredItemIdFactory monitoredItemIdFactory)
{
if (AclEnforced)
{
var identity = IdentityOf(context);
var denied = 0;
for (var ii = 0; ii < itemsToCreate.Count; ii++)
{
var item = itemsToCreate[ii];
var target = item.ItemToMonitor?.NodeId;
if (item.Processed || !OwnsNamespace(target)) continue;
if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!)) continue;
item.Processed = true;
errors[ii] = StatusCodes.BadUserAccessDenied;
denied++;
}
LogAclFiltering("CreateMonitoredItems", identity, denied, itemsToCreate.Count);
}
base.CreateMonitoredItems(
context, subscriptionId, publishingInterval, timestampsToReturn, itemsToCreate, errors,
filterErrors, monitoredItems, createDurable, monitoredItemIdFactory);
}
///
///
/// Event subscriptions are gated on the notifier they attach to. This is the alarm-delivery path:
/// without it, a session denied every value on an equipment could still subscribe to its event
/// notifier and receive every alarm the equipment raises, including the condition fields.
///
public override ServiceResult SubscribeToEvents(
OperationContext context,
object sourceId,
uint subscriptionId,
IEventMonitoredItem monitoredItem,
bool unsubscribe)
{
// ⚠️ sourceId is the MANAGER HANDLE, not a NodeState — the same `object`-typed trap as
// ContinuationPoint.NodeToBrowse. Casting it to NodeState compiles, always yields null, and
// silently disables this gate; IsHandleInNamespace is the SDK's own resolver and cannot be
// wrong about it.
//
// Only creation is gated. Refusing an UNSUBSCRIBE would strand the item on a node the session can
// no longer reach — the client could never detach it, and the server would keep delivering.
if (AclEnforced && !unsubscribe
&& IsHandleInNamespace(sourceId) is { } source
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, source.NodeId))
{
return StatusCodes.BadUserAccessDenied;
}
if (unsubscribe)
{
// Gitea #562: an item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out.
// Handing its delete to the SDK would make it remove an item that is not there — at best a
// spurious BadNodeIdUnknown on the client's delete, at worst a NullReferenceException inside
// MonitoredNode2.Remove when the node's event list has gone null. Checked and dropped under the
// same Lock the revalidation pass re-attaches under, so the two cannot interleave into
// re-attaching an item the client has just deleted.
lock (Lock)
{
if (_eventItems.TryRemove(monitoredItem, out var stoodDown) && stoodDown.Suppressed)
return ServiceResult.Good;
}
return base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe);
}
var result = base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe);
TrackLiveEventItem(sourceId, monitoredItem);
return result;
}
///
/// Starts (or refreshes) tracking a direct event subscription for revalidation (Gitea #562).
///
/// The manager handle the item subscribed through.
/// The event item.
///
///
/// Only items the SDK actually attached are tracked — a subscribe the SDK refused (a source that
/// is not an event notifier, an unknown node) has nothing to stand down. The SDK reports an item
/// that was already attached (a ModifyMonitoredItems re-subscribe) as a non-Good status
/// while having re-attached it anyway, so attachment is read off the fan-out list itself rather
/// than inferred from the status the subscribe returned.
///
///
/// A re-subscribe of an item that was stood down is the client re-asking, and it has just passed
/// the creation gate above, so the entry is replaced with a live one.
///
///
private void TrackLiveEventItem(object sourceId, IEventMonitoredItem monitoredItem)
{
if (IsHandleInNamespace(sourceId) is not { } handle || !OwnsNamespace(handle.NodeId)) return;
lock (Lock)
{
if (MonitoredNodes.TryGetValue(handle.NodeId, out var monitored)
&& monitored.EventMonitoredItems?.Contains(monitoredItem) == true)
{
_eventItems[monitoredItem] = new LiveEventItem(handle);
}
}
}
///
///
/// Re-checked on every mode change so a client cannot re-enable an item that
/// disabled when its grant was revoked. Disabling is always
/// allowed: a session that has lost its grant must still be able to stand its items down.
///
protected override ServiceResult SetMonitoringMode(
ServerSystemContext context,
IMonitoredItem monitoredItem,
MonitoringMode monitoringMode,
NodeHandle handle)
{
if (AclEnforced && monitoringMode != MonitoringMode.Disabled
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, handle.NodeId))
{
return StatusCodes.BadUserAccessDenied;
}
return base.SetMonitoringMode(context, monitoredItem, monitoringMode, handle);
}
///
protected override void OnMonitoredItemCreated(
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
base.OnMonitoredItemCreated(context, handle, monitoredItem);
TrackLiveMonitoredItem(handle, monitoredItem);
}
/// Starts tracking for revalidation, if we own its node.
/// The handle the item was created against.
/// The item.
///
/// Split out of so tests can register an item without a client
/// session; the SDK reaches it only through that override.
///
internal void TrackLiveMonitoredItem(NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
if (OwnsNamespace(handle.NodeId)) _liveItems[monitoredItem] = new LiveItem(handle);
}
///
protected override void OnMonitoredItemDeleted(
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
_liveItems.TryRemove(monitoredItem, out _);
base.OnMonitoredItemDeleted(context, handle, monitoredItem);
}
///
/// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment
/// can reach the subscriptions it was authorizing.
///
///
/// Keyed by the item itself; the value is the node it monitors. Maintained by
/// / , which the SDK calls
/// for every create and delete including subscription teardown, so the map cannot outlive the items.
///
private readonly ConcurrentDictionary _liveItems = new();
/// A tracked monitored item: the handle it was created against, and whether we have silenced it.
/// The node handle, which carries the fan-out list.
private sealed record LiveItem(NodeHandle Handle)
{
/// True while the item has been detached from its node's fan-out for want of a grant.
public bool Suppressed { get; set; }
}
///
/// Live event monitored items subscribed directly to a notifier this manager owns (Gitea #562),
/// the event-side twin of .
///
///
///
/// A second map because the SDK keeps the two kinds apart: a data-change item reaches this
/// manager through , an event item only through
/// , and
/// they hang off different lists of . Before #562 nothing tracked event
/// items at all, so an alarm feed authorized at creation survived every later revocation.
///
///
/// Maintained by that override's subscribe/unsubscribe arms — the SDK routes every create, modify
/// and delete of a direct event item through it, subscription teardown included. Mutated under
/// Lock, so the membership check the revalidation pass makes before re-attaching an item
/// cannot race a client deleting it.
///
///
/// Server-object (all-events) subscriptions are not in this map. They are not attached to
/// any notifier of ours at all; they are held in and decided per
/// event at delivery (Gitea #586), so there is nothing for this map's stand-down to detach.
///
///
private readonly ConcurrentDictionary _eventItems = new();
///
/// Live Server-object (i=2253) event monitored items — every item the SDK handed this manager
/// through (Gitea #586).
///
///
/// A set (the value is unused). Maintained by 's subscribe and
/// unsubscribe arms, which the SDK's MasterNodeManager drives for every create, modify,
/// transfer, restore and delete of an all-events item, subscription teardown included.
///
private readonly ConcurrentDictionary _allEventsItems = new();
///
///
///
/// Gitea #586. The SDK's base implementation attaches a Server-object item to the event
/// fan-out of every one of this manager's root notifiers, so every alarm in the server reached it
/// with no ACL decision at all — and, for an item created before a root notifier existed (the
/// SDK's AddRootNotifier calls its subscribe with unsubscribe: true), through the
/// root notifier's forward to Server.ReportEvent and the core node manager's Server object,
/// which this manager never sees. The Server object is where every condition lands exactly once,
/// so it was the one feed carrying every alarm, ungated.
///
///
/// This override attaches the item to nothing. It records it, and each condition hands its events
/// to the recorded items itself (), deciding every item against
/// the authorizer at delivery time. With no attachment there is no fan-out to keep in step with a
/// rebuild, and no stand-down to reverse: revalidation is implicit, because every event re-asks.
///
///
/// Creation is never refused: an all-events item is legitimate for a session granted part of the
/// address space, so what is withheld is each event the session may not see, not the item.
///
///
public override ServiceResult SubscribeToAllEvents(
OperationContext context, uint subscriptionId, IEventMonitoredItem monitoredItem, bool unsubscribe)
{
lock (Lock)
{
if (unsubscribe) _allEventsItems.TryRemove(monitoredItem, out _);
else _allEventsItems[monitoredItem] = 0;
ReconcileAllEventsMonitoring();
}
return ServiceResult.Good;
}
///
///
///
/// A Server-object (all-events) item is never attached to a notifier of ours (Gitea #586), so a
/// subscribe or unsubscribe of one against a notifier is a no-op here. The only SDK caller that
/// still reaches this with such an item is AddRootNotifier, which — an SDK bug — "subscribes"
/// every existing all-events item to a new root notifier with unsubscribe: true. Passed to the
/// base, that lowers on the new notifier, its children and
/// its forward notifiers — taking back, from every condition materialised under a live item, the
/// unit just gave it (and, before #586, a folder
/// subscriber's unit too). Caught by the rebuild leg of the monitored-bookkeeping test.
///
///
/// Direct notifier items, including the #562 revalidation pass's attach and detach, go to the base
/// unchanged.
///
///
protected override ServiceResult SubscribeToEvents(
ServerSystemContext context, NodeState source, IEventMonitoredItem monitoredItem, bool unsubscribe) =>
monitoredItem.MonitoringAllEvents
? ServiceResult.Good
: base.SubscribeToEvents(context, source, monitoredItem, unsubscribe);
///
/// The conditions currently carrying this manager's one "a Server-object item is listening" unit of
/// (Gitea #586 review).
///
/// Mutated only by , under Lock.
private readonly HashSet _allEventsMonitoredConditions =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
///
/// Keeps raised on every materialised condition while at
/// least one Server-object item exists, and lowered once none does. Call under Lock.
///
///
///
/// The SDK base attached an all-events item to every root notifier through its own
/// SubscribeToEvents, which also calls SetAreEventsMonitored(true, includeChildren)
/// on the notifier — a counter that propagates to every condition beneath or wired to it. Not
/// attaching the items lost that bookkeeping, and a condition reads it: ConditionState
/// reports its method audit events only while EventsMonitored() is true. A server whose only
/// subscribers are Server-object items — the common machine-consumer shape — would otherwise report
/// none of them. (The method-driven state-change events no longer depend on this: the SDK
/// reports none, and reports them unconditionally —
/// Gitea #587. That also takes the SDK counter's known imprecision — a folder subscriber's unit never
/// reaches a condition materialised under the folder later, yet its unsubscribe still lowers it — off
/// the state-change path; it can now cost at most an audit event while auditing is on.)
///
///
/// Held per condition, exactly one unit each, rather than per root notifier per item as the
/// base did. The counter is shared with the folder subscribers' own units, so it must balance
/// exactly; a notifier-level unit would also reach a condition materialised under an already-
/// raised folder never, or be taken back from one it never reached. This reconciles the desired
/// set (every condition, while any item exists) against what it has raised, so materialise,
/// remove, rebuild and subscribe all converge through one place.
///
///
private void ReconcileAllEventsMonitoring()
{
// The protected SubscribeToEvents override is what stops the SDK taking these units back.
lock (Lock)
{
var desired = _allEventsItems.IsEmpty
? new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance)
: new HashSet(
_alarmConditions.Values, System.Collections.Generic.ReferenceEqualityComparer.Instance);
AllEventsMonitoringConditionVisits += desired.Count + _allEventsMonitoredConditions.Count;
foreach (var condition in _allEventsMonitoredConditions.Where(c => !desired.Contains(c)).ToList())
{
condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
_allEventsMonitoredConditions.Remove(condition);
}
foreach (var condition in desired)
{
if (_allEventsMonitoredConditions.Add(condition))
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
}
}
}
///
/// The incremental form of for one newly materialised
/// condition: raises its unit when a Server-object item exists, touching no other condition. Call under
/// Lock.
///
/// The condition just added to _alarmConditions.
///
/// Equivalent to the full reconcile on the materialise path, because materialising adds one condition
/// and removes none (a same-key kind-swap gives the dropped instance's unit back itself). Kept separate
/// so a deploy of N alarms costs N set operations rather than N²/2 (review of Gitea #586).
///
private void RaiseAllEventsMonitoring(AlarmConditionState condition)
{
AllEventsMonitoringConditionVisits++;
if (!_allEventsItems.IsEmpty && _allEventsMonitoredConditions.Add(condition))
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
}
///
/// How many conditions the Server-object monitored bookkeeping has visited since start — every
/// condition a full walks, plus one per incremental
/// . Test observability: bounds the bookkeeping cost of a deploy.
///
/// Mutated only under Lock.
internal long AllEventsMonitoringConditionVisits { get; private set; }
///
///
///
/// The base is the delegate AddRootNotifier installs on a root notifier: it forwards every
/// event reaching that folder to Server.ReportEvent, where the core node manager's Server
/// object queues it to every all-events item with no ACL decision — the bypass Gitea #586 closes.
/// Server-object items now receive conditions from the condition itself
/// (), so the forward could only ever deliver an ungated
/// duplicate. Dropped.
///
///
/// Only condition events bubble through this manager's notifiers; the audit and model-change
/// events it raises never reach this, and go through instead
/// (Gitea #588).
///
///
protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
{
}
///
/// Delivers one condition event to the Server-object (all-events) subscriptions this manager holds,
/// item by item, through the data-plane ACL (Gitea #586).
///
/// The reporting context; a session-bearing one restricts delivery to that session.
/// The condition that reported.
/// The event — the condition's snapshot.
///
///
/// Installed as each condition's OnReportEvent in ,
/// so it sees every event the condition reports exactly once, whichever notifiers it then bubbles
/// through — the one-copy-at-the-Server-object behaviour the multi-notifier wiring relies on.
///
///
/// Everything except the ACL decision mirrors the SDK's MonitoredNode2.OnReportEvent, which
/// is what delivered these events before: the SDK's own role-permission check, and the rule that an
/// event reported under a session's context reaches only that session's items.
///
///
/// Inert while Security:Acl:Enabled is false — every item receives every condition, as it
/// always has.
///
///
private void OnConditionReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
{
if (_allEventsItems.IsEmpty) return;
var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
var reportingSession = (context as ISessionSystemContext)?.SessionId?.Identifier;
foreach (var item in _allEventsItems.Keys)
{
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
if (reportingSession is not null
&& item.Session?.Id?.Identifier is { } own
&& !own.Equals(reportingSession))
{
continue;
}
item.QueueEvent(e);
}
}
///
/// The SDK's audit-event guard from MonitoredNode2.OnReportEvent, reproduced exactly: an audit
/// event reaches an item only while the server is auditing, and only over a SignAndEncrypt or
/// https-binary endpoint.
///
/// The Server-object event item.
/// when the audit event may be queued to the item.
///
/// A condition reports its Part 9 method audit events (AuditConditionAcknowledgeEventType,
/// Comment, Enable, Shelving…) through its own ReportEvent, so they pass through
/// . Before #586 the Server-object copy took the root-notifier
/// forward, where ServerInternalData.ReportEvent dropped every audit event unless auditing was
/// on; without this guard they reached every Server-object item regardless (caught in review, flag off).
///
private bool IsAuditDeliverableTo(IEventMonitoredItem item)
{
if (!Server.Auditing) return false;
var endpoint = item.Session?.EndpointDescription;
return endpoint?.SecurityMode == MessageSecurityMode.SignAndEncrypt
|| endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport;
}
///
/// Reports a server-wide event this manager raises — a GeneralModelChangeEvent or an audit event
/// — to the Server object's (i=2253) subscribers, through the data-plane ACL when it is enforced
/// (Gitea #588).
///
/// The built event.
///
///
/// These events are not condition events, so they never pass a notifier of ours: they went straight
/// to Server.ReportEvent, where the core node manager's Server object queues them to every
/// all-events item with no ACL decision. A model-change event names the RawPath / UNS path of the
/// node it is about, so a session holding no grant learned the names of nodes it could not browse;
/// an audit write event would carry the written value to it too.
///
///
/// With enforcement on, this manager delivers them itself, item by item, to the all-events items it
/// already holds for #586 — the same audience Server.ReportEvent reached, since only Server-object
/// items receive what is reported at the Server object. Each item receives only what its session may
/// see (); everything else the SDK's Server object did — its audit
/// guard, its role-permission check — is kept. With enforcement off the event goes to
/// Server.ReportEvent exactly as before.
///
///
/// Called outside Lock, like the Server.ReportEvent call it replaces; the decision
/// takes no node-manager lock.
///
///
private void ReportToServerObject(BaseEventState e)
{
if (!AclEnforced)
{
Server.ReportEvent(SystemContext, e);
return;
}
// ServerInternalData.ReportEvent's own guard: no audit event leaves the server while it is not auditing.
if (e is AuditEventState && !Server.Auditing) return;
foreach (var item in _allEventsItems.Keys)
{
if (ServerObjectEventFor(item, e) is not { } visible) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
item.QueueEvent(visible);
}
}
///
/// What of a server-wide event 's session may receive with enforcement on: the
/// event itself, a copy narrowed to what it may see, or nothing (Gitea #588).
///
/// A Server-object event item.
/// The event.
/// The event to queue, or to withhold it.
///
///
/// -
/// A model-change event keeps only the changes whose Affected node the session may
/// Browse () — knowing a node exists is exactly what
/// Browse grants, ancestors of a granted node included.
///
/// -
/// An audit event needs the SDK's own delivery guard ()
/// and Read on its source node: an AuditWriteUpdateEvent carries the node's
/// prior value and the attempted one, which is what Read protects.
///
/// - Anything else is withheld: this manager reports no other server-wide event type, so an
/// unrecognised one fails closed rather than slipping through ungated.
///
///
private BaseEventState? ServerObjectEventFor(IEventMonitoredItem item, BaseEventState e)
{
var identity = item.EffectiveIdentity as RoleCarryingUserIdentity;
return e switch
{
GeneralModelChangeEventState modelChange => FilterModelChangeFor(identity, modelChange),
AuditEventState audit => IsAuditDeliverableTo(item)
&& audit.SourceNode?.Value is { IsNullNodeId: false } source
&& Permits(identity, OpcUaOperation.Read, source)
? audit
: null,
_ => null,
};
}
///
/// A model-change event narrowed to the changes whose affected node may
/// browse: the event itself when every change is visible, when none is, otherwise
/// a copy carrying only the visible ones (Gitea #588).
///
/// The session's identity.
/// The model-change event.
/// The event to deliver, or to withhold it.
///
/// Every builder in this manager emits a single change, so in practice the event is delivered whole or
/// not at all; the copy keeps a future multi-change event from leaking the hidden names, or from hiding
/// the visible ones. The copy keeps the original's EventId, time and source, with a neutral
/// Message (the builders' messages name the affected node). A change with no
/// Affected node names nothing and is kept.
///
internal GeneralModelChangeEventState? FilterModelChangeFor(
RoleCarryingUserIdentity? identity, GeneralModelChangeEventState e)
{
var changes = e.Changes?.Value;
if (changes is not { Length: > 0 }) return e;
var visible = changes
.Where(c => c.Affected is null || c.Affected.IsNullNodeId || Permits(identity, OpcUaOperation.Browse, c.Affected))
.ToArray();
if (visible.Length == changes.Length) return e;
if (visible.Length == 0) return null;
// The original Message names the affected node(s), so the copy carries a neutral one.
var copy = new GeneralModelChangeEventState(null);
copy.Initialize(
SystemContext, source: null, (EventSeverity)e.Severity.Value, new LocalizedText($"{visible.Length} node(s) changed"));
copy.EventId.Value = e.EventId.Value;
copy.Time.Value = e.Time.Value;
copy.ReceiveTime.Value = e.ReceiveTime.Value;
copy.SetChildValue(SystemContext, BrowseNames.SourceNode, e.SourceNode?.Value ?? ObjectIds.Server, false);
copy.SetChildValue(SystemContext, BrowseNames.SourceName, e.SourceName?.Value ?? "Server", false);
copy.SetChildValue(SystemContext, BrowseNames.Changes, visible, false);
return copy;
}
///
/// The nodes whose grant admits a condition to a session: the condition itself, every root-notifier
/// folder its events bubble up through, and every equipment folder wired as its extra notifier.
///
/// The condition.
/// The candidate scopes; Subscribe on any one of them admits the condition.
///
///
/// The folders are exactly the per-folder path's question. A session that may subscribe to a
/// notifier the condition reports through already receives the condition there, so the Server
/// object withholding it would protect nothing; a session granted on none of them receives it
/// nowhere. The folders must be asked as well as the condition because the trie inherits downward
/// only within a realm: an Equipment grant covers the UNS folder a native condition is wired
/// into, but not the condition, which lives at its RawPath in the raw realm.
///
///
/// The condition itself (ConditionId = RawPath for a native alarm) admits a grant scoped to
/// the alarm's own raw tag — the tag's grant covers its alarm wherever the alarm can be named.
///
///
private List ConditionVisibilityScopes(NodeState condition)
{
var scopes = new List { condition.NodeId };
lock (Lock)
{
for (var ancestor = (condition as BaseInstanceState)?.Parent;
ancestor is not null;
ancestor = (ancestor as BaseInstanceState)?.Parent)
{
if (_notifierFolders.TryGetValue(ancestor.NodeId, out var notifier) && ReferenceEquals(notifier, ancestor))
scopes.Add(ancestor.NodeId);
}
if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
&& ReferenceEquals(wiring.Alarm, condition))
{
scopes.AddRange(wiring.Folders.Select(f => f.NodeId));
}
}
return scopes;
}
/// Whether 's session may see a condition with the given scopes.
/// The Server-object event item.
/// From .
/// when the session holds Subscribe on any scope.
///
/// The identity is the one the item was created under — the thread delivering the event has no
/// session of its own — and the operation is the one the per-folder creation gate asks.
/// re-checks key revocation and expiry on
/// every call and keys its decision cache on the current generation, which is what makes revalidation
/// implicit here: a deployment or an expiry takes effect on the next event, with no pass to run.
///
private bool IsConditionVisibleTo(IEventMonitoredItem item, List scopes)
{
var identity = item.EffectiveIdentity as RoleCarryingUserIdentity;
foreach (var scope in scopes)
{
if (_authorizer!.IsAllowedCached(identity, OpcUaOperation.CreateMonitoredItems, scope, RealmOf(scope)))
return true;
}
return false;
}
///
///
///
/// A refresh on a Server-object item is the other way it learns about conditions — it is how a
/// client connecting after a transition gets the current alarm picture — and the SDK base replays
/// every condition under every root notifier to it after only its own role-permission check. With
/// enforcement on, those items are refreshed here instead, through the same
/// decision live delivery uses (Gitea #586); an event that names no
/// condition is withheld. With it off, the base runs unchanged (its per-notifier repeats of one
/// condition share an EventId, which the item's queue already collapses to one copy).
///
///
/// Items on one of this manager's own notifiers keep the base behaviour: a stood-down item is off
/// its notifier's fan-out, which the base already treats as "not refreshed".
///
///
public override ServiceResult ConditionRefresh(OperationContext context, IList monitoredItems)
{
if (!AclEnforced) return base.ConditionRefresh(context, monitoredItems);
var direct = new List();
var serverItems = new List();
foreach (var item in monitoredItems)
{
if (item is null) continue;
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
}
if (direct.Count > 0) base.ConditionRefresh(context, direct);
if (serverItems.Count == 0) return ServiceResult.Good;
var systemContext = SystemContext.Copy(context);
List notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
var events = new List();
foreach (var notifier in notifiers) notifier.ConditionRefresh(systemContext, events, true);
// One snapshot per condition, with its scopes resolved once.
var conditions = new List<(IFilterTarget Event, List Scopes)>();
var seen = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance);
foreach (var e in events)
{
if (ConditionOf(e) is not { } condition || !seen.Add(condition)) continue;
conditions.Add((e, ConditionVisibilityScopes(condition)));
}
foreach (var item in serverItems)
{
foreach (var (e, scopes) in conditions)
{
if (!IsConditionVisibleTo(item, scopes)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
item.QueueEvent(e);
}
}
return ServiceResult.Good;
}
/// The condition a refresh event describes, or null when it names none (withheld: fail closed).
/// A refresh event.
/// The condition node.
private static NodeState? ConditionOf(IFilterTarget e) => e switch
{
InstanceStateSnapshot { Handle: NodeState condition } => condition,
NodeState condition => condition,
_ => null,
};
///
/// Whether the condition at believes it is being listened to
/// (ConditionState.EventsMonitored()) — the gate on the SDK reporting its method-driven state
/// changes. Test observability for the Gitea #586 review.
///
/// The condition's NodeId identifier.
/// The condition's realm.
/// when the condition exists and reports itself monitored.
internal bool ConditionEventsMonitored(string conditionNodeId, AddressSpaceRealm realm)
{
lock (Lock)
{
return _alarmConditions.TryGetValue(MapKey(realm, conditionNodeId), out var condition)
&& condition.EventsMonitored();
}
}
///
/// How many event items are attached to the event fan-out of nodes this manager owns. Test
/// observability for Gitea #562: an item resurrected onto a notifier after its client deleted it is
/// invisible to that client, so only the server side can show it.
///
internal int AttachedEventItemCount
{
get
{
lock (Lock) return MonitoredNodes.Values.Sum(n => n.EventMonitoredItems?.Count ?? 0);
}
}
/// A tracked event item: the notifier it subscribed to, and whether we have stood it down.
/// The notifier's handle; its is what the item is attached to.
private sealed record LiveEventItem(NodeHandle Handle)
{
/// True while the item has been detached from its notifier's event fan-out for want of a grant.
public bool Suppressed { get; set; }
}
///
/// Re-authorizes every live monitored item against the current generation, standing down the ones
/// whose grant has gone.
///
///
///
/// Why this is not optional. Stage 5 made revocation reach an open session for write,
/// alarm commands and HistoryRead, because each of those re-asks on every request. A
/// subscription never re-asks: it is authorized once at creation and then streams. Subscriptions
/// are also how this server is actually consumed — ScadaBridge's Data Connection Layer
/// subscribes rather than polls — so without this, revoking a grant would stop the operations
/// nobody uses and leave the data flowing on the one everybody does.
///
///
/// A denied item is told, then detached: queued a BadUserAccessDenied so the client
/// learns why its data stopped rather than watching a feed go quiet, then removed from its node's
/// fan-out so no further value reaches it.
///
///
/// ⚠️ Detaching, not — and the order matters. The
/// obvious implementation is to queue the denial and then disable the item; it does not work,
/// and it fails in the quiet direction. Disabling an item discards its queued notifications,
/// so the client is silenced without ever being told why. This was caught by the over-the-wire
/// test, not by reasoning: the unit-level path reports the item as stood down either way.
///
///
/// Detaching is also reversible, which disabling is not from the server's side. A later
/// deployment that restores the grant re-attaches the item and the feed resumes on its own — a
/// disabled item would have needed the client to notice and re-enable it, which a machine
/// consumer will never do.
///
///
/// Items are not deleted. A node manager has no way to remove an item from a client's
/// subscription, and inventing one would leave the client's own bookkeeping describing items the
/// server had silently discarded.
///
///
/// One verdict per (session, node), not per item. A session commonly holds several items
/// on the same node — one per attribute, or one per downstream consumer sharing a session — and
/// they all ask the identical question. The pass memoizes the verdict locally so N items on a
/// node cost one call rather than N.
/// The authorizer's own decision cache does not remove this work: it memoizes the grant walk,
/// while the per-call key revocation/expiry check in front of it is deliberately outside that
/// cache and runs every time.
///
///
/// The per-item lock (Lock) takes are deliberately left alone. Batching them under one
/// hold was considered and rejected: it would stretch the SDK lock across the
/// QueueValue calls between them, and the attach/detach is what actually needs the lock.
///
///
public void RevalidateMonitoredItems()
{
if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return;
var now = DateTime.UtcNow;
var stoodDown = 0;
var restored = 0;
// Pass-local, so single-threaded by construction. A concurrent deployment can advance the
// generation mid-pass and leave later items decided on the earlier one — exactly as the
// un-memoized walk already could, and the change that advanced it runs its own pass.
var verdicts = new Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool>();
foreach (var (item, tracked) in _liveItems)
{
// The item carries the identity of the session that created it, which is what must be
// re-checked — the deploy thread running this has no session of its own.
if (item is not IMonitoredItem live) continue;
var nodeId = tracked.Handle.NodeId;
var permitted = IsSubscriptionStillPermitted(
live.EffectiveIdentity as RoleCarryingUserIdentity, nodeId, verdicts);
if (permitted)
{
if (!tracked.Suppressed) continue;
lock (Lock) tracked.Handle.MonitoredNode?.Add(item);
tracked.Suppressed = false;
restored++;
continue;
}
if (tracked.Suppressed) continue;
// Queue BEFORE detaching: the notification has to be in the item's queue while the item is
// still live, or the client is cut off with no explanation.
item.QueueValue(
new DataValue(StatusCodes.BadUserAccessDenied) { SourceTimestamp = now, ServerTimestamp = now },
new ServiceResult(StatusCodes.BadUserAccessDenied),
ignoreFilters: true);
lock (Lock) tracked.Handle.MonitoredNode?.Remove(item);
tracked.Suppressed = true;
stoodDown++;
}
var (eventsStoodDown, eventsRestored) = RevalidateEventItems(verdicts);
if (stoodDown > 0 || restored > 0)
{
_authorizer!.LogFiltered(
"RevalidateSubscriptions", principal: null, stoodDown, _liveItems.Count);
}
if (eventsStoodDown > 0 || eventsRestored > 0)
{
_authorizer!.LogFiltered(
"RevalidateEventSubscriptions", principal: null, eventsStoodDown, _eventItems.Count);
}
}
///
/// The event-item half of (Gitea #562): re-authorizes every
/// tracked direct event subscription against its notifier, standing down the denied ones and
/// re-attaching the restored ones.
///
/// The pass-local memo shared with the data-change half.
/// How many items were stood down and how many restored.
///
///
/// The verdict is the same question the creation gate in
///
/// asks — Subscribe on the notifier's scope, for the identity of the session that created
/// the item — so an event feed is held to exactly the authority it was admitted under, re-asked on
/// both triggers (a deployment, and the host's AclRevalidationTimer).
///
///
/// ⚠️ Detached through the SDK's own unsubscribe, never .
/// Same reason as the data-change half: a disabled item can only be re-enabled by the client, and
/// a machine consumer will never try, so a revocation reversed by the next deployment would leave
/// the feed dead. The stand-down is the SDK's protected SubscribeToEvents(…, unsubscribe:
/// true) — precisely what the client's own delete does to the notifier's
/// fan-out — and the restore is the same call with
/// unsubscribe: false, so neither leaves the fan-out in a shape a client could not have
/// produced. The item itself stays in the client's subscription untouched.
///
///
/// The client is not told. Unlike a data-change item, an event item has no value slot to
/// carry a BadUserAccessDenied: its notifications are event field lists, and inventing an
/// event to say "access denied" would put a fabricated event in front of every alarm consumer. The
/// feed goes quiet, and the operator-facing signal is the summary line this pass logs.
///
///
/// Membership is re-checked under Lock immediately before each attach/detach: the walk is
/// over a concurrent snapshot, and re-attaching an item the client deleted mid-pass would leave it
/// on the notifier's fan-out with no owner and no way to remove it.
///
///
private (int StoodDown, int Restored) RevalidateEventItems(
Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool> verdicts)
{
var stoodDown = 0;
var restored = 0;
foreach (var (item, tracked) in _eventItems)
{
var permitted = IsSubscriptionStillPermitted(
item.EffectiveIdentity as RoleCarryingUserIdentity, tracked.Handle.NodeId, verdicts);
if (permitted != tracked.Suppressed) continue;
lock (Lock)
{
if (!_eventItems.TryGetValue(item, out var current) || !ReferenceEquals(current, tracked)) continue;
SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted);
tracked.Suppressed = !permitted;
}
if (permitted) restored++;
else stoodDown++;
}
return (stoodDown, restored);
}
///
/// The per-item verdict for , memoized across the pass.
///
/// The identity of the session that created the item.
/// The node the item monitors.
/// The pass-local memo, keyed by session, realm and node identifier.
/// when the item may keep receiving values.
///
///
/// The session key is ApiKeyId ?? Principal — the same derivation
/// uses for its own decision cache, so this memo is
/// never coarser than the cache it sits in front of. The realm is part of the key for the same
/// reason it is part of the authorizer's: the raw and UNS namespaces have independent string
/// identifier spaces, so an identifier alone does not name a node.
///
///
/// Anything the key cannot be built from — no role-carrying identity, no principal, a node
/// identifier that is not a non-empty string — falls through to the un-memoized call and keeps
/// its existing verdict. Those are the cases the authorizer denies anyway; memoizing them would
/// only make a deny cheaper. The empty identifier is excluded for the same reason
/// excludes it from its own decision cache: it
/// addresses no ACL scope, so it is not an identity to key on.
///
///
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
/// for the duration of one pass. Since arch-review 03/S19 a subscription is re-checked on the
/// host's low-frequency timer as well as at a deployment, so an expiry that lands inside a
/// single pass is caught by the next TICK — one interval later, not one deployment later. What
/// makes that hold is that the memo is constructed per call and never reused across passes.
///
///
private bool IsSubscriptionStillPermitted(
RoleCarryingUserIdentity? identity,
NodeId nodeId,
Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool> verdicts)
{
var realm = RealmOf(nodeId);
if (identity is null
|| (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey
|| nodeId.Identifier is not string { Length: > 0 } identifier)
{
return _authorizer!.IsAllowedCached(
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
}
var key = (sessionKey, realm, identifier);
if (verdicts.TryGetValue(key, out var memoized)) return memoized;
var permitted = _authorizer!.IsAllowedCached(
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
verdicts[key] = permitted;
return permitted;
}
/// Emits the one operator-facing summary line per gated request.
/// The service being filtered.
/// The calling session's identity.
/// How many nodes or references were withheld.
/// How many were considered.
private void LogAclFiltering(string operation, RoleCarryingUserIdentity? identity, int denied, int total) =>
_authorizer?.LogFiltered(operation, identity?.Principal, denied, total);
// ---------------------------------------------------------------------------------------------
// Phase C — OPC UA HistoryRead over historized variable nodes.
//
// The base CustomNodeManager2.HistoryRead (public + protected dispatcher) does the heavy lifting:
// it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
// that carry the HistoryRead access bit), validates the timestamp args, handles
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
// protected virtuals below. We override all four arms: the three variable-history virtuals
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents). Each override
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
// handle.Index is the original index into the service-level results/errors lists, seeded by the
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
// we don't recognise stays "unsupported" by default.
//
// NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
// block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
// address space. Each handle is served in isolation under try/catch so one node's failure (timeout,
// backend throw) never throws out of the batch.
// ---------------------------------------------------------------------------------------------
///
/// Drops the handles in a HistoryRead batch the calling session is not granted
/// on, marking each denied slot
/// BadUserAccessDenied (#520 stage 5).
///
///
///
/// Until this stage HistoryRead carried no authorization check whatsoever — any session
/// that could open a connection could read the full history of every historized node. It was
/// not a regression; it had simply never been gated, because the only two gates in the server
/// were the write and alarm role checks.
///
///
/// Denial is per node, not per batch: OPC UA Part 11 gives every node in a HistoryRead
/// its own result slot, and a client reading ten nodes of which one is off-limits should get
/// nine answers and one denial rather than a whole failed request. Failing the batch would also
/// leak more than it protects — the client learns exactly which read was refused either way,
/// but a batch failure additionally denies data it was entitled to.
///
///
/// Returns the original list unchanged when enforcement is off, so the non-ACL path allocates
/// nothing and behaves exactly as before.
///
///
/// The SDK context carrying the session identity.
/// The pre-filtered handles the base dispatcher built.
/// The service-level results list.
/// The service-level errors list.
/// The handles that may proceed.
private List AuthorizeHistoryRead(
ServerSystemContext context,
List nodesToProcess,
IList results,
IList errors)
{
if (!AclEnforced) return nodesToProcess;
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
var allowed = new List(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
var veto = _authorizer!.Authorize(
identity, OpcUaOperation.HistoryRead, handle.NodeId, RealmOf(handle.NodeId));
if (veto is null)
{
allowed.Add(handle);
continue;
}
// Set both slots explicitly rather than relying on the base's pre-seeded
// BadHistoryOperationUnsupported — "unsupported" would tell the client the node has no history,
// which is a different fact from "you may not read it" and sends them debugging the wrong thing.
errors[handle.Index] = veto;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
}
return allowed;
}
///
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
var session = context.OperationContext?.Session;
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
if (details.IsReadModified)
{
// We never serve modified-value history; mark this node unsupported and move on.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
continue;
}
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
///
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
// the base dispatcher). handle.Index is the node's position in that collection.
var aggregateNodeId = details.AggregateType[handle.Index];
var aggregate = MapAggregate(aggregateNodeId);
if (aggregate is null)
{
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
continue;
}
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
// and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
// maybe more" signal to page on. Returning the complete aggregate result with a null CP is
// spec-conformant (OPC UA Part 11 lets a server return all available data in one response).
var agg = aggregate.Value;
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
tagname,
details.StartTime,
details.EndTime,
interval,
agg,
token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
///
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
// Snapshot the requested timestamps once — the same list is read for every node.
var timestamps = details.ReqTimes?.ToList() ?? new List();
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
tagname,
timestamps,
token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
///
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
// Snapshot the select clauses once — the same filter projects every node's events.
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// Key on the full (namespace-qualified) NodeId so a Raw and a UNS notifier folder that share a
// bare id resolve to their own source registration.
if (!_eventNotifierSources.TryGetValue(MapKey(handle.NodeId), out var sourceName))
{
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
continue;
}
var sn = sourceName;
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, results, errors, ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
///
/// Serve one registered event-history notifier handle: reads the source's events over the request
/// window, projects them onto the select clauses, and fills the service-level results/errors slots.
/// Per-node error isolation matches — a backend throw becomes a Bad
/// status for THIS node only and never throws out of the batch; a deadline cancellation surfaces
/// BadTimeout.
///
/// The notifier node handle; handle.Index indexes results/errors.
/// The registered historian event-source name for this notifier.
/// The event-read details (window + count cap).
/// The event-filter select clauses (the fields to emit, in order).
/// The service-level results list to fill at handle.Index.
/// The service-level errors list to fill at handle.Index.
/// Per-request deadline token.
private async Task ServeEventsAsync(
NodeHandle handle,
string? sourceName,
ReadEventDetails details,
SimpleAttributeOperandCollection selectClauses,
IList results,
IList errors,
CancellationToken ct)
{
try
{
// NOT under the node-manager Lock — awaiting the async source is safe here.
var sourceResult = await _historianDataSource.ReadEventsAsync(
sourceName,
details.StartTime,
details.EndTime,
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
// Part 4/11, so translate it to UNBOUNDED (int.MaxValue) here. Passing the int<=0
// backend-default-cap sentinel instead would silently truncate a "give me everything"
// events read at the backend default. A positive cap passes through (clamped).
EventMaxEvents(details.NumValuesPerNode),
ct).ConfigureAwait(false);
var historyEvent = ProjectEvents(sourceResult.Events, selectClauses);
results[handle.Index] = new SdkHistoryReadResult
{
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
StatusCode = sourceResult.Events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyEvent),
// We never issue continuation points — every read returns the full window in one shot.
ContinuationPoint = null,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while reading this notifier — BadTimeout for THIS node only.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
// only. Logged via the injected ILogger.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
}
/// Clamp a request cap to a non-negative for the
/// event-read surface (whose maxEvents is signed): values above
/// saturate to .
/// The uint cap from the request (NumValuesPerNode).
/// The clamped non-negative int.
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
/// Map a HistoryRead-Events NumValuesPerNode request cap onto the
/// maxEvents argument, honouring the OPC UA
/// Part 4/11 semantics that NumValuesPerNode == 0 means "no limit — return ALL values".
/// We translate 0 to UNBOUNDED () — a very large positive cap — rather than
/// the backend's maxEvents <= 0 "use the default cap" sentinel, so a client asking for the
/// whole window is not silently truncated at the backend default. A positive value passes through
/// clamped to (mirroring ).
/// The request's NumValuesPerNode cap (0 ⇒ no limit).
/// when 0 (unbounded); otherwise the clamped non-negative int.
internal static int EventMaxEvents(uint numValuesPerNode) =>
numValuesPerNode == 0 ? int.MaxValue : ClampToInt(numValuesPerNode);
///
/// Project a sequence of s into an SDK —
/// one per event, each carrying the requested
/// ' fields in select-clause order (see
/// ).
///
/// The historian's event rows.
/// The request's event-filter select clauses (the fields to emit, in order).
/// The populated SDK .
private static HistoryEvent ProjectEvents(
IReadOnlyList events, SimpleAttributeOperandCollection selectClauses)
{
var fieldLists = new HistoryEventFieldListCollection(events.Count);
foreach (var evt in events)
{
var fields = new VariantCollection(selectClauses.Count);
foreach (var operand in selectClauses)
{
fields.Add(ProjectEventField(evt, operand));
}
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
}
return new HistoryEvent { Events = fieldLists };
}
///
/// Project one field requested by a select
/// into a . Mapping is by the operand's BrowsePath LEAF QualifiedName name
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
/// shape:
/// EventId→ByteString, SourceName→String, Time→DateTime,
/// ReceiveTime→DateTime, Message→LocalizedText, Severity→UInt16. Any other
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
/// — spec-conformant: a field the server can't supply is null.
///
/// The source event row.
/// The select-clause operand naming the field to project.
/// The projected variant, or for an unsupported field.
private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
{
var leaf = LeafFieldName(operand);
return leaf switch
{
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
"Time" => new Variant(evt.EventTimeUtc),
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
"Severity" => new Variant(evt.Severity), // UInt16
// EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
_ => Variant.Null,
};
}
/// Extract the leaf (last) from a select operand's BrowsePath,
/// or null when the BrowsePath is null/empty.
/// The select-clause operand.
/// The leaf field name, or null for an empty BrowsePath.
private static string? LeafFieldName(SimpleAttributeOperand operand)
{
var path = operand.BrowsePath;
if (path is null || path.Count == 0) return null;
return path[^1].Name;
}
///
/// Block-bridge to the historian source for one node handle and project the result onto the
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
/// a single lookup; the resolved tagname is passed directly
/// to , removing any risk of a second concurrent lookup on the same key.
/// A node we don't recognise as historized maps to BadHistoryOperationUnsupported
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
/// bit, but we guard explicitly). The callback receives the resolved
/// tagname and is wrapped in try/catch so a backend throw / timeout becomes a Bad status for
/// THIS node without throwing out of the batch.
///
/// The pre-filtered node handle to serve; handle.Index indexes results/errors.
/// The service-level results list to fill at handle.Index.
/// The service-level errors list to fill at handle.Index.
/// Invokes the resolved data-source read with the resolved tagname; only called
/// once the tagname is confirmed present.
///
/// Run a batch of per-node HistoryRead work items with BOUNDED parallelism (arch-review 03/S3),
/// block-bridged ONCE at the arm boundary. Contract: every work item must be fully
/// self-contained — it fills its own results/errors slot and NEVER throws (all per-node handlers
/// catch every exception, including cancellation, and surface a Bad status for that node only). That
/// invariant is what makes the single GetAwaiter().GetResult() safe:
/// can never fault, so it cannot throw out of the synchronous SDK override. A degree ≤ 1 (or a
/// single item) runs sequentially — no semaphore, no extra tasks.
///
/// The per-node work items (index-disjoint writes into the shared results/errors).
/// Max concurrent work items ().
private static void RunBounded(IReadOnlyList> work, int degree)
{
if (work.Count == 0) return;
if (degree <= 1 || work.Count == 1)
{
foreach (var item in work)
item().GetAwaiter().GetResult();
return;
}
using var gate = new SemaphoreSlim(degree);
var tasks = new Task[work.Count];
for (var i = 0; i < work.Count; i++)
tasks[i] = RunGatedAsync(gate, work[i]);
// Safe: every body is self-contained + never throws, so WhenAll never faults (see contract above).
Task.WhenAll(tasks).GetAwaiter().GetResult();
}
///
/// Run one HistoryRead arm under the process-wide concurrent-batch limiter (arch-review 03/S3). Each
/// arm waits up to min(HistoryReadDeadline, 5 s) for a slot; on expiry every handle in the
/// batch gets BadTooManyOperations (fast-fail under flood, brief waits under mild contention)
/// and is skipped. On acquisition the arm body runs and the slot is
/// released in a finally. Combined with this caps
/// total in-flight gateway reads at
/// MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism.
///
/// The batch's handles — every slot is failed on limiter saturation.
/// The service-level results list.
/// The service-level errors list.
/// The arm body to run once a slot is acquired.
private void WithHistoryReadBatchLimiter(
List nodesToProcess,
IList results,
IList errors,
Action serve)
{
var gate = GetHistoryReadBatchLimiter();
// Bounded admission wait: cap the block at 5 s, and never exceed the per-request deadline.
var waitMs = HistoryReadDeadline > TimeSpan.Zero
? Math.Min(HistoryReadDeadline.TotalMilliseconds, 5000.0)
: 5000.0;
if (!gate.Wait(TimeSpan.FromMilliseconds(waitMs)))
{
foreach (var handle in nodesToProcess)
{
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
}
return;
}
try
{
serve();
}
finally
{
gate.Release();
}
}
///
/// Lazily create (double-checked) the process-wide HistoryRead-batch limiter, sized from
/// at first use so the Host's post-boot property set is
/// respected (the same benign wiring race as ).
///
/// The shared batch limiter semaphore.
private SemaphoreSlim GetHistoryReadBatchLimiter()
{
var gate = _historyReadBatchLimiter;
if (gate is not null) return gate;
lock (_historyReadBatchLimiterLock)
{
return _historyReadBatchLimiter ??= new SemaphoreSlim(Math.Max(1, MaxConcurrentHistoryReadBatches));
}
}
///
/// Create the per-request deadline source for a HistoryRead arm (arch-review 03/S3): a
/// that fires after , so a
/// single request can never hold an SDK request thread longer than the deadline regardless of node
/// count. Returns null when the deadline is non-positive (unbounded — the arm then passes
/// ), matching the ServerHistorianOptions warning.
///
/// The deadline source, or null for an unbounded (non-positive) deadline.
private CancellationTokenSource? CreateDeadline() =>
HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null;
/// Awaits one work item under a semaphore permit, releasing it when the item completes.
/// The concurrency-limiting semaphore.
/// The per-node work item.
private static async Task RunGatedAsync(SemaphoreSlim gate, Func work)
{
await gate.WaitAsync().ConfigureAwait(false);
try
{
await work().ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
private async Task ServeNodeAsync(
NodeHandle handle,
IList results,
IList errors,
Func> read,
CancellationToken ct)
{
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname))
{
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
// status, but set it explicitly so the contract is local + obvious.)
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
try
{
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so awaiting
// the async source here is safe and won't freeze the address space.
var sourceResult = await read(HistorianDataSource, tagname!, ct).ConfigureAwait(false);
var historyData = ToHistoryData(sourceResult);
results[handle.Index] = new SdkHistoryReadResult
{
// No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
// Single-shot arms (Processed / AtTime) never page — the backend returns the complete
// result in one read (no client count cap to detect a "full page" against), so no
// continuation point. Raw pages via ServeRawPaged, not this helper.
ContinuationPoint = null,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while this node was in flight — surface BadTimeout for
// THIS node only (a spec-conformant history-read status), ahead of the generic catch.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryAggregateNotSupportedException ex)
{
// The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
// not a logged server fault.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
}
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
// Bad status for THIS node only. Logged through the injected ILogger
// rather than swallowed silently.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
}
}
///
/// Serve one historized variable handle for a HistoryRead-Raw request WITH server-side
/// continuation-point paging. The single-shot Wonderware backend does not page, so paging is
/// synthesised time-based:
///
/// - Fresh read (no inbound continuation point): read the window from
/// details.StartTime to capped at
/// . If the page comes back FULL (exactly the cap, and the
/// cap is > 0), store a resume cursor and emit a continuation point.
/// - Resume read (inbound continuation point present): take the stored cursor, read
/// the next page from the boundary forward, trim already-emitted boundary ties, and emit a
/// FRESH continuation point only if THIS page is also full — else null (done).
///
/// The resume cursor is tie-safe (see /
/// ): the next page resumes from the boundary
/// timestamp INCLUSIVE and drops the head ties already returned, so samples sharing the boundary
/// SourceTimestamp are neither duplicated nor skipped. Continuation points live in
/// — session-bound + capped in production. Per-node error
/// isolation matches : a backend throw / an unknown continuation point
/// becomes a Bad status for THIS node only and never throws out of the batch.
///
/// The pre-filtered node handle; handle.Index indexes results/errors.
/// The session the read runs under (null on the session-less in-process path).
/// The per-node read list; nodesToRead[handle.Index].ContinuationPoint
/// carries the inbound continuation point (non-null ⇒ a resume read).
/// The service-level results list to fill at handle.Index.
/// The service-level errors list to fill at handle.Index.
/// The request window's (inclusive) lower bound, used for a fresh read.
/// The (inclusive) upper bound of the read window; unchanged across pages.
/// The client's per-page cap; 0 means "all values, no paging".
private async Task ServeRawPagedAsync(
NodeHandle handle,
ISession? session,
IList nodesToRead,
IList results,
IList errors,
DateTime startTimeUtc,
DateTime endUtc,
uint numValuesPerNode,
CancellationToken ct)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
{
DateTime startUtc;
var boundarySkip = 0;
string tagname;
if (inboundCp is { Length: > 0 })
{
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
// BadContinuationPointInvalid for THIS node.
var state = _historyContinuationStore.TryTake(session, inboundCp);
if (state is null)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
return;
}
tagname = state.Tagname;
startUtc = state.NextStartUtc;
boundarySkip = state.BoundarySkipCount;
endUtc = state.EndUtc;
numValuesPerNode = state.NumValuesPerNode;
}
else
{
// Fresh read: resolve the node's historian tagname (as ServeNode does).
if (!TryGetHistorizedTagname(handle.NodeId, out var resolved) || resolved is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
tagname = resolved;
startUtc = startTimeUtc;
}
// HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
var sourceResult = await HistorianDataSource
.ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct)
.ConfigureAwait(false);
var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode);
// On a resume read, drop the boundary ties already returned on the prior page.
var samples = inboundCp is { Length: > 0 }
? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
: sourceResult.Samples;
// Oversized tie cluster: a resume read whose FULL backend page does not advance past the resume
// boundary timestamp `startUtc`. That happens when more samples share `startUtc` than the page
// cap — a tie cluster larger than NumValuesPerNode. The fixed-(start,end,cap) backend can only
// ever return the first `cap` of those ties, so the (timestamp, skip) cursor alone can never page
// past (or even reliably within) the cluster: either the boundary-tie trim empties the page, or
// it re-emits the same head ties forever. Detect BOTH stall shapes — an empty trimmed page, or a
// non-empty page whose LAST sample is still at `startUtc` (no forward progress) — and, rather
// than fail the read, over-fetch the WHOLE cluster with an explicit, BOUNDED cap (a start==end
// read at the boundary timestamp) and page WITHIN the timestamp via SliceTieCluster.
var stalledOnTieCluster = inboundCp is { Length: > 0 }
&& backendFull
&& (samples.Count == 0
|| (samples[^1].SourceTimestampUtc ?? DateTime.MinValue) == startUtc);
if (stalledOnTieCluster)
{
// The over-fetch cap MUST be explicit and non-zero: a cap of 0 falls back to the backend's
// MaxValuesPerRead, which would re-introduce the very stall we're escaping. +1 over the bound
// lets us DETECT a cluster strictly larger than the bound (the absurd-burst backstop below).
// Compute in uint so +1 can never wrap: Math.Max(1,…) also guards against a zero/negative
// config value slipping through (Validate() warns but does not block startup).
var overfetchCap = (uint)Math.Max(1, MaxTieClusterOverfetch) + 1u;
var cluster = (await HistorianDataSource
.ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, ct)
.ConfigureAwait(false)).Samples;
// Absurd burst: more ties than we're willing to buffer in memory. Preserve today's loud-fail
// for that node rather than over-fetch an unbounded cluster; the operator's remedy is a
// larger ServerHistorian:MaxTieClusterOverfetch (or NumValuesPerNode).
if (cluster.Count > MaxTieClusterOverfetch)
{
_logger.LogError(
"OtOpcUaNodeManager: HistoryReadRaw tie cluster at {StartUtc:O} for tag '{Tagname}' has {Count} samples, " +
"exceeding MaxTieClusterOverfetch={Max}; cannot page within it. Increase MaxTieClusterOverfetch.",
startUtc, tagname, cluster.Count, MaxTieClusterOverfetch);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
return;
}
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
var slice = new List(sliceCount);
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
// Emit a continuation point only when SliceTieCluster says the read continues (within the
// cluster, or past it while the window remains). nextSkip is the boundary skip for the next
// page — within the cluster it counts the ties already emitted at startUtc; past it it's 0.
byte[]? clusterCp = null;
if (nextStartUtc is { } resumeAt)
{
var clusterState = new HistoryContinuationState(
tagname, resumeAt, endUtc, nextSkip, numValuesPerNode);
clusterCp = _historyContinuationStore.Save(session, clusterState);
}
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = slice.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(slice)),
ContinuationPoint = clusterCp,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
// The "full page" test is against the RAW backend count (before trimming): the backend honoured
// the cap, so a full backend page ⇒ there may be more even if we trimmed some boundary ties.
var historyData = ToHistoryDataFromSamples(samples);
byte[]? outboundCp = null;
if (backendFull && samples.Count > 0)
{
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
var nextState = new HistoryContinuationState(
tagname, nextStart, endUtc, skip, numValuesPerNode);
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
outboundCp = _historyContinuationStore.Save(session, nextState);
}
results[handle.Index] = new SdkHistoryReadResult
{
// No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the
// degenerate-cluster guard above, a resumed empty page now only means the window/cluster is
// genuinely drained — never silent data loss.
StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
ContinuationPoint = outboundCp,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while paging this node — BadTimeout for THIS node only.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
}
}
///
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
IList nodesToRead,
IList errors,
List nodesToProcess,
IDictionary cache)
{
var session = context.OperationContext?.Session;
foreach (var handle in nodesToProcess)
{
var cp = nodesToRead[handle.Index].ContinuationPoint;
if (cp is { Length: > 0 })
{
_historyContinuationStore.Release(session, cp);
}
errors[handle.Index] = ServiceResult.Good;
}
}
/// Project a plain sample list into an SDK (the paged Raw path
/// works on a trimmed rather than a whole ).
/// The samples to project (already trimmed of boundary duplicates).
/// The populated SDK .
private static HistoryData ToHistoryDataFromSamples(IReadOnlyList samples)
{
var values = new DataValueCollection(samples.Count);
foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
return new HistoryData { DataValues = values };
}
///
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
/// . Returns null for any aggregate we don't serve so
/// the caller can surface BadAggregateNotSupported.
///
/// The per-node aggregate-function NodeId from the request.
/// The mapped aggregate, or null when unsupported.
private static HistoryAggregateType? MapAggregate(NodeId aggregateNodeId)
{
if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
return null;
}
///
/// Project the historian source's (Core.Abstractions DTO) into an
/// SDK — one per ,
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
/// DateTime.MinValue (the SDK's "unset" sentinel for that field).
///
/// The data source's read result.
/// The populated SDK .
private static HistoryData ToHistoryData(HistorianRead sourceResult)
{
var values = new DataValueCollection(sourceResult.Samples.Count);
foreach (var sample in sourceResult.Samples)
{
values.Add(ToSdkDataValue(sample));
}
return new HistoryData { DataValues = values };
}
/// Convert one driver-agnostic to an SDK
/// , mirroring value / status code / source + server timestamps.
/// The source sample.
/// The equivalent SDK data value.
private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new()
{
WrappedValue = new Variant(snapshot.Value),
StatusCode = new StatusCode(snapshot.StatusCode),
SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue,
ServerTimestamp = snapshot.ServerTimestampUtc,
};
///
public override void CreateAddressSpace(IDictionary> externalReferences)
{
lock (Lock)
{
base.CreateAddressSpace(externalReferences);
// Create one root folder under Objects/ for every variable we mint to hang under.
// Its identifier is V3NodeIds.SharedRootFolderId rather than a literal because the ACL gate
// keys the trie's realm-root scope off exactly this identity — see
// DataPlaneAuthorizer.IsSharedRootFolder. Both realm trees parent here (ResolveParentFolder),
// so the anchor is the one node every browse of this server passes through.
_root = new FolderState(null)
{
NodeId = new NodeId(V3NodeIds.SharedRootFolderId, NamespaceIndex),
BrowseName = new QualifiedName(V3NodeIds.SharedRootFolderId, NamespaceIndex),
DisplayName = V3NodeIds.SharedRootFolderId,
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
};
_root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
{
refs = new List();
externalReferences[ObjectIds.ObjectsFolder] = refs;
}
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));
AddPredefinedNode(SystemContext, _root);
}
}
private BaseDataVariableState CreateVariable(string nodeId, ushort ns)
{
var v = new BaseDataVariableState(_root)
{
NodeId = new NodeId(nodeId, ns),
BrowseName = new QualifiedName(nodeId, ns),
DisplayName = nodeId,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = DataTypeIds.BaseDataType,
// Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no
// array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's
// scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true).
ValueRank = ValueRanks.Scalar,
ArrayDimensions = null,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
};
_root?.AddChild(v);
AddPredefinedNode(SystemContext, v);
return v;
}
}