using System.Collections.Concurrent; 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(); /// 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(); /// 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 only routes an Unshelve back to the engine (OnTimedUnshelve), a no-op once the // engine's own expiry has run. 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, MapSeverity(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 MAPPED bucket (a /// ) — the same value the node holds after SetSeverity — so two /// raw severities that fall in the same bucket are correctly treated as "no change". 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-mapped /// bucket 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 bucket the node holds (the projection calls SetSeverity(MapSeverity(...))), 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)MapSeverity(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 (treated as an OPC UA 1..1000 severity); mapped to . /// 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); PredefinedNodes?.Remove(existing.NodeId); // 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, MapSeverity(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 auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK // fires it with the node manager's system context — there is NO session and NO user identity. // Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied, // leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the // same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve // state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated. alarm.OnTimedUnshelve = (context, condition) => { var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty; // User MUST be non-empty: the engine's Part9StateMachine.ApplyUnshelve rejects a // null/whitespace user (ArgumentException "User required."), and that exception is swallowed // downstream — an empty user would make the timed auto-unshelve silently no-op, so a // TimedShelve would never auto-expire. There is no client principal on a system-timer // unshelve, so label it the canonical engine-internal "system" user (matching the engine's // own AutoUnshelve audit user and the codebase-wide "system" system-actor convention). The // AlarmAck gate bypass is intentional and preserved — this is a session-less SDK timer. AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", "system", null, null)); return 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); } } /// 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 a /// 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). 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 only routes an Unshelve back to it (OnTimedUnshelve), a no-op once the engine has expired it. 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. var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node); if (structure is not null) return structure; // 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 /// EXACTLY (shape, declared length and element type — the SDK's own rule, restored), and an array /// value to a scalar node is refused. 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 ). /// 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) { // 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. 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 (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) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK // runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every // writable equipment node has one, so nothing else stands between a mistyped array and the driver: a // UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote // uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16 // DataType. This restores the SDK's own rule for arrays — the element type must match, with the single // Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and // an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were // never client-writable before #610, so no client relies on a looser rule. if (isArrayNode) { return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString) ? null : 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; /// 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), }; /// 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); } /// Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the /// enum buckets the SDK's SetSeverity expects. private static EventSeverity MapSeverity(int severity) => severity switch { < 200 => EventSeverity.Low, < 400 => EventSeverity.MediumLow, < 600 => EventSeverity.Medium, < 800 => EventSeverity.MediumHigh, _ => EventSeverity.High, }; /// /// 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) { 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!; } } /// 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(); foreach (var alarm in _alarmConditions.Values) { alarm.Parent?.RemoveChild(alarm); PredefinedNodes?.Remove(alarm.NodeId); } // 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(); } } /// 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 _); 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); PredefinedNodes?.Remove(condition.NodeId); _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 _); } // 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); PredefinedNodes?.Remove(c.NodeId); } _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 (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; 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; } }