Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
T
Joseph Doherty 97668d26d3
v2-ci / build (pull_request) Successful in 5m10s
v2-ci / unit-tests (pull_request) Failing after 28m34s
fix(historian): respect gateway ceilings and history value contracts
2026-10-13 03:51:04 -04:00

7029 lines
424 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
/// the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
/// under the manager's namespace; subsequent writes update the existing node's Value +
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
/// <c>ClearChangeMasks</c> path.
///
/// This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
/// seam — once a <see cref="SdkAddressSpaceSink"/> 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. <c>"ns=2;s=eq-1/temp"</c>). Beyond lazily-created flat
/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy
/// (<see cref="EnsureFolder"/>), typed equipment-tag variables with the correct built-in
/// <c>DataType</c> / array shape / access levels (<see cref="EnsureVariable"/>), historized
/// nodes (the <c>HistoryRead</c> access bit + HistoryRead overrides), and real Part 9
/// <see cref="AlarmConditionState"/> nodes (<see cref="MaterialiseAlarmCondition"/>). The
/// <c>AddressSpaceApplier</c> drives these passes from the composed deployment artifact;
/// the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
/// (composer → applier → sink → node-manager) chain.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
/// <summary>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. <see cref="V3NodeIds.RawNamespaceUri"/> is
/// registered FIRST (namespace index <see cref="NamespaceIndexes"/>[0]) and
/// <see cref="V3NodeIds.UnsNamespaceUri"/> SECOND ([1]); <see cref="NamespaceIndexForRealm"/> maps a realm
/// to its index. Which realm a node belongs to travels with each sink call as an
/// <see cref="AddressSpaceRealm"/> — the namespace is never parsed back out of the id string.</summary>
private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri };
/// <summary>Transitional alias kept so the pre-v3 single-namespace integration tests
/// (<c>SubscriptionSurvivalTests</c>) still resolve a namespace index. It points at
/// <see cref="V3NodeIds.UnsNamespaceUri"/> — 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.</summary>
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> 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 <see cref="_alarmConditions"/>:
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>Gitea #641: every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
/// <see cref="CompleteAddressSpaceApply"/> runs is discarded. Guarded by <c>Lock</c>.</summary>
private readonly Dictionary<RealmKey, NativeConditionCarryOver> _rebuildCarryOver = new();
/// <summary>Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
/// event at <see cref="CompleteAddressSpaceApply"/> (deferred until then so every notifier folder the
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
/// not reported. Guarded by <c>Lock</c>.</summary>
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
/// <summary>Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
/// SCALAR tag (Modbus / S7 — <c>DriverArrayWriteSupport.RefusesArrayValueOnScalarTag</c>), so an array write to
/// one while it is scalar-shaped is refused inline. Every other scalar node keeps the pre-#610 behaviour (the
/// write is dispatched and the driver decides). Set by <see cref="EnsureVariable"/>; cleared alongside
/// <see cref="_variables"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, byte> _arrayOnScalarRefused = new();
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
/// <summary>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 <see cref="WireAlarmNotifiers"/>).
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
/// <c>ReportEvent</c> 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 <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>. 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
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, string> _eventNotifierSources = new();
private FolderState? _root;
private readonly ILogger<OtOpcUaNodeManager> _logger;
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
/// <param name="server">The OPC UA server instance.</param>
/// <param name="configuration">The application configuration.</param>
/// <param name="logger">Logger for the manager's swallowed-fault and skipped-wiring diagnostics;
/// <c>null</c> (tests) falls back to <see cref="NullLogger{T}.Instance"/>.</param>
public OtOpcUaNodeManager(
IServerInternal server, ApplicationConfiguration configuration, ILogger<OtOpcUaNodeManager>? logger = null)
: base(server, configuration, RegisteredNamespaceUris)
{
_logger = logger ?? NullLogger<OtOpcUaNodeManager>.Instance;
// SystemContext is initialised by the base ctor; both namespace URIs are now registered and their
// indexes captured (in the order passed) in NamespaceIndexes.
}
/// <summary>Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered
/// first (<see cref="NamespaceIndexes"/>[0]), UNS second ([1]) — see <see cref="RegisteredNamespaceUris"/>.</summary>
/// <param name="realm">The address-space realm.</param>
/// <returns>The namespace index for the realm.</returns>
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."),
};
/// <summary>Recover the realm of an already-resolved SDK <see cref="NodeId"/> 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).</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The realm the node belongs to.</returns>
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
/// <summary>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).</summary>
/// <param name="Realm">The realm the id lives in.</param>
/// <param name="Id">The bare <c>s=</c> identifier.</param>
private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id);
/// <summary>Build the internal map key for a realm-qualified string id. The key pairs the realm with the
/// bare <c>s=</c> 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
/// <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <param name="realm">The realm the id lives in.</param>
/// <param name="id">The bare <c>s=</c> identifier.</param>
/// <returns>The realm-qualified map key.</returns>
private static RealmKey MapKey(AddressSpaceRealm realm, string id) => new(realm, id);
/// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node. The SDK-facing
/// seams only ever pass nodes this manager minted (string <c>s=</c> 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.</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The realm-qualified map key.</returns>
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);
}
/// <summary>Gets the count of variable nodes currently managed.</summary>
public int VariableCount => _variables.Count;
/// <summary>Gets the count of folder nodes currently managed.</summary>
public int FolderCount => _folders.Count;
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
public int AlarmConditionCount => _alarmConditions.Count;
/// <summary>
/// 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 <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
/// <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
/// delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
/// <para>
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
/// <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
/// delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
/// (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
/// handler a safe no-op — it still gates + returns, just routes nowhere.
/// </para>
/// </summary>
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
/// <summary>
/// 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 <c>OnAcknowledge</c> handler (wired in
/// <see cref="MaterialiseAlarmCondition"/>) branches on <see cref="IsNativeAlarmNode"/> and — after
/// the same <c>AlarmAck</c> role gate as <see cref="AlarmCommandRouter"/> — invokes THIS delegate
/// with a <see cref="NativeAlarmAck"/> instead of routing an <see cref="AlarmCommand"/> 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).
/// <para>
/// Like <see cref="AlarmCommandRouter"/>, the handler delegate runs under the manager's
/// <c>Lock</c>, 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 <see cref="AlarmCommandRouter"/> path (H6c scope is the
/// Acknowledge → driver path only).
/// </para>
/// </summary>
public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }
/// <summary>
/// Gitea #597 — whether this node may accept an OPC UA Acknowledge of a NATIVE (driver-fed) condition
/// right now: the redundancy data-plane verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as
/// published to <c>IDriverPrimaryRoleSource</c>) — the same verdict <c>DriverHostActor</c> gates the
/// upstream ack route and device writes on.
/// <para>
/// <c>false</c> ⇒ the Acknowledge is REFUSED with <c>BadInvalidState</c> before anything is routed or
/// applied. Without this the Secondary applied and reported the ack locally while
/// <c>DriverHostActor</c> 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.
/// </para>
/// <para>
/// Read under <c>Lock</c> 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 <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
/// </para>
/// </summary>
public Func<bool>? NativeAlarmAckPrimaryGate { get; set; }
/// <summary>
/// Gitea #674 — whether this node may serve an OPC UA HistoryUpdate right now: the redundancy data-plane
/// verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as published to <c>IDriverPrimaryRoleSource</c>) —
/// the same source as <see cref="NativeAlarmAckPrimaryGate"/>.
/// <para>
/// <c>false</c> ⇒ every node of the request that passed authorization is refused <c>BadInvalidState</c>
/// and nothing reaches the updater. Rewriting history is a single-writer operation: the SQL backends'
/// prior-value capture (and QuestDB's per-process per-tag lock) assume one node serialises a tag's updates,
/// so both pair nodes accepting updates concurrently could record a wrong prior value in the modification
/// trail. HistoryRead is NOT gated — reads are served from any node.
/// </para>
/// <para>
/// Evaluated AFTER authorization (a caller who may not update learns nothing about the node's role). 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 <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
/// </para>
/// </summary>
public Func<bool>? HistoryUpdatePrimaryGate { get; set; }
/// <summary>
/// Gitea #674 — how far beyond <see cref="Clock"/>'s now a HistoryUpdate (Insert / Replace / Update) item's
/// <c>SourceTimestamp</c> may lie. An item past <c>now + skew</c> is refused <c>BadOutOfRange</c> before it
/// reaches any backend: a far-future row (a 2062 typo) is never a legitimate history edit, and on a backend whose
/// retention is judged against the newest timestamp it could age out every older partition. Mirrors
/// <c>ServerHistorianOptions.HistoryUpdateMaxFutureSkew</c>; the Host sets it at <c>StartAsync</c>. Non-positive
/// disables the bound. Default 1 day. Live recorder writes are not bounded by this.
/// </summary>
public TimeSpan HistoryUpdateMaxFutureSkew { get; set; } = TimeSpan.FromDays(1);
/// <summary>
/// The clock the HistoryUpdate future-skew bound (<see cref="HistoryUpdateMaxFutureSkew"/>) reads. The Host
/// assigns its <see cref="TimeProvider"/> at <c>StartAsync</c>; tests inject a fixed clock. Defaults to
/// <see cref="TimeProvider.System"/>; assigning null restores it.
/// </summary>
public TimeProvider Clock
{
get => _clock;
set => _clock = value ?? TimeProvider.System;
}
private volatile TimeProvider _clock = TimeProvider.System;
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
/// <summary>
/// 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 <see cref="BaseDataVariableState.OnWriteValue"/>
/// handler (<see cref="OnEquipmentTagWrite"/>, attached by <see cref="EnsureVariable"/> when the
/// variable is writable) first gates on the caller's <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
/// role and, when allowed, calls <see cref="IOpcUaNodeWriteGateway.WriteAsync"/> with the node's
/// string id + the written value to route the write to the backing driver.
/// <para>
/// This is the write-side twin of <see cref="AlarmCommandRouter"/>; the gateway abstraction keeps
/// this assembly Akka-free (the host wires an <c>ActorNodeWriteGateway</c> that Asks the local
/// <c>DriverHostActor</c>). The handler delegates run under the node-manager <c>Lock</c> (the OPC
/// UA SDK's <c>CustomNodeManager2.Write</c> holds <c>Lock</c> while invoking <c>OnWriteValue</c>),
/// so the dispatch is FIRE-AND-FORGET — the handler kicks off <c>WriteAsync</c> and returns
/// <c>Good</c> immediately so the SDK applies the client value optimistically; it MUST NOT block
/// on the device round-trip. When the asynchronous <see cref="NodeWriteOutcome"/> comes back
/// FAILED, an off-Lock continuation self-corrects: it re-takes <c>Lock</c> 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
/// <see cref="ShouldRevert"/> / <see cref="RevertOptimisticWriteIfNeeded"/>).
/// </para>
/// <para>
/// Set by the host at <c>StartAsync</c>; the <see cref="NullOpcUaNodeWriteGateway"/> default
/// (assigning <c>null</c> restores it) makes every write resolve to a "writes unavailable"
/// failure. Backed by a <c>volatile</c> 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.
/// </para>
/// </summary>
public IOpcUaNodeWriteGateway NodeWriteGateway
{
get => _nodeWriteGateway;
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
}
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
/// <summary>
/// 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
/// <c>Historizing</c> (a tag with <see cref="TryGetHistorizedTagname"/> 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.
/// <para>
/// Set by the Host at <c>StartAsync</c>. The <see cref="NullHistorianDataSource"/>
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
/// faulting. Backed by a <c>volatile</c> 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
/// <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
/// </para>
/// <para>
/// Gitea #682: assigning a source also re-syncs <c>HistoryServerCapabilities/AggregateFunctions</c> to its
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (see <see cref="SyncAdvertisedAggregates"/>).
/// </para>
/// </summary>
public IHistorianDataSource HistorianDataSource
{
get => _historianDataSource;
set
{
_historianDataSource = value ?? NullHistorianDataSource.Instance;
SyncAdvertisedAggregates();
}
}
/// <summary>
/// Gitea #682 — trim <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (null ⇒ all, <see cref="AdvertisedAggregateFunctions.ServedBy"/>).
/// <c>ServerCapabilities/AggregateFunctions</c> (i=2997) keeps the SDK's full set — the server still computes every
/// one of them for live <c>AggregateFilter</c> subscriptions. Called on every <see cref="HistorianDataSource"/>
/// assignment and by <see cref="OtOpcUaSdkServer"/> right after the SDK registers its aggregate factories, so the
/// Null default is applied from boot. Only the folder references change — the AggregateManager's calculator
/// factories (live <c>AggregateFilter</c> subscriptions) are untouched. Never throws: a failure leaves the previous
/// advertisement in place and is logged, because HistoryRead's own up-front check still enforces the set.
/// </summary>
internal void SyncAdvertisedAggregates()
{
try
{
var source = _historianDataSource;
var advertised = AdvertisedAggregateFunctions.Apply(Server, AdvertisedAggregateFunctions.ServedBy(source));
if (advertised is not null)
{
_logger.LogInformation(
"OtOpcUaNodeManager: advertising {Count} history aggregate function(s) for historian {Source}: {Aggregates}",
advertised.Count, source.GetType().Name,
string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString())));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to sync the advertised aggregate functions");
}
}
private volatile IHistorianDataUpdater? _historianDataUpdater;
/// <summary>
/// Optional server-side backend for the OPC UA HistoryUpdate service (insert / replace / update /
/// delete of historized samples). <c>null</c> (the default) means the selected historian backend
/// cannot modify history: every HistoryUpdate is reported <c>BadHistoryOperationUnsupported</c>.
/// <para>
/// ⚠️ <b>Assign it before any node materialises.</b> Whether an updater is present decides the
/// <c>HistoryWrite</c> AccessLevel bit a historized node is created with
/// (<see cref="ComposeAccessLevel"/>), and the SDK base admits a node to HistoryUpdate only when
/// that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Setting the updater after
/// nodes exist does NOT retro-update their AccessLevel. The Host assigns it in
/// <c>OtOpcUaServerHostedService.StartAsync</c> before installing the address-space sink, which
/// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as
/// <see cref="HistorianDataSource"/>.
/// </para>
/// </summary>
public IHistorianDataUpdater? HistorianDataUpdater
{
get => _historianDataUpdater;
set => _historianDataUpdater = value;
}
/// <summary>
/// #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.
/// </summary>
/// <remarks>
/// <para>
/// This closes the fourth #520 blocker: <c>NodeScope.ClusterId</c> is <c>required</c> and
/// <c>TriePermissionEvaluator</c> fails closed when it disagrees with the session's, but the
/// node manager had no idea what cluster it was serving — so no <c>NodeScope</c> it built
/// could ever have evaluated. It is fed here rather than at construction because the SDK
/// constructs this manager during <c>StartAsync</c>, before any artifact has been applied,
/// and the value changes on every deploy.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Backed by a <c>volatile</c> field for the same reason as <see cref="HistorianDataSource"/>:
/// written on the deploy path, read on SDK request threads. Assigning <c>null</c> restores the
/// empty source, which authenticates and authorizes nothing.
/// </para>
/// </remarks>
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;
/// <summary>
/// Re-authorizes live subscriptions when a deployment installs a new authorization generation.
/// </summary>
/// <remarks>
/// <para>
/// <b>There are TWO triggers</b> (arch-review 03/S19): this one, the deployment axis; and the
/// host's low-frequency <c>AclRevalidationTimer</c>, the clock axis — a key's
/// <c>ExpiresUtc</c> 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.
/// </para>
/// <para>
/// ⚠️ <b>The second trigger is safe only because the memo is pass-local.</b>
/// <see cref="IsSubscriptionStillPermitted"/> 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.
/// </para>
/// </remarks>
/// <param name="snapshot">The newly published snapshot; unused — the current state is re-read per item.</param>
private void OnAuthorityChanged(DataPlaneAuthoritySnapshot snapshot) => RevalidateMonitoredItems();
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>
/// The node-scoped ACL gate (#520 stage 5), or <c>null</c> when none is wired. Consulted by the
/// write, alarm-command and HistoryRead seams <b>only while
/// <see cref="Security.DataPlaneAuthorizer.Enabled"/> is true</b>; otherwise each keeps its
/// server-wide role check and behaviour is identical to before this stage — with one exception: the
/// two role gates consult <see cref="Security.DataPlaneAuthorizer.AuthorizeKeyUsable"/> in either
/// mode, so an expired or revoked key loses its activation-time roles (Gitea #563).
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public DataPlaneAuthorizer? Authorizer
{
get => _authorizer;
set => _authorizer = value;
}
private volatile DataPlaneAuthorizer? _authorizer;
/// <summary>
/// True when a wired authorizer is switched on — i.e. when ACL grants, rather than the server-wide
/// role set, decide the gated operations.
/// </summary>
private bool AclEnforced => _authorizer is { Enabled: true };
/// <summary>
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> 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 (every row of its <c>N + skip + 1</c> read, past the
/// trimmed ties, still sits on the boundary timestamp — #692: only a genuine cluster larger than the page), the
/// paging over-fetches up to <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a
/// <c>start == end</c> read) and slices through them via <see cref="HistoryPaging.SliceTieCluster"/>.
/// A cluster strictly larger than this still fails that node <c>BadTooManyOperations</c> (the absurd-burst
/// backstop; <c>BadHistoryOperationUnsupported</c> before #686). Also the tie-cluster ceiling (not the segment size, #689) of a reverse read
/// walked back from forward reads (<see cref="ReverseRawReader"/>). Mirrors the configured
/// <c>ServerHistorianOptions.MaxTieClusterOverfetch</c>; the Host sets it at <c>StartAsync</c>. The
/// default (65536) survives when the historian section is absent.
/// </summary>
public int MaxTieClusterOverfetch { get; set; } = 65536;
/// <summary>
/// Gitea #691: whether HistoryRead-Raw honours <c>ReturnBounds</c> (Part 11 bounding values, <see cref="HistoryBounds"/>)
/// — <c>ServerHistorian:HonorReturnBounds</c>, default <c>true</c>. <c>false</c> ignores the flag, as before #691,
/// including the modified-read <c>Bad_InvalidArgument</c>.
/// </summary>
public bool HonorReturnBounds { get; set; } = true;
/// <summary>
/// Gitea #694: <c>ServerHistorian:EventReadHorizon</c> — HistoryReadEvents never reads events older than
/// <c>now − EventReadHorizon</c> (<see cref="EventHorizon"/>). <see cref="TimeSpan.Zero"/> (the default) = no horizon:
/// every read is exact down to its own window (a walked Gateway reverse read proves "nothing older" down to 1970).
/// </summary>
public TimeSpan EventReadHorizon { get; set; } = TimeSpan.Zero;
/// <summary>
/// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3).
/// Mirrors <c>ServerHistorianOptions.HistoryReadBatchParallelism</c>; the Host sets it at
/// <c>StartAsync</c>. <c>≤ 1</c> falls back to sequential serving. The default (4) survives when the
/// ServerHistorian section is absent.
/// </summary>
public int HistoryReadBatchParallelism { get; set; } = 4;
/// <summary>
/// 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
/// <c>BadTooManyOperations</c>. Mirrors <c>ServerHistorianOptions.MaxConcurrentHistoryReadBatches</c>;
/// the Host sets it at <c>StartAsync</c>. The default (16) survives when the section is absent.
/// </summary>
public int MaxConcurrentHistoryReadBatches { get; set; } = 16;
/// <summary>
/// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in
/// flight at the deadline surfaces <c>BadTimeout</c>. Non-positive = unbounded. Mirrors
/// <c>ServerHistorianOptions.HistoryReadDeadline</c>; the Host sets it at <c>StartAsync</c>. The
/// default (60 s) survives when the section is absent.
/// </summary>
public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Lazily-created process-wide HistoryRead-batch limiter (see <see cref="MaxConcurrentHistoryReadBatches"/>).</summary>
private volatile SemaphoreSlim? _historyReadBatchLimiter;
private readonly object _historyReadBatchLimiterLock = new();
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
/// <summary>
/// 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
/// <see cref="SessionHistoryContinuationStore"/> binds points to the OPC UA session — so they are
/// capped (<c>ServerConfiguration.MaxHistoryContinuationPoints</c>, SDK default 100, oldest-evicted)
/// and disposed when the session closes. Exposed (internal) so the session-less in-process tests can
/// inject an <see cref="InMemoryHistoryContinuationStore"/> and exercise the full multi-page round
/// trip through the same dispatch path. Assigning <c>null</c> restores the session-backed default.
/// </summary>
internal IHistoryContinuationStore HistoryContinuationStore
{
get => _historyContinuationStore;
set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore();
}
/// <summary>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.</summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null;
/// <summary>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.</summary>
/// <param name="nodeId">The variable node identifier.</param>
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
/// <returns>True when the node is registered as historized; otherwise false.</returns>
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;
}
/// <summary>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.</summary>
/// <param name="nodeId">The resolved SDK node id from the HistoryRead request.</param>
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
/// <returns>True when the node is registered as historized; otherwise false.</returns>
private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname)
{
if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; }
tagname = null;
return false;
}
/// <summary>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.</summary>
/// <param name="nodeId">The variable node identifier.</param>
/// <returns>The cached <see cref="BaseDataVariableState"/>, or null when none is registered.</returns>
internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null;
/// <summary>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).</summary>
/// <param name="nodeId">The folder node identifier.</param>
/// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
/// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
/// across redeploys.</summary>
/// <param name="alarmNodeId">The alarm condition node identifier.</param>
/// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
/// <returns>The number of notifier entries on the condition.</returns>
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<NodeState.Notifier>();
alarm.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised folder <paramref name="folderNodeId"/> — 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.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
/// <returns>The number of notifier entries on the folder.</returns>
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<NodeState.Notifier>();
folder.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
/// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="realm">The realm the folder lives in.</param>
/// <returns>True when the folder is a registered root notifier.</returns>
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
&& _notifierFolders.ContainsKey(folder.NodeId);
}
/// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
/// </summary>
/// <param name="nodeId">The node identifier of the variable.</param>
/// <param name="value">The new value to write.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included (#519 — passed to the SDK verbatim).</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
/// <param name="realm">Which of the two v3 namespaces the <paramref name="nodeId"/> lives in; the
/// namespace index is resolved from this rather than parsed out of the id string.</param>
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);
}
/// <summary>The most items <see cref="WriteValues"/> applies under a SINGLE <c>Lock</c> acquisition before
/// releasing and re-taking it.
/// <para>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 &gt;99.8% of the
/// takes, so an unbounded hold buys essentially nothing more. What it DOES buy is a stall: the manager-wide
/// <c>Lock</c> 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 <c>CreateVariable</c> 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.</para></summary>
private const int MaxWritesPerLockAcquisition = 512;
/// <summary>Batched <see cref="WriteValue"/>: one <c>Lock</c> acquisition per chunk of
/// <see cref="MaxWritesPerLockAcquisition"/> 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
/// <see cref="WriteValue"/> (create-on-first-write, #519 verbatim status codes, ClearChangeMasks per node).
/// <para><b>Items are isolated.</b> 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 <see cref="ValueWriteBatchException"/>. 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.</para></summary>
/// <param name="values">The value writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">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.</exception>
public void WriteValues(IReadOnlyList<ValueWrite> 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!);
}
}
/// <summary>Apply a batch of <b>status-only</b> 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 <see cref="WriteValues"/>: an outside-the-lock validate/resolve pre-pass, then
/// <see cref="MaxWritesPerLockAcquisition"/>-sized chunks under one <c>Lock</c> each, per-item isolation,
/// and a <see cref="ValueWriteBatchException"/> aggregate.
/// <para><b>It never creates a node.</b> 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).</para></summary>
/// <param name="statuses">The status writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">When one or more items failed validation or threw. Every
/// other item HAS been applied.</exception>
public void WriteStatuses(IReadOnlyList<StatusWrite> 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!);
}
}
/// <summary>The body of a single status-only write. <b>Caller MUST hold <c>Lock</c></b>.</summary>
/// <param name="key">The realm-qualified map key.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The timestamp of the annotation in UTC.</param>
/// <returns><see langword="true"/> when the node existed and was stamped; <see langword="false"/> when
/// no such node is materialised (skipped — deliberately not a create; see <see cref="WriteStatuses"/>).</returns>
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;
}
/// <summary>One <see cref="WriteValues"/> item after the outside-the-lock pre-pass: its namespace index and
/// map key already resolved, or <paramref name="IsValid"/> false when the item failed validation.</summary>
/// <param name="NodeId">The item's bare <c>s=</c> identifier.</param>
/// <param name="NamespaceIndex">The namespace index resolved from the item's realm.</param>
/// <param name="Key">The realm-qualified map key.</param>
/// <param name="IsValid">False when the item failed the pre-pass and must be skipped.</param>
private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid);
/// <summary>The body of a single value write. <b>Caller MUST hold <c>Lock</c></b> — this is factored out
/// solely so <see cref="WriteValues"/> can apply a whole batch under one acquisition; it is byte-identical
/// to what <see cref="WriteValue"/> has always done inside its own <c>lock</c>.</summary>
/// <param name="nodeId">The bare <c>s=</c> identifier of the variable.</param>
/// <param name="ns">The namespace index resolved from the write's realm.</param>
/// <param name="key">The realm-qualified map key for <paramref name="nodeId"/>.</param>
/// <param name="value">The new value to write.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
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);
}
/// <summary>
/// Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
/// been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
/// this projects the whole <paramref name="state"/> snapshot
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
/// node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
/// Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
/// <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
/// materialised (and the existing unit tests) keep working.
/// </summary>
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, alarmNodeId);
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
lock (Lock)
{
if (_alarmConditions.TryGetValue(key, out var condition))
{
// T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
// SDK mutated AckedState, then WireMethodTransitionReporting reported the one event for it —
// Gitea #587) NEVER passed through this method, so a last-written cache would be stale and
// wrongly report a delta. By the time the engine re-projects that ack here, the node already
// holds the acked state → no delta → suppress.
var current = ReadConditionDelta(condition);
var incoming = ToConditionDelta(state, condition);
bool fire = ShouldFireConditionEvent(current, incoming);
// EnabledState / AckedState / ActiveState are mandatory children — always present after
// Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
// that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
// future SDK that builds a leaner child set) may leave them null. Null-guard each optional
// child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
// no-op rather than an NRE.
condition.SetEnableState(SystemContext, state.Enabled);
condition.SetActiveState(SystemContext, state.Active);
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (condition.ConfirmedState is not null)
{
condition.SetConfirmedState(SystemContext, state.Confirmed);
}
if (condition.ShelvingState is not null)
{
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
// arms does nothing (OnTimedUnshelve is a no-op): the engine's own expiry re-projects.
condition.SetShelvingState(
SystemContext,
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0);
}
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
// #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
// it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
// null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
if (condition.Quality is not null)
{
condition.Quality.Value = new StatusCode(state.StatusCode);
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
}
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// ConditionRefresh replays it. The event firing below also depends on this Retain being
// correct (a non-retained inactive+acked condition still fires its transition event, but
// won't be replayed on a later ConditionRefresh).
condition.Retain.Value = state.Active || !state.Acknowledged;
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
// T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
// change (the delta-gate decided above, against the node's pre-projection state). A
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
// of a client method call already applied (and already reported, by the method path —
// WireMethodTransitionReporting) equals the node's current state → no delta → suppress, so
// the transition is reported exactly once. ReportConditionEvent
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
if (fire)
{
ReportConditionEvent(condition, sourceTimestampUtc);
}
return;
}
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
// un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
// the SDK address space, so it MUST run under Lock (see WriteValue).
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(alarmNodeId, ns);
_variables[key] = variable;
}
variable.Value = new[] { state.Active, state.Acknowledged };
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>
/// Gitea #597 — the <c>shelvingTime</c> (ms) to hand <c>SetShelvingState</c> for a timed shelve expiring at
/// <paramref name="unshelveAtUtc"/>. <c>0</c> when the expiry is unknown (the SDK then uses
/// <c>MaxTimeShelved</c>, 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 <c>int.MaxValue</c> (the SDK timer's range).
/// </summary>
/// <param name="unshelveAtUtc">The engine's expiry instant, or null.</param>
/// <returns>The shelving time in milliseconds.</returns>
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);
}
/// <summary>
/// #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 <see cref="ConditionState.Quality"/> — 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.
/// <para><b>#519 changed the fire test from bucket to full code.</b> 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.</para>
/// </summary>
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
/// <param name="statusCode">The source-data status code to annotate, sub-code included.</param>
/// <param name="sourceTimestampUtc">Timestamp of the connectivity transition in UTC.</param>
/// <param name="realm">The condition's address-space realm.</param>
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);
}
}
}
/// <summary>
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
/// have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
/// <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
/// <see cref="InstanceStateSnapshot"/> (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).
/// <para>
/// A fresh <c>EventId</c> 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
/// <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
/// per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
/// materialise) — no branch creation here.
/// </para>
/// <para>
/// <b>Method-driven transitions (Gitea #587).</b> 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 <see cref="WriteAlarmCondition"/>.
/// The SDK reports <b>no</b> event for it — its <c>ReportStateChange</c> is gated on
/// <c>AutoReportStateChanges</c>, which stays false — so the method path calls this method itself,
/// once, after the handler returns (<see cref="WireMethodTransitionReporting"/>). The engine then
/// re-projects that same transition through <see cref="WriteAlarmCondition"/>, 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.
/// </para>
/// </summary>
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
/// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
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);
}
}
/// <summary>
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
/// instances compare by value, so <see cref="ShouldFireConditionEvent"/> is a plain inequality.
/// <para>
/// <b>Severity</b> is stored as the value the node holds after <c>SetSeverity</c> — the exact
/// Part 9 severity clamped to 1..1000 (<see cref="ToPart9Severity"/>; it used to be one of five
/// <see cref="EventSeverity"/> buckets, see there) — so a severity change fires an event. The
/// <b>Shelving</b> 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.
/// </para>
/// <para>
/// <b>Why <c>CommentAdded</c> is not a field here (intentional).</b>
/// <c>EmissionKind.CommentAdded</c> is produced only by
/// <c>Part9StateMachine.ApplyAddComment</c>, which is reached only via
/// <c>ScriptedAlarmEngine.AddCommentAsync</c>, which is called only from
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
/// <c>ServiceResult.Good</c>, the OPC UA SDK applies the comment to the node, and the method
/// path reports the one Part 9 comment event (<see cref="WireMethodTransitionReporting"/>,
/// Gitea #587) — BEFORE the engine re-projects via <see cref="WriteAlarmCondition"/>. 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
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
/// comment path, so suppression never drops a needed event.
/// </para>
/// <para>
/// <b>Why <c>Retain</c> is absent (intentional — safe today).</b>
/// <c>Retain</c> is projected as <c>state.Active || !state.Acknowledged</c> in
/// <see cref="WriteAlarmCondition"/>. Every path that flips <c>Retain</c> necessarily
/// changes <c>Active</c> or <c>Acknowledged</c> (both ARE compared fields), so a
/// <c>Retain</c> flip always rides along with a real delta and fires correctly. If a
/// future engine were to set <c>Retain</c> independently — without touching
/// <c>Active</c>/<c>Acknowledged</c> — it would need to be added here.
/// </para>
/// </summary>
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);
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> 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 <c>true</c> iff
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
/// <paramref name="current"/> == <paramref name="incoming"/> ⇒ <c>false</c> (suppress the re-projected
/// double-emit); a genuine engine-driven transition differs ⇒ <c>true</c> (fire).</summary>
/// <param name="current">The node's current (pre-projection) gate-relevant state.</param>
/// <param name="incoming">The incoming snapshot's gate-relevant state.</param>
/// <returns><c>true</c> to fire a condition event; <c>false</c> to suppress (no delta).</returns>
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
current != incoming;
/// <summary>Read the gate-relevant slice off the LIVE condition node. Mandatory children
/// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
/// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-clamped
/// value the node stores, and shelving is mapped from the shelving
/// state machine's CurrentState so it lines up with <see cref="ToConditionDelta"/>.</summary>
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);
/// <summary>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 <see cref="ToPart9Severity"/> so it matches
/// the value the node holds (the projection calls <c>SetSeverity(ToPart9Severity(...))</c>), 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.</summary>
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
Active: state.Active,
Acknowledged: state.Acknowledged,
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
Enabled: state.Enabled,
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
// node's read-back default (Unshelved).
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
MappedSeverity: (ushort)ToPart9Severity(state.Severity),
Message: state.Message ?? string.Empty,
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
// node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
Quality: condition.Quality is not null ? new StatusCode(state.StatusCode) : StatusCodes.Good);
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
/// <see cref="AlarmShelvingKind"/> 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
/// <see cref="AlarmShelvingKind.Unshelved"/> — the same value <see cref="ToConditionDelta"/> folds an
/// unsupported shelving snapshot to, so the two stay comparable.</summary>
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;
}
/// <summary>
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> 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
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
/// <para>
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
/// <b>no</b> events itself.
/// </para>
/// Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
/// </summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
/// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
/// <param name="severity">Domain severity (an OPC UA 1..1000 severity); written verbatim, clamped to 1..1000.</param>
/// <param name="isNative">True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.</param>
/// <remarks>
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
/// map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
/// <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
/// <c>LimitAlarm</c>, which has no script-supplied limits) → the base
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.</para>
/// </remarks>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var conditionKey = MapKey(realm, alarmNodeId);
lock (Lock)
{
// R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply,
// MaterialiseScriptedAlarms + the native-alarm materialise re-run over the FULL composition, so
// an existing enabled condition is re-materialised with the SAME id + kind. Skip it — KEEP the
// existing AlarmConditionState instance alive so every MonitoredItem on that condition node
// survives the deploy (the drop-and-recreate below would otherwise kill them). A genuine
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
return;
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
if (_alarmConditions.TryRemove(conditionKey, out var existing))
{
// Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no
// referencing equipment folder keeps a dangling inverse-notifier entry to the dropped
// AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no
// same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
UnwireAlarmNotifiers(conditionKey);
existing.Parent?.RemoveChild(existing);
ForgetPredefinedSubtree(existing);
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
// dropped instance's back here rather than leave it tracked until the next full reconcile.
if (_allEventsMonitoredConditions.Remove(existing))
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
}
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
// kind, so always drop the stale native flag first and only re-add it below when isNative.
_nativeAlarmNodeIds.Remove(conditionKey);
var parent = ResolveParentFolder(equipmentNodeId, realm);
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
alarm.SymbolicName = displayName;
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
// type's embedded definition; we do not hand-build them.
alarm.Create(
SystemContext,
new NodeId(alarmNodeId, ns),
new QualifiedName(displayName, ns),
new LocalizedText(displayName),
assignNodeIds: true);
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
// with NO default), and nothing downstream fills them in: the auto-filling
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
// is already carried by ConditionName below. See docs/AlarmTracking.md.
alarm.EventType.Value = alarm.TypeDefinitionId;
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
alarm.SourceName.Value = alarmNodeId;
// #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
// text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
// #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
// UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
// unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
// has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
// BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
// driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
// SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
// annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
if (alarm.Quality is not null)
{
alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
}
// Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
// supplies on every transition, rather than the SDK's default false until the first projection.
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
alarm.SetSeverity(SystemContext, ToPart9Severity(severity));
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
// TimedShelve handler rejects any ShelvingTime above it — so every client TimedShelve failed with
// BadShelvingTimeOutOfRange. The bound becomes the longest shelve the SDK's own shelving timer can
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
maxTimeShelved.Value = int.MaxValue;
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
alarm.Message.Value = new LocalizedText(displayName);
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
// SDK applies its node state. The SDK does NOT report an event for it (its ReportStateChange is
// gated on AutoReportStateChanges, which stays false) — the ONE condition event per transition
// is ours, raised after the SDK handler returns by WireMethodTransitionReporting (Gitea #587).
// The engine's later re-projection of the same transition reaches WriteAlarmCondition's
// delta-gate, which finds the node already in that state and suppresses a second event.
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the
// scripted path even for native conditions (H6c scope is Acknowledge only).
// Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the
// shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for
// them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would
// silently undo one at the next transition.
alarm.OnAcknowledge = (context, condition, _, comment) =>
IsNativeAlarmNode(alarmNodeId, realm)
? HandleNativeAlarmAck(context, condition, comment)
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
alarm.OnConfirm = (context, condition, _, comment) =>
IsNativeAlarmNode(alarmNodeId, realm)
? new ServiceResult(StatusCodes.BadNotSupported)
: HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
alarm.OnAddComment = (context, condition, _, comment) =>
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
{
if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported);
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
// shelvingTime is an OPC UA Duration (milliseconds).
var (operation, unshelveAt) =
!shelving ? ("Unshelve", (DateTime?)null)
: oneShot ? ("OneShotShelve", null)
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
};
// The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since Gitea
// #597) fires with a session-less system context. It is deliberately a NO-OP: the engine owns
// timed-shelve expiry (ScriptedAlarmEngine's shelving check → Part9StateMachine.ApplyShelvingCheck,
// recorded as "AutoUnshelve" by "system" with its "Timed shelving expired" comment), and its
// re-projection is what unshelves this node. Routing an operator-style "Unshelve" from here used to
// pre-empt that check on every expiry — from BOTH pair nodes, rewriting the audit trail as a manual
// unshelve — and a timer callback already queued when a re-shelve disposed it could unshelve the
// fresh shelve. The SDK's own OnTimerExpired changes no shelving state itself (it only refreshes
// UnshelveTime after this callback), so returning without routing leaves the node shelved until
// the engine's expiry lands. Native conditions are never timed-shelved (BadNotSupported above).
alarm.OnTimedUnshelve = (_, _) => ServiceResult.Good;
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
// engine enable/disable surface — they short-circuit to BadNotSupported.
alarm.OnEnableDisable = (context, condition, enabling) =>
{
if (IsNativeAlarmNode(alarmNodeId, realm))
return new ServiceResult(StatusCodes.BadNotSupported);
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
};
// Gitea #587: exactly one condition event per accepted method call, reported by us once the SDK's
// own handler has applied the new state.
WireMethodTransitionReporting(alarm, conditionKey);
// Gitea #586: the condition itself delivers its events to Server-object (all-events)
// subscriptions, item by item through the ACL. A condition is never an event notifier, so no
// MonitoredNode2 ever claims this single-slot delegate — every event the condition reports, ours
// or the SDK's own method-driven state changes, passes through it exactly once.
alarm.OnReportEvent = OnConditionReportEvent;
parent.AddChild(alarm);
// Promote the equipment folder to an event notifier + register it as a root notifier so
// T16's ReportEvent reaches the folder's subscribers. Guard so repeated
// materialise under the same folder doesn't double-add the root notifier.
EnsureFolderIsEventNotifier(parent);
AddPredefinedNode(SystemContext, alarm);
_alarmConditions[conditionKey] = alarm;
// Only the new condition can be missing its unit here, so raise just that one — a full reconcile
// walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object
// item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile.
RaiseAllEventsMonitoring(alarm);
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
// for a later re-add to resurrect.
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
RestoreCarryOver(alarm, conditionKey, carried);
}
}
/// <summary>
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
/// <see cref="RebuildAddressSpace"/>.
/// </summary>
/// <param name="State">The gate-relevant fields, read off the node as <see cref="ReadConditionDelta"/> does.</param>
/// <param name="Time">The condition's <c>Time</c> — the instant its current state came about.</param>
/// <param name="ActiveTransitionTime">When it last changed Active, if the SDK recorded it.</param>
/// <param name="QualityTimestamp">The source timestamp of its Quality annotation, if present.</param>
/// <param name="Comment">The condition's <c>Comment</c> (a client's ack / AddComment text), if any.</param>
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
LocalizedText? Comment = null, string? ClientUserId = null);
/// <summary>
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the NODE, not a host-side cache.</b> The node is the only place that knows the true state:
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
/// the driver or <c>NativeAlarmProjector</c>. Replaying from any host-side record would un-acknowledge it.
/// </para>
/// <para>
/// <b>Why it is needed at all.</b> A rebuild recreates every condition inactive + acknowledged. When the
/// alarm-tag set is unchanged — the common case: adding a <c>UnsTagReference</c> to an alarm-bearing raw
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
/// Without this, every active alarm read normal until its next device transition.
/// </para>
/// </remarks>
/// <param name="condition">The live native condition, about to be discarded.</param>
/// <returns>Its carried state.</returns>
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
ReadConditionDelta(condition),
condition.Time?.Value ?? DateTime.UtcNow,
condition.ActiveState?.TransitionTime?.Value,
condition.Quality?.SourceTimestamp?.Value,
condition.Comment?.Value,
condition.ClientUserId?.Value);
/// <summary>
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
/// under <c>Lock</c>, after the condition is registered.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quality always; the rest only off-normal.</b> Source quality is annotated only on a driver's
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
/// reading <c>BadWaitingForInitialData</c> until the driver next flapped; it is restored silently. A
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
/// materialise built, message and authored severity included, and reports nothing — the same rule the
/// scripted re-assert follows (#487).
/// </para>
/// <para>
/// <b>Reported later, once.</b> The state is applied now, so a Read or ConditionRefresh in the window
/// already sees it, but the one condition event waits for <see cref="CompleteAddressSpaceApply"/>: the
/// equipment folders the condition reports through are wired AFTER this call
/// (<see cref="WireAlarmNotifiers"/>), and reporting now would miss them. Native conditions refuse
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
/// </para>
/// </remarks>
/// <param name="alarm">The new condition instance.</param>
/// <param name="conditionKey">Its key.</param>
/// <param name="carried">What the discarded instance held.</param>
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
{
var state = carried.State;
if (alarm.Quality is not null)
{
alarm.Quality.Value = state.Quality;
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
alarm.Quality.SourceTimestamp.Value = qualityAt;
}
var retained = state.Active || !state.Acknowledged;
if (!retained) return;
alarm.SetActiveState(SystemContext, state.Active);
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
transitionTime.Value = activeAt;
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
alarm.Message.Value = new LocalizedText(state.Message);
alarm.Retain.Value = true;
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
alarm.Time.Value = carried.Time;
alarm.ReceiveTime.Value = carried.Time;
_restoredConditionReports.Add((conditionKey, alarm));
}
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
/// the instance changes).</summary>
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
/// <remarks>
/// 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 <paramref name="notifierFolderNodeIds"/>
/// (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
/// <c>ChangedRawTags → Rebuild</c> branch to <em>alarm-bearing / identity-changing</em> raw-tag deltas
/// only (see <c>AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible</c>, which carries the
/// matching binding guard): a NON-alarm raw tag carries no notifier wiring, so its
/// <c>ReferencingEquipmentPaths</c> edit is node-inert and routes surgically.
/// </remarks>
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> 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<FolderState>());
_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);
}
}
}
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
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);
}
/// <summary>
/// Wraps each Part 9 method the SDK wired on <paramref name="alarm"/> (Acknowledge, Confirm, AddComment,
/// Enable, Disable, OneShotShelve, TimedShelve, Unshelve) so an accepted call reports exactly one
/// condition event carrying the post-call state (Gitea #587).
/// </summary>
/// <param name="alarm">The freshly created condition; its method children carry the SDK's handlers.</param>
/// <param name="conditionKey">Its key in <c>_alarmConditions</c>.</param>
/// <remarks>
/// <para>
/// <b>Why not <c>AutoReportStateChanges</c>.</b> The SDK's own report from a method handler
/// (<c>ConditionState.ReportStateChange</c>) is gated on <c>AutoReportStateChanges</c> — 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
/// <c>EventsMonitored()</c>, the <c>AreEventsMonitored</c> 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
/// <c>i=2253</c> item lives (#586 review). And the SDK's shelve handlers report <i>before</i> any
/// shelving state is applied (they only call <c>OnShelve</c>), 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: <see cref="ReportConditionEvent"/> walks the notifier
/// graph unconditionally, and Server-object delivery goes through <see cref="OnConditionReportEvent"/>
/// and the ACL like every other condition event.
/// </para>
/// <para>
/// <b>Shelving is applied here.</b> The SDK's shelve methods never change <c>ShelvingState</c>
/// themselves, so an accepted shelve call applies it — with the same <c>(shelved, oneShot)</c> mapping
/// <see cref="WriteAlarmCondition"/> projects, and a TimedShelve with its requested duration so
/// <c>UnshelveTime</c> 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.
/// </para>
/// <para>
/// <b>Exactly once.</b> The whole call runs under <c>Lock</c>. 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 <c>Lock</c> 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.
/// </para>
/// <para>
/// <b>Native conditions support Acknowledge and AddComment only</b> (Confirm and the shelve methods
/// are refused with <c>BadNotSupported</c> before they get here). Their Confirmed and Shelving stay
/// owned by the driver projection (<c>NativeAlarmProjector</c>: always Confirmed, Unshelved). The SDK's
/// Acknowledge sets <c>Confirmed = false</c> because the condition type supports Confirm, which would
/// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps
/// <c>ConfirmedState</c> straight into its alarm state — so a native ack re-asserts Confirmed and the
/// projection's Retain rule (<c>Active || !Acked</c>) before the one report.
/// </para>
/// <para>
/// <b>Only a real change is reported.</b> 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 <c>Confirmed = false</c> again; a re-ack therefore also restores the
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores an
/// uncommented re-ack) and the call is silent — unless it carried a comment, which the SDK writes and
/// which is then reported once like AddComment (Gitea #597). The scripted engine records that comment
/// too (<c>Part9StateMachine.ApplyAcknowledge</c> treats a commented re-ack as AddComment), so the
/// reported comment is persisted and replicated, not node-local. AddComment always reports: adding a comment is itself
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
/// </para>
/// <para>
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. a denied gate, or a refused method on a native
/// condition) reports nothing: there was no transition.
/// </para>
/// </remarks>
private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey)
{
alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks)
if (alarm.Acknowledge?.OnCall is { } acknowledge)
{
// A commented acknowledge always reports (Gitea #597): the SDK writes the comment even on a re-ack,
// and a new comment is a Part 9 change in its own right — exactly as AddComment always reports. An
// uncommented re-ack still changes nothing and stays silent.
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true,
alwaysReport: !LocalizedText.IsNullOrEmpty(comment));
}
WrapComment(alarm.Confirm, alwaysReport: false);
WrapComment(alarm.AddComment, alwaysReport: true);
WrapGeneric(alarm.Enable, apply: null);
WrapGeneric(alarm.Disable, apply: null);
if (alarm.ShelvingState is { } shelving)
{
WrapGeneric(shelving.OneShotShelve, () => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: true, shelvingTime: 0));
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
{
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
// arms does nothing (OnTimedUnshelve is a no-op — the engine's AutoUnshelve owns the expiry).
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
() => timed(context, method, objectId, shelvingTime),
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));
}
}
void WrapComment(AddCommentMethodState? method, bool alwaysReport)
{
if (method?.OnCall is not { } sdk) return;
method.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport);
}
void WrapGeneric(MethodState? method, Action? apply)
{
if (method?.OnCallMethod is not { } sdk) return;
method.OnCallMethod = (context, m, inputs, outputs) =>
ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply);
}
ServiceResult ReportAfterMethod(
Func<ServiceResult> 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;
}
}
}
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> 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.</summary>
/// <param name="alarmNodeId">The alarm condition node id.</param>
/// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
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));
}
/// <summary>
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
/// (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
/// mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
/// </summary>
/// <param name="context">The SDK context the handler delegate was invoked with — a
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
/// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
/// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
/// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state, and the method path
/// reports the one condition event — Gitea #587);<c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
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;
}
/// <summary>
/// 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 <see cref="NativeAlarmAck"/> to
/// <see cref="NativeAlarmAckRouter"/> (the driver-bound seam) rather than a scripted
/// <see cref="AlarmCommand"/> to <see cref="AlarmCommandRouter"/>. The calling principal is resolved
/// and the <c>AlarmAck</c> role gate applied EXACTLY as in <see cref="HandleAlarmCommand"/>
/// (<b>fails closed</b>: a missing identity or missing role is denied — no route, no state mutation).
/// </summary>
/// <param name="context">The SDK context the handler delegate was invoked with — a
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session identity
/// (T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>).</param>
/// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state; the method path reports the event);
/// <c>BadUserAccessDenied</c> when the gate vetoes, <c>BadInvalidState</c> when this node is not the redundancy
/// Primary (<see cref="NativeAlarmAckPrimaryGate"/>, Gitea #597) — in both cases no route, no state mutation.</returns>
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;
}
/// <summary>
/// The one authorization gate both alarm-command paths share — scripted
/// (<see cref="HandleAlarmCommand"/>) and native (<see cref="HandleNativeAlarmAck"/>).
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// With ACLs enforced, the Part 9 <c>Acknowledge</c> / <c>Confirm</c> / shelve calls map onto
/// their own <see cref="OpcUaOperation"/> members, so a grant CAN distinguish them — unlike the
/// role gate this replaces, where one <c>AlarmAck</c> role covered all three. Anything not
/// recognised as confirm or shelve is treated as an acknowledge, which is the most restrictive
/// reading available: <c>AlarmAcknowledge</c> is the permission an operator authoring "can this
/// principal act on alarms at all" grants first.
/// </para>
/// </remarks>
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
/// <param name="condition">The condition the method targets.</param>
/// <param name="operation">The Part 9 operation name.</param>
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
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;
}
/// <summary>Maps a Part 9 method name onto the permission it requires.</summary>
/// <param name="operation">The Part 9 operation name.</param>
/// <returns>The operation to authorize.</returns>
internal static OpcUaOperation AlarmOperationFor(string operation) => operation switch
{
"Confirm" => OpcUaOperation.AlarmConfirm,
"OneShotShelve" or "TimedShelve" or "Unshelve" => OpcUaOperation.AlarmShelve,
_ => OpcUaOperation.AlarmAcknowledge,
};
/// <summary>
/// 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.
/// </summary>
/// <param name="identity">The authenticated identity.</param>
/// <returns>The name to record.</returns>
private static string AlarmOperatorName(RoleCarryingUserIdentity identity) =>
identity.Principal.Length > 0 ? identity.Principal : identity.DisplayName ?? string.Empty;
/// <summary>
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
/// (<b>fails closed</b>: a missing identity / missing role ⇒ <c>BadUserAccessDenied</c>; no gateway ⇒
/// <c>BadNotWritable</c>) via the pure <see cref="EvaluateEquipmentWriteGate"/>, and on pass dispatches
/// the value through <see cref="NodeWriteGateway"/>.
/// <para>
/// The dispatch is FIRE-AND-FORGET: the SDK's <c>CustomNodeManager2.Write</c> holds the node
/// manager <c>Lock</c> 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
/// <see cref="ServiceResult.Good"/> lets the SDK apply the written value optimistically.
/// </para>
/// <para>
/// <b>Item A — synchronous structural fail-fast.</b> After the authz gate passes but BEFORE the
/// optimistic dispatch, a pure <see cref="EvaluateEquipmentWriteStructure"/> pre-check rejects a
/// structurally-invalid write (e.g. a <c>null</c> 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.
/// </para>
/// <para>
/// <b>Write-outcome self-correction.</b> Before returning Good (which makes the SDK overwrite the
/// node with <paramref name="value"/>) 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
/// <see cref="NodeWriteOutcome"/> 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 <b>Bad-quality blip</b> (Item B), reverts the node to its
/// prior value/status, and raises a Part 8 <b>AuditWriteUpdateEvent</b> (Item C) recording the
/// rejected write (<see cref="RevertOptimisticWriteIfNeeded"/> / <see cref="ShouldRevert"/>). On
/// success the optimistic value stands and the next poll re-confirms it via the normal
/// <see cref="WriteValue"/> path.
/// </para>
/// </summary>
private ServiceResult OnEquipmentTagWrite(
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gateway = _nodeWriteGateway;
var gatewayWired = gateway is not NullOpcUaNodeWriteGateway;
// #520 stage 5. With ACLs enforced the scoped decision REPLACES the server-wide WriteOperate role
// check — see AclOptions for why chaining them would leave every narrow grant inert. The
// gateway-wired check applies either way: it is an availability fact, not an authorization one.
// With the flag off, Gitea #563: the activation-time WriteOperate role is honoured only while the
// session's key is still usable — checked first, so an expired key reads as denied rather than as
// "writes unavailable" on a node with no gateway.
var gate = AclEnforced
? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId))
?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"))
: _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.WriteOperate, node.NodeId)
?? EvaluateEquipmentWriteGate(identity, gatewayWired);
if (gate is not null) return gate;
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below.
// A numeric array of another element type is converted (checked) to the node's element type here and
// the converted array replaces `value`, so the SDK stores — and the driver receives — the node's own type.
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
var range = EvaluateEquipmentWriteRange(indexRange);
if (range is not null) return range;
var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
if (structure is not null) return structure;
value = coerced!;
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
var optimisticValue = value;
// v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS
// write to nodes that share a bare id are distinguishable downstream — WP3's write gateway keys its
// (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm.
var nodeKey = node.NodeId.ToString();
var nodeRealm = RealmOf(node.NodeId);
var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty;
object? priorValue = null;
StatusCode priorStatus = StatusCodes.Good;
if (node is BaseDataVariableState variable)
{
priorValue = variable.Value;
priorStatus = variable.StatusCode;
}
// Item C: thread the writing principal's user-id string into the failure continuation so the audit
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
// practice non-null on this path, but kept defensive).
// Prefer the ACL principal: for a key-bearing client DisplayName is the static username every key
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
// sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the
// revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
// task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
// re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
_ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None)
.ContinueWith(
t =>
{
var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");
RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm);
},
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
return ServiceResult.Good;
}
/// <summary>
/// Pure role + availability gate for an inbound equipment-tag write, extracted off
/// <see cref="OnEquipmentTagWrite"/> so it is unit-testable without booting an SDK server. Fails closed:
/// a null identity or an identity missing the <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role ⇒
/// <c>BadUserAccessDenied</c>. When the gate passes but no real gateway is wired
/// (<paramref name="gatewayWired"/> is false) ⇒ <c>BadNotWritable</c> ("writes unavailable"). A
/// <c>null</c> return means "proceed" (the caller dispatches + returns Good). Role comparison is
/// case-insensitive (the role set is built with <see cref="StringComparer.OrdinalIgnoreCase"/>),
/// matching the alarm gate.
/// </summary>
/// <param name="identity">The role-carrying identity extracted off the SDK context, or null when the
/// session is anonymous / carries no role-carrying identity.</param>
/// <param name="gatewayWired">True when a non-Null <see cref="IOpcUaNodeWriteGateway"/> is wired; false
/// for the Null default (no route — e.g. admin-only nodes / pre-boot).</param>
/// <returns><c>null</c> to proceed (gate passed); otherwise the veto <see cref="ServiceResult"/>
/// (<c>BadUserAccessDenied</c> on a failed role gate, <c>BadNotWritable</c> when no gateway is wired).</returns>
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;
}
/// <summary>
/// <b>Item A — synchronous structural fail-fast.</b> Pure structural pre-check for an inbound
/// equipment-tag write, run AFTER <see cref="EvaluateEquipmentWriteGate"/> passes but BEFORE the
/// optimistic device dispatch in <see cref="OnEquipmentTagWrite"/>. A structurally-invalid write is
/// rejected INLINE (a Bad <see cref="ServiceResult"/> 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.
/// <para>
/// <b>Interpretation / tradeoff (the item was deliberately under-specified).</b> The minimum
/// sensible structural check is a <c>null</c> value write to a value variable ⇒
/// <c>BadTypeMismatch</c> (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 <see cref="BaseDataVariableState"/>
/// whose <see cref="BaseVariableState.DataType"/> 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 ⇒ <c>BadTypeMismatch</c>. 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
/// <see cref="Variant"/> payload whose type isn't cheaply known) is allowed through.
/// </para>
/// <para>
/// <b>⚠️ This is the ONLY type check a writable equipment node gets</b> (Gitea #610 review). The SDK's
/// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when
/// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
/// by shape and declared length, and its element type must either match the node's DataType or be
/// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
/// into the node's element type (<see cref="TryConvertNumericArray"/>) — refused whole if any element is
/// out of range, fractional for an integer node, or NaN/±Infinity for an integer node — and every other
/// mismatch is refused. An array value to a scalar node is refused only when
/// <paramref name="refuseArrayOnScalar"/> says the owning driver cannot take one. A SCALAR write keeps
/// the looser numeric rule above on purpose (see <see cref="IsBuiltInTypeCompatible"/>).
/// </para>
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
/// <c>DataType</c>, so it is unit-testable in isolation.
/// </summary>
/// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
/// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
/// <param name="refuseArrayOnScalar">True when the node's owning driver cannot write an array value to a
/// scalar tag (Modbus / S7): an array value to a scalar node is then refused. False keeps the pre-#610
/// behaviour for that case (dispatch; the driver — e.g. OpcUaClient's pass-through — decides).</param>
/// <param name="coerced">On a <c>null</c> return, the value to store and dispatch: <paramref name="value"/>
/// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
/// <c>null</c> when the write is refused.</param>
/// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
/// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
internal static ServiceResult? EvaluateEquipmentWriteStructure(
object? value, NodeState node, bool refuseArrayOnScalar, out object? coerced)
{
coerced = null;
var result = EvaluateEquipmentWriteStructureCore(value, node, refuseArrayOnScalar, ref coerced);
if (result is not null) coerced = null;
else coerced ??= value;
return result;
}
/// <summary>The verdict half of <see cref="EvaluateEquipmentWriteStructure(object?, NodeState, bool, out object?)"/>
/// for callers that only need the decision; the array-on-scalar refusal is applied as for a Modbus / S7 node.
/// Production goes through the four-argument overload, which also returns the converted value.</summary>
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) =>
EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar: true, out _);
private static ServiceResult? EvaluateEquipmentWriteStructureCore(
object? value, NodeState node, bool refuseArrayOnScalar, ref object? coerced)
{
// Minimum sensible check: a null payload is never a valid value write to a value variable.
if (value is null)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
}
// Gitea #610 — an array node (writable only when its driver encodes whole-array writes) takes a whole
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
var isArrayNode = false;
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
{
isArrayNode = true;
if (value is not Array { Rank: 1 } array)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
}
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
if (declared > 0 && array.Length != declared)
{
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array write of {array.Length} element(s) to a {declared}-element array node");
}
}
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
if (node is not BaseDataVariableState variable) return null;
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
// ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
// lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
// writing that array back worked before #610 on the pass-through drivers (OpcUaClient forwards the
// Variant; Galaxy encodes it), so those keep dispatching and the driver decides. A byte[] is exempt: on
// the wire it is a ByteString, which is a scalar (the type check below still decides whether the node
// takes one). An unresolved-DataType node deferred above, as it always has.
if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
}
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
if (!IsCheckableExpectedType(expected)) return null;
// TypeInfo.Construct(object) classifies the runtime payload (an array by its ELEMENT type); an
// unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType;
// Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
// because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
// handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
// driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
// covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
// serving an int[] under an Int16 DataType.
// • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
// classifies as ByteString) proceeds unchanged.
// • A NUMERIC array of another numeric element type is CONVERTED, checked, into the node's element type,
// and the converted array is what the SDK stores and the driver receives. This is not optional: the
// consumer #610 exists for, ScadaBridge, writes the CLR list it decoded from a List attribute, and its
// element types are only Boolean/Int32/Float/Double/String/DateTime — an exact-match rule left every
// Int16/UInt16/Byte/UInt32/Int64/UInt64 array node (Modbus's default Int16 included) and a Double list
// to a Float array permanently unwritable from it. Any element out of the target's range, fractional
// for an integer target, or NaN/±Infinity for an integer target refuses the WHOLE write — so the
// UInt32 → Int32 reinterpretation stays closed (4,000,000,000 overflows; in-range values convert).
// • Anything else — a non-numeric mismatch, or an unclassifiable element type (Variant[] / object[]) — is
// refused rather than deferred.
if (isArrayNode)
{
if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
{
if (TryConvertNumericArray(source, expected, out var converted))
{
coerced = converted;
return null;
}
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array element of type {actual} is not exactly representable as node data type {expected}");
}
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array element type {actual} does not match node data type {expected}");
}
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
if (!IsBuiltInTypeCompatible(expected, actual))
{
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"value built-in type {actual} is not compatible with node data type {expected}");
}
return null;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="indexRange">The write's parsed IndexRange (<see cref="NumericRange.Empty"/> when absent).</param>
/// <returns><c>null</c> to proceed; otherwise <c>BadWriteNotSupported</c>.</returns>
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
indexRange == NumericRange.Empty
? null
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
/// <summary>
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>'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
/// (<c>Convert.ToInt16</c>, MxValueEncoder's <c>uint ≤ int.MaxValue</c> arm, …), so an out-of-range value
/// fails the device write and is reverted rather than silently reinterpreted; a
/// <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
/// <see cref="BuiltInType.Variant"/> 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.
/// </summary>
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;
}
/// <summary>True for the OPC UA numeric built-in types (the integer + floating families).</summary>
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;
/// <summary>
/// #610 review R6 — CHECKED element-wise conversion of a numeric array to the node's numeric element type.
/// Fails (nothing converted) when ANY element is out of the target's range, is fractional or NaN/±Infinity
/// for an integer target, or is finite yet overflows to ±Infinity for a <see cref="BuiltInType.Float"/>
/// target. Precision loss inside the target's range (a Double to a Float, a large Int64 to a Double) is
/// accepted, as the scalar path's drivers accept it. Pure + static.
/// </summary>
/// <param name="source">The 1-D numeric array the client wrote.</param>
/// <param name="target">The node's numeric element type.</param>
/// <param name="converted">The converted array (element CLR type of <paramref name="target"/>), or null.</param>
/// <returns>True when every element converted exactly enough to be written.</returns>
internal static bool TryConvertNumericArray(Array source, BuiltInType target, out Array? converted)
{
converted = null;
var elementType = target switch
{
BuiltInType.SByte => typeof(sbyte), BuiltInType.Byte => typeof(byte),
BuiltInType.Int16 => typeof(short), BuiltInType.UInt16 => typeof(ushort),
BuiltInType.Int32 => typeof(int), BuiltInType.UInt32 => typeof(uint),
BuiltInType.Int64 => typeof(long), BuiltInType.UInt64 => typeof(ulong),
BuiltInType.Float => typeof(float), BuiltInType.Double => typeof(double),
_ => null,
};
if (elementType is null) return false;
var result = Array.CreateInstance(elementType, source.Length);
for (var i = 0; i < source.Length; i++)
{
if (!TryConvertNumericElement(source.GetValue(i), target, out var element)) return false;
result.SetValue(element, i);
}
converted = result;
return true;
}
private static bool TryConvertNumericElement(object? element, BuiltInType target, out object? result)
{
result = null;
if (element is null) return false;
var integerTarget = target is not (BuiltInType.Float or BuiltInType.Double);
if (element is float or double)
{
var d = Convert.ToDouble(element, CultureInfo.InvariantCulture);
// Convert.ToInt16(1.5) ROUNDS (banker's) rather than failing, so a fractional value is refused here.
if (integerTarget && (!double.IsFinite(d) || Math.Truncate(d) != d)) return false;
if (target == BuiltInType.Float && double.IsFinite(d) && float.IsInfinity((float)d)) return false;
}
try
{
result = target switch
{
BuiltInType.SByte => Convert.ToSByte(element, CultureInfo.InvariantCulture),
BuiltInType.Byte => Convert.ToByte(element, CultureInfo.InvariantCulture),
BuiltInType.Int16 => Convert.ToInt16(element, CultureInfo.InvariantCulture),
BuiltInType.UInt16 => Convert.ToUInt16(element, CultureInfo.InvariantCulture),
BuiltInType.Int32 => Convert.ToInt32(element, CultureInfo.InvariantCulture),
BuiltInType.UInt32 => Convert.ToUInt32(element, CultureInfo.InvariantCulture),
BuiltInType.Int64 => Convert.ToInt64(element, CultureInfo.InvariantCulture),
BuiltInType.UInt64 => Convert.ToUInt64(element, CultureInfo.InvariantCulture),
BuiltInType.Float => Convert.ToSingle(element, CultureInfo.InvariantCulture),
BuiltInType.Double => Convert.ToDouble(element, CultureInfo.InvariantCulture),
_ => null,
};
return result is not null;
}
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
{
return false;
}
}
/// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types <see cref="ResolveBuiltInDataType"/> 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.</summary>
private static bool IsCheckableExpectedType(BuiltInType t) =>
IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString;
/// <summary>
/// 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 <see cref="object.Equals(object?, object?)"/>) so it is unit-testable without an SDK
/// server.
/// </summary>
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
/// <param name="currentNodeValue">The node's current Value at revert time.</param>
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
/// <returns><c>true</c> to revert (failed outcome and node unchanged since the optimistic write);
/// <c>false</c> on success, or when a poll has already moved the node off the optimistic value.</returns>
internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
!outcome.Success && Equals(currentNodeValue, optimisticValue);
/// <summary>
/// Off-Lock continuation body for the write-outcome self-correction: re-takes <c>Lock</c> and, when
/// <see cref="ShouldRevert"/> 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
/// <see cref="WriteValue"/>). 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.
/// <list type="number">
/// <item>
/// <b>Item B — Bad-quality blip.</b> Before restoring the prior value, the node is published
/// once holding the (still-applied) optimistic value but with StatusCode
/// <c>BadDeviceFailure</c>, 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. <b>Caveat:</b> the two <c>ClearChangeMasks</c> 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
/// <see cref="AuditWriteUpdateEventState"/> raised below (Item C) is the reliable, durable record
/// of the rejected write.
/// </item>
/// <item>
/// <b>Item C — AuditWriteUpdateEvent.</b> A Part 8 <see cref="AuditWriteUpdateEventState"/> is
/// raised through the SDK <see cref="CustomNodeManager2.Server"/> 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
/// <paramref name="outcome"/>.Reason carried in the event Message). It is reported
/// OUTSIDE <c>Lock</c> (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 — <c>ReportEvent</c> simply reaches no monitored
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
/// </item>
/// </list>
/// 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.
/// <para><c>internal</c> (not private) so the behavioural test drives the blip-then-settle continuation
/// against a booted node manager — see <c>NodeManagerWriteRevertTests</c>.</para>
/// </summary>
/// <param name="nodeId">The string id of the written variable node.</param>
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
/// <param name="priorValue">The node's real value captured before the optimistic write.</param>
/// <param name="priorStatus">The node's real status captured before the optimistic write.</param>
/// <param name="clientUserId">The writing principal's user-id string (the identity's DisplayName), threaded
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
internal void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId, AddressSpaceRealm realm)
{
// 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);
}
/// <summary>
/// Item C — build (but do not report) a Part 8 <see cref="AuditWriteUpdateEventState"/> recording a
/// rejected device write on <paramref name="variable"/>. The caller holds <c>Lock</c> (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 <see cref="AuditEventState.Initialize(ISystemContext, NodeState, EventSeverity,
/// LocalizedText, bool, DateTime)"/> helper; this method then fills the write-specific fields
/// (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId.
/// </summary>
/// <param name="variable">The written node (the audit event's SourceNode).</param>
/// <param name="outcome">The failed device-write outcome (its Reason goes into the event Message + Status).</param>
/// <param name="optimisticValue">The value the client attempted (the audit NewValue).</param>
/// <param name="priorValue">The node's real pre-write value (the audit OldValue).</param>
/// <param name="clientUserId">The writing principal's user-id string; null when unknown.</param>
/// <returns>A populated, unreported <see cref="AuditWriteUpdateEventState"/>.</returns>
/// <remarks><c>internal</c> (not private) so <c>NodeManagerWriteRevertTests</c> can assert the populated
/// audit fields at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would
/// need a subscribed event monitored-item to observe).</remarks>
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<bool> (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;
}
/// <summary>
/// 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
/// <c>ILogger</c>) rather than propagated. Reported with the node manager's <c>SystemContext</c>, which
/// is what the alarm event path uses too.
/// </summary>
/// <param name="auditEvent">The populated audit event-state to report.</param>
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);
}
}
/// <summary>Map our domain <c>AlarmType</c> 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 cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
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),
};
/// <summary>
/// Gitea #629 — the <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
/// the shelve methods by. <see cref="New"/> records them as the methods' <c>MethodDeclarationId</c>
/// (the SDK's own is the <c>ShelvedStateMachineType</c>'s), so calls by these ids keep resolving after
/// the methods move to instance-scoped NodeIds. Calls by the <c>ShelvedStateMachineType_*</c> ids are
/// not supported (pinned by <c>ConditionChildNodeIdTests</c>).
/// </summary>
private static readonly HashSet<NodeId> ShelvingMethodDeclarationIds =
[
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
];
/// <summary>
/// Gitea #629 — the <see cref="INodeIdFactory"/> hook <c>NodeState.Create(…, assignNodeIds: true)</c> calls
/// for the node being created and then, top-down, for every child it built from the type's definition.
/// A node that already carries an id in one of our namespaces (the condition root, whose id
/// <see cref="MaterialiseAlarmCondition"/> passes to <c>Create</c>) keeps it; a child is given
/// <c>{parent id}/{BrowseName}</c> in its parent's namespace.
/// </summary>
/// <remarks>
/// <para>
/// The SDK default (<c>CustomNodeManager2.New</c>) returns the node's CURRENT id, which for a child is
/// the condition type's instance-declaration id (<c>i=9189</c> for <c>ShelvingState/UnshelveTime</c>) or
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
/// TranslateBrowsePathsToNodeIds of any child path returned <c>BadNoMatch</c>, and a Read of a browsed
/// child id either hit <c>BadNodeIdUnknown</c> or was served by the core node manager from the TYPE
/// (UnshelveTime read <c>0</c> on a condition timed-shelved for a minute). Event delivery and the Part 9
/// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its
/// declaration id.
/// </para>
/// <para>
/// The derived id is deterministic and unique per condition: the parent part is the condition's own
/// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames
/// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing
/// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also
/// slash-joined on purpose: <c>DataPlaneAuthorizer.BuildScope</c> splits the identifier on <c>/</c>,
/// so a child's scope is its condition's scope plus the browse path, and the permission trie's
/// downward inheritance governs a child exactly as it governs its condition.
/// </para>
/// </remarks>
/// <param name="context">The system context of the create.</param>
/// <param name="node">The node being assigned an id.</param>
/// <returns>The id the node is to carry.</returns>
public override NodeId New(ISystemContext context, NodeState node)
{
if (OwnsNamespace(node.NodeId)) return node.NodeId;
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
&& OwnsNamespace(parentId)
&& !QualifiedName.IsNull(node.BrowseName))
{
// A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId =
// e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR
// MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id
// already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id
// is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the
// AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before
// it is replaced, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
// lookup in the standard address space would take other node managers' locks while we hold Lock.
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
method.MethodDeclarationId = method.NodeId;
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
}
return node.NodeId;
}
/// <summary>
/// Gitea #629 — drops a removed condition AND every descendant from <c>PredefinedNodes</c>.
/// <c>AddPredefinedNode</c> indexes the whole subtree, so removing only the root would leave the
/// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead
/// condition's last value. MUST be called under <c>Lock</c>.
/// </summary>
/// <param name="node">The node whose subtree is forgotten.</param>
private void ForgetPredefinedSubtree(NodeState node)
{
PredefinedNodes?.Remove(node.NodeId);
var children = new List<BaseInstanceState>();
node.GetChildren(SystemContext, children);
foreach (var child in children) ForgetPredefinedSubtree(child);
}
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) 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).</summary>
/// <remarks>
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> 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 <c>StartAsync</c> — 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 <see cref="RebuildAddressSpace"/> re-promotes it (acceptable, documented). The
/// HistoryRead bit + source registration happen inside the same first-time
/// <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
/// </remarks>
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);
}
/// <summary>
/// The Part 9 <c>Severity</c> (1..1000) a condition carries for a domain severity, which is already on
/// that scale: the value itself, clamped. <c>SetSeverity</c> takes an <see cref="EventSeverity"/>, but
/// that enum merely names five points on the 1..1000 scale — any value in range is a valid Severity.
/// </summary>
/// <remarks>
/// This used to re-bucket onto those five points (<c>&lt; 800</c> ⇒ 700, else 900), so the condition
/// node was the one seam that disagreed with every other: an MTConnect Fault (#626's exact 800) read and
/// evented as 900, a scripted alarm the engine graded 1000 published 900 on the node while its
/// <c>/alerts</c> row and historian event said 1000, and a bucket-only native driver's Low (200) became 300.
/// </remarks>
/// <param name="severity">The domain severity.</param>
/// <returns>The severity the node carries.</returns>
private static EventSeverity ToPart9Severity(int severity) => (EventSeverity)Math.Clamp(severity, 1, 1000);
/// <summary>
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
/// Used by <see cref="AddressSpaceApplier"/> 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.
/// </summary>
/// <param name="folderNodeId">The node identifier of the folder.</param>
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{
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;
}
}
/// <summary>
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
/// UNS Area / Line rename. <see cref="EnsureFolder"/> 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 <see cref="RebuildAddressSpace"/>
/// cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
/// friendly display name, not the logical id, so browse-path resolution + ACLs are unaffected).
/// <c>ClearChangeMasks</c> is called so subscribed clients see the new DisplayName immediately,
/// mirroring the <see cref="UpdateTagAttributes"/> surgical path. Returns false when the folder id
/// is unknown (caller falls back to a full rebuild).
/// </summary>
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
{
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;
}
}
/// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
/// timestamp=epoch — <see cref="WriteValue"/> 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 <c>_variables.ContainsKey</c> 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
/// <see cref="RebuildAddressSpace"/> (which the planner triggers on an equipment-tag delta).
/// </summary>
/// <param name="variableNodeId">The node identifier of the variable.</param>
/// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
/// to its <c>OnWriteValue</c> so a client write gates on the <c>WriteOperate</c> role + routes
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
/// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
/// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
/// <c>HistoryRead</c> access bit OR-ed into both <c>AccessLevel</c> and <c>UserAccessLevel</c>, and the
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
/// resolves against.</param>
/// <param name="isArray">Phase 4c: when true the node is materialised as a 1-D array
/// (<c>ValueRank=OneDimension</c> with <c>ArrayDimensions=[arrayLength]</c>); when false (default) it
/// stays scalar (<c>ValueRank=Scalar</c>). Array elements share the scalar's base
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, variableNodeId);
// If already present, leave it alone (idempotent re-applies).
if (_variables.ContainsKey(key)) return;
lock (Lock)
{
if (_variables.ContainsKey(key)) return;
var parent = ResolveParentFolder(parentFolderNodeId, realm);
// Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead
// access bit (on top of the writable composite) so clients can browse + HistoryRead it.
var historized = historianTagname is not null;
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
// CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized.
var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null);
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<uint>(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
AccessLevel = access,
UserAccessLevel = access,
Historizing = historized,
Value = null,
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
// A writable equipment tag owns an inbound-write handler. The SDK invokes
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
if (writable)
{
variable.OnWriteValue = OnEquipmentTagWrite;
}
parent.AddChild(variable);
AddPredefinedNode(SystemContext, variable);
_variables[key] = variable;
// Phase C: register the resolved historian tagname so the HistoryRead override can map this
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
if (historized) _historizedTagnames[key] = historianTagname!;
if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
}
}
/// <summary>Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable +
/// Historizing flags — the single source of truth shared by <see cref="EnsureVariable"/> (node
/// creation) and <see cref="UpdateTagAttributes"/> (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, and the HistoryWrite bit OR-ed in when historized AND
/// <paramref name="historyWritable"/> (a <see cref="HistorianDataUpdater"/> is wired). The HistoryWrite bit is
/// what admits a node to the SDK base's HistoryUpdate dispatch at all. OR-ing byte constants promotes to int,
/// so cast back.</summary>
/// <param name="writable">The node accepts client value writes.</param>
/// <param name="historized">The node is historized.</param>
/// <param name="historyWritable">A HistoryUpdate backend is wired.</param>
/// <returns>The access-level byte.</returns>
internal static byte ComposeAccessLevel(bool writable, bool historized, bool historyWritable)
{
byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
if (historized) access = (byte)(access | AccessLevels.HistoryRead);
if (historized && historyWritable) access = (byte)(access | AccessLevels.HistoryWrite);
return access;
}
/// <summary>F10b surgical counterpart of <see cref="EnsureVariable"/>: update an EXISTING tag variable's
/// Writable (AccessLevel + <see cref="OnEquipmentTagWrite"/> handler), Historizing (+ historian-tagname
/// binding), and presentation shape (<paramref name="dataType"/> → <c>DataType</c>;
/// <paramref name="isArray"/>/<paramref name="arrayLength"/> → <c>ValueRank</c> + <c>ArrayDimensions</c>)
/// 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).
/// <para>
/// When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// happen. (1) The node's value is reset to <c>BadWaitingForInitialData</c> (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 <see cref="EnsureVariable"/> creates, which closes the "brief value-type
/// mismatch" window. (2) A Part 3 <c>GeneralModelChangeEvent</c> (verb=<c>DataTypeChanged</c>) 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).
/// </para>
/// <para>
/// The model-change event is built under <c>Lock</c> but reported AFTER the lock is released —
/// mirroring <see cref="RevertOptimisticWriteIfNeeded"/>: <c>Server.ReportEvent</c> re-enters the
/// server's own subscription/event path, so holding the node <c>Lock</c> across it risks a
/// lock-order inversion.
/// </para></summary>
/// <param name="variableNodeId">The folder-scoped node id of the variable to update in place.</param>
/// <param name="writable">When true the node becomes read/write with the inbound-write handler; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.</param>
/// <param name="dataType">The OPC UA built-in data type name to apply (e.g. "Boolean", "Int32", "Float").</param>
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
{
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, _historianDataUpdater is not null);
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<uint>(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;
}
/// <summary>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 <c>ValueRank != newValueRank</c> 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).</summary>
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);
}
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that
/// <paramref name="variable"/>'s definition changed (DataType / ValueRank / ArrayDimensions). Verb =
/// <c>DataTypeChanged</c> — the model-change verb the SDK exposes for an attribute-shape change (there is
/// no separate ValueRank verb). <c>internal</c> (not private) so a node-manager test can assert the
/// populated Changes structure at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c>
/// dispatch would need a subscribed event monitored-item to observe).</summary>
/// <param name="variable">The variable whose shape changed.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
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;
}
/// <summary>Report the built <c>GeneralModelChangeEvent</c> 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 <see cref="ReportAuditEvent"/>).</summary>
/// <param name="variable">The variable whose shape changed.</param>
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);
}
}
/// <summary>
/// Emit a Part 3 <c>GeneralModelChangeEvent</c> (verb <c>NodeAdded</c>) announcing that one or more
/// nodes were added UNDER <paramref name="affectedNodeId"/> 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 (<see cref="ReportNodeShapeChangedEvent"/>):
/// 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.
/// <para>
/// The event is built under <c>Lock</c> but reported AFTER the lock is released, mirroring
/// <see cref="ReportNodeShapeChangedEvent"/> / <see cref="RevertOptimisticWriteIfNeeded"/>:
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding the node
/// <c>Lock</c> 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 (<see cref="ReportAuditEvent"/>). The nodes have already been materialised, so
/// a surprise from the event path MUST NOT propagate out of this announcement.
/// </para>
/// </summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
{
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);
}
}
/// <inheritdoc cref="IOpcUaAddressSpaceSink.AddReference" />
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);
}
}
/// <summary>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
/// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
/// <returns>The node state, or null when unknown.</returns>
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;
}
/// <summary>Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified
/// id) using the manager's own <c>SystemContext</c>, or an empty list when the node is unknown. Exposed
/// so <c>SdkAddressSpaceSinkTests</c> can assert the cross-tree <c>Organizes</c> edge
/// <see cref="AddReference"/> wires without needing the (protected) SDK system context.</summary>
/// <param name="nodeId">The node's <c>s=</c> id.</param>
/// <param name="realm">The realm the node lives in.</param>
/// <returns>The node's references, or an empty list when the node is not registered.</returns>
internal IReadOnlyList<IReference> GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
var node = ResolveNodeState(MapKey(realm, nodeId));
if (node is null) return Array.Empty<IReference>();
var list = new List<IReference>();
node.GetReferences(SystemContext, list);
return list;
}
}
/// <summary>Map a hierarchical reference-type name to its SDK <see cref="NodeId"/>. Unknown names fall
/// back to <see cref="ReferenceTypeIds.Organizes"/> (the UNS→Raw cross-tree link).</summary>
/// <param name="referenceType">The reference-type name (e.g. "Organizes").</param>
/// <returns>The reference-type NodeId.</returns>
private static NodeId ResolveReferenceType(string referenceType) => referenceType switch
{
"HasComponent" => ReferenceTypeIds.HasComponent,
"HasProperty" => ReferenceTypeIds.HasProperty,
_ => ReferenceTypeIds.Organizes,
};
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that nodes were
/// added under <paramref name="affectedNodeId"/>. MIRRORS <see cref="BuildNodeShapeChangedEvent"/> exactly —
/// the only differences are <c>Verb = NodeAdded</c> (vs <c>DataTypeChanged</c>) and <c>Affected</c> = the
/// passed parent node id (vs the variable's own NodeId). <c>AffectedType</c> carries the affected node's
/// TypeDefinition resolved from the live node maps (same semantics the shape-changed builder gets from the
/// variable), defaulting to <see cref="NodeId.Null"/> when the id is not (yet) materialised. <c>internal</c>
/// (not private) so a node-manager test can assert the populated Changes structure at the nearest
/// deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would need a subscribed event
/// monitored-item to observe).</summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
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;
}
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that the node
/// at <paramref name="affectedNodeId"/> was DELETED. Mirrors <see cref="BuildNodesAddedModelChange"/>
/// exactly — the only differences are <c>Verb = NodeDeleted</c> and that the affected node has already
/// been dropped from the maps, so <c>AffectedType</c> resolves to <see cref="NodeId.Null"/> (a valid Part 3
/// "type not applicable"; clients re-browse the parent regardless). <c>internal</c> so a node-manager test
/// can assert the populated Changes structure at the nearest deterministic seam.</summary>
/// <param name="affectedNodeId">The node id of the deleted node.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
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;
}
/// <summary>Report a pre-built <see cref="GeneralModelChangeEventState"/> OUTSIDE <c>Lock</c> —
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding <c>Lock</c>
/// across it risks a lock-order inversion (mirrors <see cref="RaiseNodesAddedModelChange"/> /
/// <c>ReportNodeShapeChangedEvent</c>). Tolerant: swallow-and-log when eventing is disabled / there are no
/// monitored items / the server is shutting down — the node mutation already stands.</summary>
/// <param name="e">The pre-built event to report.</param>
/// <param name="affectedNodeId">The affected node id (for the diagnostic only).</param>
private void ReportModelChangeOutsideLock(GeneralModelChangeEventState e, string affectedNodeId)
{
try
{
ReportToServerObject(e);
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {NodeId}", affectedNodeId);
}
}
/// <summary>Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
/// model-change event's <c>AffectedType</c>; <see cref="NodeId.Null"/> when the id is not registered.</summary>
/// <param name="nodeId">The folder-scoped node id whose TypeDefinition is wanted.</param>
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;
}
/// <summary>
/// Gitea #693: the AtTime value-type hint of a historized variable — its DataType's built-in type as a
/// <see cref="TypeCode"/> when the node is a scalar of a type the historian evaluates (Boolean, the integers, Float,
/// Double, String, DateTime); <c>null</c> for an array, an unknown node, or any other type. A UNS mirror carries its
/// backing raw tag's DataType, so either NodeId gives the same hint.
/// </summary>
/// <param name="nodeId">The HistoryRead request's node id.</param>
/// <returns>The hint, or null.</returns>
internal TypeCode? AtTimeValueType(NodeId nodeId)
{
if (!_variables.TryGetValue(MapKey(nodeId), out var variable) || variable.ValueRank != ValueRanks.Scalar) return null;
return TypeInfo.GetBuiltInType(variable.DataType) switch
{
BuiltInType.Boolean => TypeCode.Boolean,
BuiltInType.SByte => TypeCode.SByte,
BuiltInType.Byte => TypeCode.Byte,
BuiltInType.Int16 => TypeCode.Int16,
BuiltInType.UInt16 => TypeCode.UInt16,
BuiltInType.Int32 => TypeCode.Int32,
BuiltInType.UInt32 => TypeCode.UInt32,
BuiltInType.Int64 => TypeCode.Int64,
BuiltInType.UInt64 => TypeCode.UInt64,
BuiltInType.Float => TypeCode.Single,
BuiltInType.Double => TypeCode.Double,
BuiltInType.String => TypeCode.String,
BuiltInType.DateTime => TypeCode.DateTime,
_ => null,
};
}
/// <summary>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).</summary>
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,
};
/// <summary>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.</summary>
public void RebuildAddressSpace()
{
lock (Lock)
{
foreach (var v in _variables.Values)
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
// conditions are not captured — their engine re-asserts them after the reload (#487).
_rebuildCarryOver.Clear();
_restoredConditionReports.Clear();
foreach (var (key, alarm) in _alarmConditions)
{
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
}
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
ForgetPredefinedSubtree(alarm);
}
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
UnwireAlarmNotifiers(alarmKey);
_alarmConditions.Clear();
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
_nativeAlarmNodeIds.Clear();
foreach (var f in _folders.Values)
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
_folders.Clear();
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
// teardown above doesn't matter — but it must run under this same Lock.
foreach (var folder in _notifierFolders.Values)
{
RemoveRootNotifier(folder);
}
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
// equipment folders to event notifiers.
_notifierFolders.Clear();
// Phase C: drop the event-history source registrations alongside the notifier folders
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
_eventNotifierSources.Clear();
ReconcileAllEventsMonitoring();
}
}
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
/// <remarks>
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
/// </remarks>
public void CompleteAddressSpaceApply()
{
int reported = 0, discarded;
lock (Lock)
{
foreach (var (key, alarm) in _restoredConditionReports)
{
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
// newer instant of a report that did (a client method call on an SDK thread can land between the
// restore and here) — never roll the condition's Time back.
ReportConditionEvent(alarm, alarm.Time.Value);
reported++;
}
discarded = _rebuildCarryOver.Count;
_restoredConditionReports.Clear();
_rebuildCarryOver.Clear();
}
if (reported > 0 || discarded > 0)
{
_logger.LogInformation(
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
reported, discarded);
}
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, variableNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
// 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds
// BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a
// condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference
// variable, inverse (OrganizedBy) on the backing RAW node. The raw node SURVIVES this scoped
// remove, so without this it keeps a dangling inverse reference to a node that no longer exists —
// browsable, unresolvable, and permanent.
//
// Why it matters more now than it used to: a full RebuildAddressSpace clears every node and every
// reference with it, so these edges were swept up on the next structural deploy. Surgical applies
// made rebuilds rare, so the dangling edges now ACCUMULATE on the raw node for the life of the
// process — one per re-point or effective-name edit of any reference that ever pointed at it.
SeverOutgoingReferences(variable);
variable.Parent?.RemoveChild(variable);
PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(key, out _);
_arrayOnScalarRefused.TryRemove(key, out _);
e = BuildNodesRemovedModelChange(variableNodeId, realm);
}
ReportModelChangeOutsideLock(e, variableNodeId);
return true;
}
/// <summary>
/// 2026-08-15 review I3 — remove the INVERSE half, held on the far node, of every FORWARD reference
/// <paramref name="node"/> carries to another node this manager owns. The exact reverse of the
/// bidirectional wiring in <see cref="AddReference"/>, and the variable-node counterpart of
/// <see cref="UnwireAlarmNotifiers"/>. MUST be called under <c>Lock</c>.
/// <para>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 <c>AddChild</c>), and the parent's side of that
/// is severed by <c>Parent.RemoveChild</c> — 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.</para>
/// </summary>
/// <param name="node">The node being removed, whose outgoing edges' inverse halves must be severed.</param>
private void SeverOutgoingReferences(NodeState node)
{
var references = new List<IReference>();
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);
}
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, alarmNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ map drift ⇒ false (caller rebuilds). Mirrors the condition teardown inside
// RebuildAddressSpace, scoped to this one condition. The equipment folder's event-notifier
// promotion is intentionally NOT demoted here — other conditions may still hang under it, and a
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
// this they would keep a dangling inverse-notifier entry to the removed condition.
UnwireAlarmNotifiers(key);
condition.Parent?.RemoveChild(condition);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
ReconcileAllEventsMonitoring();
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
}
ReportModelChangeOutsideLock(e, alarmNodeId);
return true;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
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 "<equipmentNodeId>/" (sub-folders, variables, condition nodes); the explicit realm
// compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s="
// — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot
// each map's in-scope keys first (ToList) so we don't mutate while enumerating.
var prefix = equipmentNodeId + "/";
bool InScope(RealmKey key) => key.Realm == realm
&& (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal));
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
{
if (_variables.TryRemove(id, out var v))
{
// Gitea #599 (archreview 03/S20): sever the inverse half of each cross-tree Organizes edge
// first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a
// RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge.
SeverOutgoingReferences(v);
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
_arrayOnScalarRefused.TryRemove(id, out _);
}
// Part 9 condition nodes (+ their native flags).
foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
{
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
ForgetPredefinedSubtree(c);
}
_nativeAlarmNodeIds.Remove(id);
}
ReconcileAllEventsMonitoring();
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
foreach (var wiring in _alarmNotifierWiring.Values)
{
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
wiring.Folders.Remove(folder);
}
}
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
// _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key.
foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList())
{
RemoveRootNotifier(kvp.Value);
_notifierFolders.Remove(kvp.Key);
}
// Drop the event-history source registrations for the subtree (keyed by the folder id string).
foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
_eventNotifierSources.TryRemove(src, out _);
// Sub-folders + the equipment folder itself (children already detached above).
foreach (var id in _folders.Keys.Where(InScope).ToList())
{
if (_folders.TryRemove(id, out var f))
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
}
e = BuildNodesRemovedModelChange(equipmentNodeId, realm);
}
ReportModelChangeOutsideLock(e, equipmentNodeId);
return true;
}
private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm)
{
EnsureAddressSpaceCreated();
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
// A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment
// folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root.
return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!;
}
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>, <see cref="WriteValues"/>,
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
/// <see cref="MaterialiseAlarmCondition"/>) and <see cref="ResolveParentFolder"/> assume it has run.
/// If one is invoked before the server has started (a sink wired or a publish replayed before
/// <c>StartAsync</c> completes), <c>_root</c> is null and the dereference would NRE; throw a legible
/// <see cref="InvalidOperationException"/> instead. Happy-path behaviour is unchanged.</summary>
/// <exception cref="InvalidOperationException">When the address space has not been created yet.</exception>
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.
// ---------------------------------------------------------------------------------------------
/// <summary>True when <paramref name="nodeId"/> is minted in one of this manager's two namespaces.</summary>
/// <param name="nodeId">The node id to test.</param>
/// <returns><see langword="true"/> when this manager owns the node's namespace.</returns>
private bool OwnsNamespace(NodeId? nodeId) =>
nodeId is not null
&& (nodeId.NamespaceIndex == NamespaceIndexes[0] || nodeId.NamespaceIndex == NamespaceIndexes[1]);
/// <summary>Pulls the ACL identity off an SDK operation context.</summary>
/// <param name="context">The SDK context carrying the session identity.</param>
/// <returns>The role-carrying identity, or null when the session has none.</returns>
private static RoleCarryingUserIdentity? IdentityOf(OperationContext context) =>
context.UserIdentity as RoleCarryingUserIdentity;
/// <summary>Pulls the ACL identity off a system context (the protected SDK seams).</summary>
/// <param name="context">The SDK system context.</param>
/// <returns>The role-carrying identity, or null when the session has none.</returns>
private static RoleCarryingUserIdentity? IdentityOf(ServerSystemContext context) =>
(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
/// <summary>Whether the session may perform <paramref name="operation"/> on <paramref name="nodeId"/>.</summary>
/// <param name="identity">The calling session's identity.</param>
/// <param name="operation">The operation being attempted.</param>
/// <param name="nodeId">The target node.</param>
/// <returns><see langword="true"/> when permitted, or when the node is not ours to gate.</returns>
private bool Permits(RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId) =>
!OwnsNamespace(nodeId)
|| _authorizer!.IsAllowedCached(identity, operation, nodeId, RealmOf(nodeId));
/// <inheritdoc />
/// <remarks>
/// Denied slots are answered and marked <see cref="ReadValueId.Processed"/> <b>before</b> 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 <c>Read</c> permission rather than only <c>Value</c>: 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.
/// </remarks>
public override void Read(
OperationContext context,
double maxAge,
IList<ReadValueId> nodesToRead,
IList<DataValue> values,
IList<ServiceResult> 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);
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Reachability comes from <c>PermissionTrie.HasBrowseTransit</c> — a node is browsable when it
/// merely leads somewhere the principal may browse. Note what this necessarily discloses: the
/// <em>names</em> of the folders on the path to a granted node are visible to anyone holding
/// that grant, because navigation requires them.
/// </para>
/// <para>
/// ⚠️ There is ONE shared root folder (<c>V3NodeIds.SharedRootFolderId</c>, minted in the raw
/// namespace by <see cref="CreateAddressSpace"/>) fronting BOTH realm trees — not one root per
/// namespace. It is referenced from the ns=0 <c>Objects</c> 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
/// <c>Security.DataPlaneAuthorizer.BuildScope</c> maps the anchor to.
/// </para>
/// </remarks>
public override void Browse(
OperationContext context,
ref ContinuationPoint continuationPoint,
IList<ReferenceDescription> 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);
}
/// <inheritdoc />
/// <remarks>
/// Gated for the same reason Browse is, and it is not optional: without it a client denied a browse
/// simply asks <c>TranslateBrowsePathsToNodeIds</c> for the same path and gets the NodeId back, which
/// would make the reference filtering above decorative.
/// </remarks>
public override void TranslateBrowsePath(
OperationContext context,
object sourceHandle,
RelativePathElement relativePath,
IList<ExpandedNodeId> targetIds,
IList<NodeId> 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);
}
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
public override void CreateMonitoredItems(
OperationContext context,
uint subscriptionId,
double publishingInterval,
TimestampsToReturn timestampsToReturn,
IList<MonitoredItemCreateRequest> itemsToCreate,
IList<ServiceResult> errors,
IList<MonitoringFilterResult> filterErrors,
IList<IMonitoredItem> 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);
}
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
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;
}
/// <summary>
/// Starts (or refreshes) tracking a direct event subscription for revalidation (Gitea #562).
/// </summary>
/// <param name="sourceId">The manager handle the item subscribed through.</param>
/// <param name="monitoredItem">The event item.</param>
/// <remarks>
/// <para>
/// 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 <c>ModifyMonitoredItems</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
}
}
/// <inheritdoc />
/// <remarks>
/// Re-checked on every mode change so a client cannot re-enable an item that
/// <see cref="RevalidateMonitoredItems"/> 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.
/// </remarks>
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);
}
/// <inheritdoc />
protected override void OnMonitoredItemCreated(
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
base.OnMonitoredItemCreated(context, handle, monitoredItem);
TrackLiveMonitoredItem(handle, monitoredItem);
}
/// <summary>Starts tracking <paramref name="monitoredItem"/> for revalidation, if we own its node.</summary>
/// <param name="handle">The handle the item was created against.</param>
/// <param name="monitoredItem">The item.</param>
/// <remarks>
/// Split out of <see cref="OnMonitoredItemCreated"/> so tests can register an item without a client
/// session; the SDK reaches it only through that override.
/// </remarks>
internal void TrackLiveMonitoredItem(NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
if (OwnsNamespace(handle.NodeId)) _liveItems[monitoredItem] = new LiveItem(handle);
}
/// <inheritdoc />
protected override void OnMonitoredItemDeleted(
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
_liveItems.TryRemove(monitoredItem, out _);
base.OnMonitoredItemDeleted(context, handle, monitoredItem);
}
/// <summary>
/// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment
/// can reach the subscriptions it was authorizing.
/// </summary>
/// <remarks>
/// Keyed by the item itself; the value is the node it monitors. Maintained by
/// <see cref="OnMonitoredItemCreated"/> / <see cref="OnMonitoredItemDeleted"/>, which the SDK calls
/// for every create and delete including subscription teardown, so the map cannot outlive the items.
/// </remarks>
private readonly ConcurrentDictionary<ISampledDataChangeMonitoredItem, LiveItem> _liveItems = new();
/// <summary>A tracked monitored item: the handle it was created against, and whether we have silenced it.</summary>
/// <param name="Handle">The node handle, which carries the <see cref="MonitoredNode2"/> fan-out list.</param>
private sealed record LiveItem(NodeHandle Handle)
{
/// <summary>True while the item has been detached from its node's fan-out for want of a grant.</summary>
public bool Suppressed { get; set; }
}
/// <summary>
/// Live <b>event</b> monitored items subscribed directly to a notifier this manager owns (Gitea #562),
/// the event-side twin of <see cref="_liveItems"/>.
/// </summary>
/// <remarks>
/// <para>
/// A second map because the SDK keeps the two kinds apart: a data-change item reaches this
/// manager through <see cref="OnMonitoredItemCreated"/>, an event item only through
/// <see cref="SubscribeToEvents(OperationContext, object, uint, IEventMonitoredItem, bool)"/>, and
/// they hang off different lists of <see cref="MonitoredNode2"/>. Before #562 nothing tracked event
/// items at all, so an alarm feed authorized at creation survived every later revocation.
/// </para>
/// <para>
/// 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
/// <c>Lock</c>, so the membership check the revalidation pass makes before re-attaching an item
/// cannot race a client deleting it.
/// </para>
/// <para>
/// <b>Server-object (all-events) subscriptions are not in this map.</b> They are not attached to
/// any notifier of ours at all; they are held in <see cref="_allEventsItems"/> and decided per
/// event at delivery (Gitea #586), so there is nothing for this map's stand-down to detach.
/// </para>
/// </remarks>
private readonly ConcurrentDictionary<IEventMonitoredItem, LiveEventItem> _eventItems = new();
/// <summary>
/// Live Server-object (<c>i=2253</c>) event monitored items — every item the SDK handed this manager
/// through <see cref="SubscribeToAllEvents"/> (Gitea #586).
/// </summary>
/// <remarks>
/// A set (the value is unused). Maintained by <see cref="SubscribeToAllEvents"/>'s subscribe and
/// unsubscribe arms, which the SDK's <c>MasterNodeManager</c> drives for every create, modify,
/// transfer, restore and delete of an all-events item, subscription teardown included.
/// </remarks>
private readonly ConcurrentDictionary<IEventMonitoredItem, byte> _allEventsItems = new();
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>Gitea #586.</b> 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 <c>AddRootNotifier</c> calls its subscribe with <c>unsubscribe: true</c>), through the
/// root notifier's forward to <c>Server.ReportEvent</c> 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.
/// </para>
/// <para>
/// This override attaches the item to nothing. It records it, and each condition hands its events
/// to the recorded items itself (<see cref="OnConditionReportEvent"/>), 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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;
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// 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 <c>AddRootNotifier</c>, which — an SDK bug — "subscribes"
/// every existing all-events item to a new root notifier with <c>unsubscribe: true</c>. Passed to the
/// base, that lowers <see cref="NodeState.AreEventsMonitored"/> on the new notifier, its children and
/// its forward notifiers — taking back, from every condition materialised under a live item, the
/// unit <see cref="ReconcileAllEventsMonitoring"/> just gave it (and, before #586, a folder
/// subscriber's unit too). Caught by the rebuild leg of the monitored-bookkeeping test.
/// </para>
/// <para>
/// Direct notifier items, including the #562 revalidation pass's attach and detach, go to the base
/// unchanged.
/// </para>
/// </remarks>
protected override ServiceResult SubscribeToEvents(
ServerSystemContext context, NodeState source, IEventMonitoredItem monitoredItem, bool unsubscribe) =>
monitoredItem.MonitoringAllEvents
? ServiceResult.Good
: base.SubscribeToEvents(context, source, monitoredItem, unsubscribe);
/// <summary>
/// The conditions currently carrying this manager's one "a Server-object item is listening" unit of
/// <see cref="NodeState.AreEventsMonitored"/> (Gitea #586 review).
/// </summary>
/// <remarks>Mutated only by <see cref="ReconcileAllEventsMonitoring"/>, under <c>Lock</c>.</remarks>
private readonly HashSet<AlarmConditionState> _allEventsMonitoredConditions =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
/// <summary>
/// Keeps <see cref="NodeState.AreEventsMonitored"/> raised on every materialised condition while at
/// least one Server-object item exists, and lowered once none does. Call under <c>Lock</c>.
/// </summary>
/// <remarks>
/// <para>
/// The SDK base attached an all-events item to every root notifier through its own
/// <c>SubscribeToEvents</c>, which also calls <c>SetAreEventsMonitored(true, includeChildren)</c>
/// 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: <c>ConditionState</c>
/// reports its method audit events only while <c>EventsMonitored()</c> 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 <i>state-change</i> events no longer depend on this: the SDK
/// reports none, and <see cref="WireMethodTransitionReporting"/> 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.)
/// </para>
/// <para>
/// Held per <b>condition</b>, 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.
/// </para>
/// </remarks>
private void ReconcileAllEventsMonitoring()
{
// The protected SubscribeToEvents override is what stops the SDK taking these units back.
lock (Lock)
{
var desired = _allEventsItems.IsEmpty
? new HashSet<AlarmConditionState>(System.Collections.Generic.ReferenceEqualityComparer.Instance)
: new HashSet<AlarmConditionState>(
_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);
}
}
}
/// <summary>
/// The incremental form of <see cref="ReconcileAllEventsMonitoring"/> for one newly materialised
/// condition: raises its unit when a Server-object item exists, touching no other condition. Call under
/// <c>Lock</c>.
/// </summary>
/// <param name="condition">The condition just added to <c>_alarmConditions</c>.</param>
/// <remarks>
/// 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).
/// </remarks>
private void RaiseAllEventsMonitoring(AlarmConditionState condition)
{
AllEventsMonitoringConditionVisits++;
if (!_allEventsItems.IsEmpty && _allEventsMonitoredConditions.Add(condition))
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
}
/// <summary>
/// How many conditions the Server-object monitored bookkeeping has visited since start — every
/// condition a full <see cref="ReconcileAllEventsMonitoring"/> walks, plus one per incremental
/// <see cref="RaiseAllEventsMonitoring"/>. Test observability: bounds the bookkeeping cost of a deploy.
/// </summary>
/// <remarks>Mutated only under <c>Lock</c>.</remarks>
internal long AllEventsMonitoringConditionVisits { get; private set; }
/// <inheritdoc />
/// <remarks>
/// <para>
/// The base is the delegate <c>AddRootNotifier</c> installs on a root notifier: it forwards every
/// event reaching that folder to <c>Server.ReportEvent</c>, 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
/// (<see cref="OnConditionReportEvent"/>), so the forward could only ever deliver an ungated
/// duplicate. Dropped.
/// </para>
/// <para>
/// Only condition events bubble through this manager's notifiers; the audit and model-change
/// events it raises never reach this, and go through <see cref="ReportToServerObject"/> instead
/// (Gitea #588).
/// </para>
/// </remarks>
protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
{
}
/// <summary>
/// Delivers one condition event to the Server-object (all-events) subscriptions this manager holds,
/// item by item, through the data-plane ACL (Gitea #586).
/// </summary>
/// <param name="context">The reporting context; a session-bearing one restricts delivery to that session.</param>
/// <param name="node">The condition that reported.</param>
/// <param name="e">The event — the condition's snapshot.</param>
/// <remarks>
/// <para>
/// Installed as each condition's <c>OnReportEvent</c> in <see cref="MaterialiseAlarmCondition"/>,
/// 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.
/// </para>
/// <para>
/// Everything except the ACL decision mirrors the SDK's <c>MonitoredNode2.OnReportEvent</c>, 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.
/// </para>
/// <para>
/// Inert while <c>Security:Acl:Enabled</c> is false — every item receives every condition, as it
/// always has.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// The SDK's audit-event guard from <c>MonitoredNode2.OnReportEvent</c>, reproduced exactly: an audit
/// event reaches an item only while the server is auditing, and only over a SignAndEncrypt or
/// https-binary endpoint.
/// </summary>
/// <param name="item">The Server-object event item.</param>
/// <returns><see langword="true"/> when the audit event may be queued to the item.</returns>
/// <remarks>
/// A condition reports its Part 9 method audit events (<c>AuditConditionAcknowledgeEventType</c>,
/// Comment, Enable, Shelving…) through its own <c>ReportEvent</c>, so they pass through
/// <see cref="OnConditionReportEvent"/>. Before #586 the Server-object copy took the root-notifier
/// forward, where <c>ServerInternalData.ReportEvent</c> dropped every audit event unless auditing was
/// on; without this guard they reached every Server-object item regardless (caught in review, flag off).
/// </remarks>
private bool IsAuditDeliverableTo(IEventMonitoredItem item)
{
if (!Server.Auditing) return false;
var endpoint = item.Session?.EndpointDescription;
return endpoint?.SecurityMode == MessageSecurityMode.SignAndEncrypt
|| endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport;
}
/// <summary>
/// Reports a server-wide event this manager raises — a <c>GeneralModelChangeEvent</c> or an audit event
/// — to the Server object's (<c>i=2253</c>) subscribers, through the data-plane ACL when it is enforced
/// (Gitea #588).
/// </summary>
/// <param name="e">The built event.</param>
/// <remarks>
/// <para>
/// These events are not condition events, so they never pass a notifier of ours: they went straight
/// to <c>Server.ReportEvent</c>, 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.
/// </para>
/// <para>
/// With enforcement on, this manager delivers them itself, item by item, to the all-events items it
/// already holds for #586 — the same audience <c>Server.ReportEvent</c> reached, since only Server-object
/// items receive what is reported at the Server object. Each item receives only what its session may
/// see (<see cref="ServerObjectEventFor"/>); everything else the SDK's Server object did — its audit
/// guard, its role-permission check — is kept. With enforcement off the event goes to
/// <c>Server.ReportEvent</c> exactly as before.
/// </para>
/// <para>
/// Called outside <c>Lock</c>, like the <c>Server.ReportEvent</c> call it replaces; the decision
/// takes no node-manager lock.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// What of a server-wide event <paramref name="item"/>'s session may receive with enforcement on: the
/// event itself, a copy narrowed to what it may see, or nothing (Gitea #588).
/// </summary>
/// <param name="item">A Server-object event item.</param>
/// <param name="e">The event.</param>
/// <returns>The event to queue, or <see langword="null"/> to withhold it.</returns>
/// <remarks>
/// <list type="bullet">
/// <item>
/// A <b>model-change</b> event keeps only the changes whose <c>Affected</c> node the session may
/// <c>Browse</c> (<see cref="FilterModelChangeFor"/>) — knowing a node exists is exactly what
/// Browse grants, ancestors of a granted node included.
/// </item>
/// <item>
/// An <b>audit</b> event needs the SDK's own delivery guard (<see cref="IsAuditDeliverableTo"/>)
/// <b>and</b> <c>Read</c> on its source node: an <c>AuditWriteUpdateEvent</c> carries the node's
/// prior value and the attempted one, which is what <c>Read</c> protects.
/// </item>
/// <item>Anything else is withheld: this manager reports no other server-wide event type, so an
/// unrecognised one fails closed rather than slipping through ungated.</item>
/// </list>
/// </remarks>
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,
};
}
/// <summary>
/// A model-change event narrowed to the changes whose affected node <paramref name="identity"/> may
/// browse: the event itself when every change is visible, <see langword="null"/> when none is, otherwise
/// a copy carrying only the visible ones (Gitea #588).
/// </summary>
/// <param name="identity">The session's identity.</param>
/// <param name="e">The model-change event.</param>
/// <returns>The event to deliver, or <see langword="null"/> to withhold it.</returns>
/// <remarks>
/// 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 <c>EventId</c>, time and source, with a neutral
/// <c>Message</c> (the builders' messages name the affected node). A change with no
/// <c>Affected</c> node names nothing and is kept.
/// </remarks>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="condition">The condition.</param>
/// <returns>The candidate scopes; <c>Subscribe</c> on any one of them admits the condition.</returns>
/// <remarks>
/// <para>
/// 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 <c>Equipment</c> grant covers the UNS folder a native condition is wired
/// into, but not the condition, which lives at its RawPath in the raw realm.
/// </para>
/// <para>
/// The condition itself (<c>ConditionId</c> = 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.
/// </para>
/// </remarks>
private List<NodeId> ConditionVisibilityScopes(NodeState condition)
{
var scopes = new List<NodeId> { 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;
}
/// <summary>Whether <paramref name="item"/>'s session may see a condition with the given scopes.</summary>
/// <param name="item">The Server-object event item.</param>
/// <param name="scopes">From <see cref="ConditionVisibilityScopes"/>.</param>
/// <returns><see langword="true"/> when the session holds <c>Subscribe</c> on any scope.</returns>
/// <remarks>
/// 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.
/// <see cref="Security.DataPlaneAuthorizer.IsAllowedCached"/> 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.
/// </remarks>
private bool IsConditionVisibleTo(IEventMonitoredItem item, List<NodeId> 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;
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// 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
/// <see cref="IsConditionVisibleTo"/> 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).
/// </para>
/// <para>
/// 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".
/// </para>
/// </remarks>
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
{
if (!AclEnforced) return base.ConditionRefresh(context, monitoredItems);
var direct = new List<IEventMonitoredItem>();
var serverItems = new List<IEventMonitoredItem>();
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<NodeState> notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
var events = new List<IFilterTarget>();
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<NodeId> Scopes)>();
var seen = new HashSet<NodeState>(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;
}
/// <summary>The condition a refresh event describes, or null when it names none (withheld: fail closed).</summary>
/// <param name="e">A refresh event.</param>
/// <returns>The condition node.</returns>
private static NodeState? ConditionOf(IFilterTarget e) => e switch
{
InstanceStateSnapshot { Handle: NodeState condition } => condition,
NodeState condition => condition,
_ => null,
};
/// <summary>
/// Whether the condition at <paramref name="conditionNodeId"/> believes it is being listened to
/// (<c>ConditionState.EventsMonitored()</c>) — the gate on the SDK reporting its method-driven state
/// changes. Test observability for the Gitea #586 review.
/// </summary>
/// <param name="conditionNodeId">The condition's NodeId identifier.</param>
/// <param name="realm">The condition's realm.</param>
/// <returns><see langword="true"/> when the condition exists and reports itself monitored.</returns>
internal bool ConditionEventsMonitored(string conditionNodeId, AddressSpaceRealm realm)
{
lock (Lock)
{
return _alarmConditions.TryGetValue(MapKey(realm, conditionNodeId), out var condition)
&& condition.EventsMonitored();
}
}
/// <summary>
/// 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.
/// </summary>
internal int AttachedEventItemCount
{
get
{
lock (Lock) return MonitoredNodes.Values.Sum(n => n.EventMonitoredItems?.Count ?? 0);
}
}
/// <summary>A tracked event item: the notifier it subscribed to, and whether we have stood it down.</summary>
/// <param name="Handle">The notifier's handle; its <see cref="NodeHandle.Node"/> is what the item is attached to.</param>
private sealed record LiveEventItem(NodeHandle Handle)
{
/// <summary>True while the item has been detached from its notifier's event fan-out for want of a grant.</summary>
public bool Suppressed { get; set; }
}
/// <summary>
/// Re-authorizes every live monitored item against the current generation, standing down the ones
/// whose grant has gone.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this is not optional.</b> 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.
/// </para>
/// <para>
/// A denied item is <b>told, then detached</b>: queued a <c>BadUserAccessDenied</c> so the client
/// learns why its data stopped rather than watching a feed go quiet, then removed from its node's
/// <see cref="MonitoredNode2"/> fan-out so no further value reaches it.
/// </para>
/// <para>
/// ⚠️ <b>Detaching, not <see cref="MonitoringMode.Disabled"/> — and the order matters.</b> 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 <em>discards its queued notifications</em>,
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Items are <b>not deleted</b>. 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.
/// </para>
/// <para>
/// <b>One verdict per (session, node), not per item.</b> 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 <see cref="Security.DataPlaneAuthorizer.IsAllowedCached"/> 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.
/// </para>
/// <para>
/// The per-item <c>lock (Lock)</c> takes are deliberately left alone. Batching them under one
/// hold was considered and rejected: it would stretch the SDK lock across the
/// <c>QueueValue</c> calls between them, and the attach/detach is what actually needs the lock.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// The event-item half of <see cref="RevalidateMonitoredItems"/> (Gitea #562): re-authorizes every
/// tracked direct event subscription against its notifier, standing down the denied ones and
/// re-attaching the restored ones.
/// </summary>
/// <param name="verdicts">The pass-local memo shared with the data-change half.</param>
/// <returns>How many items were stood down and how many restored.</returns>
/// <remarks>
/// <para>
/// The verdict is the same question the creation gate in
/// <see cref="SubscribeToEvents(OperationContext, object, uint, IEventMonitoredItem, bool)"/>
/// asks — <c>Subscribe</c> 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 <c>AclRevalidationTimer</c>).
/// </para>
/// <para>
/// ⚠️ <b>Detached through the SDK's own unsubscribe, never <see cref="MonitoringMode.Disabled"/>.</b>
/// 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 <c>SubscribeToEvents(…, unsubscribe:
/// true)</c> — precisely what the client's own delete does to the notifier's
/// <see cref="MonitoredNode2"/> fan-out — and the restore is the same call with
/// <c>unsubscribe: false</c>, 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.
/// </para>
/// <para>
/// <b>The client is not told.</b> Unlike a data-change item, an event item has no value slot to
/// carry a <c>BadUserAccessDenied</c>: 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.
/// </para>
/// <para>
/// Membership is re-checked under <c>Lock</c> 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.
/// </para>
/// </remarks>
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);
}
/// <summary>
/// The per-item verdict for <see cref="RevalidateMonitoredItems"/>, memoized across the pass.
/// </summary>
/// <param name="identity">The identity of the session that created the item.</param>
/// <param name="nodeId">The node the item monitors.</param>
/// <param name="verdicts">The pass-local memo, keyed by session, realm and node identifier.</param>
/// <returns><see langword="true"/> when the item may keep receiving values.</returns>
/// <remarks>
/// <para>
/// The session key is <c>ApiKeyId ?? Principal</c> — the same derivation
/// <see cref="Security.DataPlaneAuthorizer"/> 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.
/// </para>
/// <para>
/// 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
/// <see cref="Security.DataPlaneAuthorizer"/> excludes it from its own decision cache: it
/// addresses no ACL scope, so it is not an identity to key on.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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;
}
/// <summary>Emits the one operator-facing summary line per gated request.</summary>
/// <param name="operation">The service being filtered.</param>
/// <param name="identity">The calling session's identity.</param>
/// <param name="denied">How many nodes or references were withheld.</param>
/// <param name="total">How many were considered.</param>
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.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Drops the handles in a HistoryRead batch the calling session is not granted
/// <see cref="OpcUaOperation.HistoryRead"/> on, marking each denied slot
/// <c>BadUserAccessDenied</c> (#520 stage 5).
/// </summary>
/// <remarks>
/// <para>
/// <b>Until this stage HistoryRead carried no authorization check whatsoever</b> — 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.
/// </para>
/// <para>
/// Denial is <b>per node, not per batch</b>: 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.
/// </para>
/// <para>
/// Returns the original list unchanged when enforcement is off, so the non-ACL path allocates
/// nothing and behaves exactly as before.
/// </para>
/// </remarks>
/// <param name="context">The SDK context carrying the session identity.</param>
/// <param name="nodesToProcess">The pre-filtered handles the base dispatcher built.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <returns>The handles that may proceed.</returns>
private List<NodeHandle> AuthorizeHistoryRead(
ServerSystemContext context,
List<NodeHandle> nodesToProcess,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors)
{
if (!AclEnforced) return nodesToProcess;
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
var allowed = new List<NodeHandle>(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;
}
/// <inheritdoc />
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
if (details.IsReadModified && details.ReturnBounds && HonorReturnBounds)
{
// Part 11 §6.5.3.3: returnBounds "shall be set to FALSE" for a modified read, "otherwise the Server returns
// a Bad_InvalidArgument" (#691). Off with the ServerHistorian:HonorReturnBounds kill switch.
errors[handle.Index] = StatusCodes.BadInvalidArgument;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadInvalidArgument };
continue;
}
if (details.IsReadModified)
{
// Part 11 modified-value history (#668): the prior values every HistoryUpdate recorded. Gated by the
// same HistoryRead authorization as a raw read (above); a backend with no trail ⇒ unsupported.
work.Add(() => ServeModifiedPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
ct));
continue;
}
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
ct, details.ReturnBounds && HonorReturnBounds));
}
RunBounded(work, HistoryReadBatchParallelism);
});
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
// #677: a client AggregateConfiguration other than the server defaults is refused per node, never silently ignored.
var configurationServable = ServerAggregateConfiguration.IsServable(details.AggregateConfiguration);
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
var served = AdvertisedAggregateFunctions.ServedBy(HistorianDataSource);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(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];
// #682: one authority — the source's SupportedAggregates (also what HistoryServerCapabilities/
// AggregateFunctions advertises; null ⇒ all, the same rule). An aggregate the server cannot name, or the
// backend does not serve, is refused up front without a backend call.
var aggregate = AggregateFunctionMap.Map(aggregateNodeId);
if (aggregate is null || !served.Contains(aggregate.Value))
{
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
continue;
}
if (!configurationServable)
{
errors[handle.Index] = StatusCodes.BadAggregateConfigurationRejected;
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);
});
}
/// <inheritdoc />
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// Snapshot the requested timestamps once — the same list is read for every node.
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// #693: the node's scalar value type, for a backend whose stored values carry none (the Gateway).
var valueType = AtTimeValueType(handle.NodeId);
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
tagname,
timestamps,
valueType,
token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// Snapshot the select clauses once — the same filter projects every node's events.
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
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<Func<Task>>(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, nodesToRead, session, results, errors, ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
/// <summary>
/// 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 <see cref="ServeNodeAsync"/> — a backend throw becomes a Bad
/// status for THIS node only and never throws out of the batch; a deadline cancellation surfaces
/// <c>BadTimeout</c>.
/// </summary>
/// <param name="handle">The notifier node handle; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="sourceName">The registered historian event-source name for this notifier.</param>
/// <param name="details">The event-read details (window + count cap).</param>
/// <param name="selectClauses">The event-filter select clauses (the fields to emit, in order).</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="ct">Per-request deadline token.</param>
private async Task ServeEventsAsync(
NodeHandle handle,
string? sourceName,
ReadEventDetails details,
SimpleAttributeOperandCollection selectClauses,
IList<HistoryReadValueId> nodesToRead,
ISession? session,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
CancellationToken ct)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
var resumed = inboundCp is { Length: > 0 };
// One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree.
var source = _historianDataSource;
try
{
var startUtc = details.StartTime;
var endUtc = details.EndTime;
var numValuesPerNode = details.NumValuesPerNode;
var boundarySkip = 0;
var walkHint = default(ReverseWalkHint); // #691.4: a walked-back read's width + proven-empty stretch
bool reverse;
if (resumed)
{
// Single-use take. A miss (unknown / evicted / malformed / a RAW cursor) ⇒ BadContinuationPointInvalid
// for THIS node only.
var state = _historyContinuationStore.TryTake(session, inboundCp!);
// A point is bound to the node that issued it: another node's source name is a miss too.
if (state is null || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty))
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
return;
}
sourceName = state.Tagname;
startUtc = state.NextStartUtc;
endUtc = state.EndUtc;
boundarySkip = state.BoundarySkipCount;
numValuesPerNode = state.NumValuesPerNode;
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
// reverse read resumes reverse, a forward one forward.
reverse = state.Reverse;
walkHint = state.WalkHint;
}
else
{
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — on every backend since #691.4 (a backend
// that cannot read newest-first is walked back below). Without a count cap an end-only read keeps its
// forward [MinValue, EndTime) meaning.
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
reverse = startUtc > endUtc;
// #694: the opt-in events horizon raises the window's earlier bound (the cursor then carries it). A window
// wholly older than it is empty and is not sent (a forward [start, end) clamped past its end would be refused).
if (!EventHorizon.TryClamp(ref startUtc, ref endUtc, reverse, EventReadHorizon, Clock.GetUtcNow().UtcDateTime))
{
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.GoodNoData,
HistoryData = new ExtensionObject(ProjectEvents([], selectClauses)),
};
errors[handle.Index] = ServiceResult.Good;
return;
}
}
// A reverse read on a backend that cannot read newest-first (the Gateway) is never sent there (#675 refused it
// here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by
// EventId because the gateway orders by event time only. Decided by the capability, not by catching an
// exception, so a NotSupportedException thrown from inside a backend stays a backend fault.
var walked = reverse && !source.SupportsReverseEvents;
// NOT under the node-manager Lock — awaiting the async source is safe here.
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel. A resumed page re-reads its
// boundary timestamp inclusively and then drops the `boundarySkip` ties already emitted, so it asks for that
// many more rows than the cap — otherwise every resumed page would come back short by the skip.
var fetch = EventMaxEvents(numValuesPerNode);
if (numValuesPerNode > 0 && boundarySkip > 0)
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
HistoricalEventsResult sourceResult;
if (walked)
{
// Walk for one more than the fetch: finding it is the "more events exist" signal a source gives with its
// continuation point (the walk itself cannot flag truncation).
var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1);
var (rows, next) = await ReverseEventsReader.ReadAsync(
source, sourceName, startUtc, endUtc, want, MaxTieClusterOverfetch, Clock.GetUtcNow().UtcDateTime,
walkHint, ct)
.ConfigureAwait(false);
walkHint = next;
var more = want > 0 && rows.Count > fetch;
if (more) rows.RemoveRange(fetch, rows.Count - fetch);
sourceResult = new HistoricalEventsResult(rows, more ? [0x01] : null);
}
else
{
sourceResult = await source.ReadEventsAsync(
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
}
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
// page that happens to be exactly cap-sized but complete never emits a point.
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0;
IReadOnlyList<HistoricalEvent> events = sourceResult.Events;
if (resumed && boundarySkip > 0)
{
// Drop the events at the boundary timestamp already emitted on the prior page. The resumed read is in
// the cursor's direction, so in both directions those ties lead the page.
var i = 0;
while (i < events.Count && i < boundarySkip && events[i].EventTimeUtc == startUtc) i++;
if (i > 0) events = events.Skip(i).ToList();
}
// The skip over-fetch can leave more than the cap (fewer boundary ties came back than were skipped — pruned
// between pages): keep the cap, and the dropped tail means the read continues.
if (numValuesPerNode > 0 && events.Count > numValuesPerNode)
{
events = events.Take((int)Math.Min(int.MaxValue, numValuesPerNode)).ToList();
truncated = true;
}
// The cursor to store, if any. Saved only AFTER the page is projected, so a projection throw can
// never orphan a stored cursor.
HistoryContinuationState? pendingCursor = null;
if (truncated && resumed
&& (events.Count == 0 || events[^1].EventTimeUtc == startUtc))
{
// Oversized tie cluster: more events share startUtc than the page cap, so (time, skip) alone
// cannot advance. Over-fetch the whole cluster over the half-open [T, T+resolution) window
// with an explicit bounded cap and page WITHIN it (same scheme as raw).
var resolution = Math.Max(1L, source.TimestampResolutionTicks);
var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1);
IReadOnlyList<HistoricalEvent> cluster = (await source.ReadEventsAsync(
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
.ConfigureAwait(false)).Events;
// A walked read (#691.4) emitted its ties sorted by EventId; the gateway returns them in any order and its
// end is inclusive — keep exactly the cluster, in the walk's order, so the skip indexes the same events.
if (walked)
cluster = cluster.Where(e => e.EventTimeUtc == startUtc)
.OrderBy(e => e, EventIdTieOrder.Instance).ToList();
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
if (reverse) cluster = cluster.Reverse().ToList();
if (cluster.Count > MaxTieClusterOverfetch)
{
_logger.LogError(
"OtOpcUaNodeManager: HistoryReadEvents tie cluster at {StartUtc:O} for source '{Source}' has {Count} events, " +
"exceeding MaxTieClusterOverfetch={Max}; cannot page within it. Increase MaxTieClusterOverfetch.",
startUtc, sourceName, cluster.Count, MaxTieClusterOverfetch);
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
return;
}
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
reverse: reverse, resolutionTicks: resolution);
var slice = new List<HistoricalEvent>(sliceCount);
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
events = slice;
if (nextStartUtc is { } resumeAt)
{
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
EmptyToTicks: walkHint.EmptyToTicks);
}
}
else if (truncated && events.Count > 0)
{
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted. In a
// reverse read the last event is the OLDEST on the page, and the resumed (end, T] read starts there.
var boundary = events[^1].EventTimeUtc;
var skip = 0;
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
EmptyToTicks: walkHint.EmptyToTicks);
}
var historyEvent = ProjectEvents(events, selectClauses);
var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor);
results[handle.Index] = new SdkHistoryReadResult
{
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
StatusCode = events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyEvent),
// Null when the page was complete, or when no session is available to hold the cursor.
ContinuationPoint = outboundCp,
};
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 (HistoryReadLimitExceededException ex)
{
// A request limit (#691.4: a walked-back reverse read met a tie cluster over the budget) — BadTooManyOperations
// for THIS node, as on the raw path; not a backend fault.
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
}
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] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
// ---------------------------------------------------------------------------------------------
// HistoryUpdate (pluggable historian, spec §5). The SDK's public HistoryUpdate admits a variable only when
// its AccessLevel carries HistoryWrite (see ComposeAccessLevel), pre-seeds every admitted slot
// BadHistoryOperationUnsupported, RELEASES the node-manager Lock and dispatches by details type to the
// protected arms below — so, as for HistoryRead, block-bridging the async updater here is safe. Event and
// structure-data details are not overridden: the base leaves them Unsupported.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Drops the handles in a HistoryUpdate batch the calling session may not update, marking each denied
/// slot <c>BadUserAccessDenied</c>. Unlike <see cref="AuthorizeHistoryRead"/> this gate exists in BOTH
/// modes, because rewriting recorded history was never ungated.
/// </summary>
/// <remarks>
/// <para>
/// ACL flag <b>on</b>: the scoped decision for <see cref="OpcUaOperation.HistoryUpdate"/> REPLACES the
/// role check (never chained — see <see cref="OnEquipmentTagWrite"/> for why chaining would leave
/// every narrow grant inert).
/// </para>
/// <para>
/// ACL flag <b>off</b>: the session's key is re-checked first (Gitea #563 — an expired or revoked key
/// loses its activation-time roles), then the dedicated
/// <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role is required. <c>WriteOperate</c> is
/// deliberately NOT reused: writing a live value and rewriting history are different authorities.
/// </para>
/// </remarks>
/// <param name="context">The SDK context carrying the session identity.</param>
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <returns>The handles that may proceed.</returns>
private List<NodeHandle> AuthorizeHistoryUpdate(
ServerSystemContext context,
List<NodeHandle> nodesToProcess,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
var allowed = new List<NodeHandle>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
var veto = AclEnforced
? _authorizer!.Authorize(identity, OpcUaOperation.HistoryUpdate, handle.NodeId, RealmOf(handle.NodeId))
: _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.HistoryUpdate, handle.NodeId)
?? EvaluateHistoryUpdateRoleGate(identity);
if (veto is null)
{
allowed.Add(handle);
continue;
}
// Both slots explicitly: "you may not update this" is a different fact from the pre-seeded
// "this node has no updatable history".
errors[handle.Index] = veto;
results[handle.Index] = new HistoryUpdateResult { StatusCode = veto.StatusCode };
}
return allowed;
}
/// <summary>
/// The flag-off role gate for HistoryUpdate: a null identity or one without the
/// <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role ⇒ <c>BadUserAccessDenied</c>; otherwise
/// <c>null</c> (proceed). Case-insensitive, matching the other role gates.
/// </summary>
/// <param name="identity">The session's role-carrying identity, or null.</param>
/// <returns><c>null</c> to proceed; otherwise the veto.</returns>
internal static ServiceResult? EvaluateHistoryUpdateRoleGate(RoleCarryingUserIdentity? identity) =>
identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.HistoryUpdate, StringComparer.OrdinalIgnoreCase)
? new ServiceResult(StatusCodes.BadUserAccessDenied)
: null;
/// <inheritdoc />
protected override void HistoryUpdateData(
ServerSystemContext context,
IList<UpdateDataDetails> nodesToUpdate,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// #674: one "now" per request, so every item of the batch is judged against the same bound.
var latest = LatestAcceptedSourceTime();
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
{
var details = nodesToUpdate[handle.Index];
HistoryUpdateMode mode;
switch (details.PerformInsertReplace)
{
case PerformUpdateType.Insert: mode = HistoryUpdateMode.Insert; break;
case PerformUpdateType.Replace: mode = HistoryUpdateMode.Replace; break;
case PerformUpdateType.Update: mode = HistoryUpdateMode.Update; break;
default: return null; // Remove (and anything unknown) ⇒ unsupported
}
var items = (IList<DataValue>?)details.UpdateValues ?? Array.Empty<DataValue>();
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct);
});
}
/// <inheritdoc />
protected override void HistoryDeleteRawModified(
ServerSystemContext context,
IList<DeleteRawModifiedDetails> nodesToUpdate,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
{
var details = nodesToUpdate[handle.Index];
// Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the
// audit record of every change and is aged out only by retention (#668).
if (details.IsDeleteModified) return null;
var start = details.StartTime;
var end = details.EndTime;
// Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
// it here rather than let a backend read MinValue as "from the beginning of time".
if (start == DateTime.MinValue || end == DateTime.MinValue)
return (_, _, _, _) => Task.FromResult(new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidArgument });
return async (updater, tagname, who, ct) =>
{
var code = await updater.DeleteRawAsync(tagname, start, end, who, ct).ConfigureAwait(false);
return new HistoryUpdateResult { StatusCode = code };
};
});
}
/// <inheritdoc />
protected override void HistoryDeleteAtTime(
ServerSystemContext context,
IList<DeleteAtTimeDetails> nodesToUpdate,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
{
var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List<DateTime>();
return async (updater, tagname, who, ct) =>
{
var codes = times.Count == 0
? Array.Empty<uint>()
: await updater.DeleteAtTimeAsync(tagname, times, who, ct).ConfigureAwait(false);
if (codes.Count != times.Count)
throw new InvalidOperationException(
$"historian updater returned {codes.Count} result(s) for {times.Count} time(s)");
return new HistoryUpdateResult
{
StatusCode = StatusCodes.Good,
OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))),
};
};
});
}
/// <summary>
/// #674 review: a HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a
/// <see cref="DateTimeKind.Local"/> value, which is converted (comparing its raw ticks against a UTC bound would be
/// off by the zone offset). <see cref="DateTimeKind.Unspecified"/> is treated as UTC, as the backends treat it.
/// </summary>
/// <param name="value">The item's source timestamp.</param>
/// <returns>The same instant as a UTC <see cref="DateTime"/>.</returns>
private static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Local => value.ToUniversalTime(),
DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
_ => value,
};
/// <summary>
/// #674: the latest <c>SourceTimestamp</c> a HistoryUpdate item may carry — <see cref="Clock"/>'s now plus
/// <see cref="HistoryUpdateMaxFutureSkew"/> — or <c>null</c> when the bound is disabled (non-positive skew).
/// Saturates at <see cref="DateTime.MaxValue"/> rather than overflowing for an absurd configured skew.
/// </summary>
/// <returns>The inclusive upper bound, or null for unbounded.</returns>
private DateTime? LatestAcceptedSourceTime()
{
var skew = HistoryUpdateMaxFutureSkew;
if (skew <= TimeSpan.Zero) return null;
var now = _clock.GetUtcNow().UtcDateTime;
return skew.Ticks > DateTime.MaxValue.Ticks - now.Ticks
? new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc)
: now + skew;
}
/// <summary>
/// Applies one node's UpdateDataDetails: items the server can refuse on its own
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a <c>SourceTimestamp</c> later than
/// <paramref name="latest"/> ⇒ <c>BadOutOfRange</c>, #674; a value <see cref="HistorianValue.TryFromObject"/>
/// cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally, the rest go to the updater in ONE call, and the
/// per-item codes are stitched back in request order.
/// </summary>
/// <param name="updater">The wired updater.</param>
/// <param name="tagname">The node's historian tagname.</param>
/// <param name="mode">The update mode.</param>
/// <param name="items">The request's values, in order.</param>
/// <param name="latest">The latest accepted source time (<see cref="LatestAcceptedSourceTime"/>); null = unbounded.</param>
/// <param name="who">The caller, recorded in the modification trail.</param>
/// <param name="ct">The request deadline.</param>
/// <returns>The node's result, carrying one operation result per item.</returns>
private static async Task<HistoryUpdateResult> UpdateDataAsync(
IHistorianDataUpdater updater, string tagname, HistoryUpdateMode mode, IList<DataValue> items,
DateTime? latest, HistoryUpdateContext who, CancellationToken ct)
{
var codes = new uint[items.Count];
var toSend = new List<HistorizationValue>(items.Count);
var sentIndex = new List<int>(items.Count);
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (item is null)
{
codes[i] = StatusCodes.BadInvalidArgument;
}
else if (item.SourceTimestamp == DateTime.MinValue)
{
codes[i] = StatusCodes.BadInvalidTimestamp;
}
else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound)
{
// #674: a far-future history edit is refused before any backend sees it.
codes[i] = StatusCodes.BadOutOfRange;
}
else if (!HistorianValue.TryFromObject(item.Value, out var value))
{
codes[i] = StatusCodes.BadTypeMismatch;
}
else
{
toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code));
sentIndex.Add(i);
}
}
if (toSend.Count > 0)
{
var backend = await updater.UpdateDataAsync(tagname, mode, toSend, who, ct).ConfigureAwait(false);
if (backend.Count != toSend.Count)
throw new InvalidOperationException(
$"historian updater returned {backend.Count} result(s) for {toSend.Count} value(s)");
for (var j = 0; j < backend.Count; j++) codes[sentIndex[j]] = backend[j];
}
// Per-node StatusCode stays Good even when item results are mixed — Part 11 reports item outcomes in
// OperationResults; the node code is for whole-node failures only.
return new HistoryUpdateResult
{
StatusCode = StatusCodes.Good,
OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))),
};
}
/// <summary>
/// Shared driver for the three HistoryUpdate arms: authorize, then under the shared HistoryRead batch
/// limiter + per-request deadline resolve each node's tagname and run its operation with bounded
/// parallelism. A node that is not historized, has no updater, or whose details are out of scope
/// (<paramref name="plan"/> returns null) is <c>BadHistoryOperationUnsupported</c>.
/// </summary>
/// <param name="context">The SDK context.</param>
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="plan">Builds a node's operation from its details, or returns null when unsupported.</param>
private void ServeHistoryUpdate(
ServerSystemContext context,
List<NodeHandle> nodesToProcess,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
Func<NodeHandle, Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>>?> plan)
{
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
if (nodesToProcess.Count == 0) return;
// Plan BEFORE the Primary gate (#674 review): a request this server can never serve (no updater, a node that is
// not historized, Remove, IsDeleteModified) is answered with the honest BadHistoryOperationUnsupported on every
// node, so a Secondary does not send the client to the Primary for something the Primary would also refuse.
// Planning is pure — it builds closures and reads the in-memory tagname map; nothing reaches a backend.
var updater = _historianDataUpdater;
var planned = new List<(NodeHandle Handle, string Tagname,
Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>> Operation)>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
var operation = updater is null ? null : plan(handle);
if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
continue;
}
planned.Add((handle, tagname, operation));
}
if (planned.Count == 0) return;
// #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role)
// and after planning. Off-Primary every servable node is refused BadInvalidState and nothing reaches the updater.
if (HistoryUpdatePrimaryGate is { } primary && !primary())
{
var refusal = new ServiceResult(StatusCodes.BadInvalidState,
new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
foreach (var (handle, _, _) in planned)
{
errors[handle.Index] = refusal;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidState };
}
return;
}
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
var who = HistoryUpdateCallerOf(context);
WithHistoryReadBatchLimiter(planned.Select(p => p.Handle).ToList(), results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(planned.Count);
foreach (var (handle, tagname, operation) in planned)
{
var h = handle;
var tag = tagname;
var op = operation;
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => op(updater!, tag, who, token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
/// <summary>
/// The <see cref="HistoryUpdateContext"/> of the session behind <paramref name="context"/>: its API-key principal
/// (or the reserved <c>anonymous</c> principal) and key id. Only an identity that already passed
/// <see cref="AuthorizeHistoryUpdate"/> reaches a backend, so the anonymous fallback for a missing identity is
/// defence in depth, not a path.
/// </summary>
/// <param name="context">The SDK context carrying the session identity.</param>
/// <returns>The caller recorded in the modification trail.</returns>
internal static HistoryUpdateContext HistoryUpdateCallerOf(ServerSystemContext context)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
return identity is null
? new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null)
: new HistoryUpdateContext(identity.Principal, identity.ApiKeyId);
}
/// <summary>
/// Runs one node's HistoryUpdate operation and fills its slots. Never throws (the
/// <see cref="RunBounded"/> contract): a deadline cancellation is <c>BadTimeout</c>, any backend failure
/// is logged and reported <c>BadInternalError</c> for THIS node only.
/// </summary>
/// <param name="handle">The node handle.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="operation">The node's operation.</param>
/// <param name="ct">The request deadline.</param>
private async Task ServeUpdateNodeAsync(
NodeHandle handle,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
Func<CancellationToken, Task<HistoryUpdateResult>> operation,
CancellationToken ct)
{
try
{
results[handle.Index] = await operation(ct).ConfigureAwait(false);
// The base pre-seeded BadHistoryOperationUnsupported, and the master node manager copies a non-Good
// error over the result's StatusCode — so a success MUST clear it.
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTimeout };
}
catch (NotSupportedException)
{
// The backend cannot perform this update at all (e.g. a SqlServer historian still at schema v1, with no
// modification trail to record it in, #674) — the spec status for the whole node, not a server fault.
// Mirrors the HistoryRead side's handling of the same exception.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryUpdate failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadInternalError;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInternalError };
}
}
/// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
/// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
/// saturate to <see cref="int.MaxValue"/>.</summary>
/// <param name="value">The uint cap from the request (<c>NumValuesPerNode</c>).</param>
/// <returns>The clamped non-negative int.</returns>
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> <c>maxEvents</c> argument, honouring the OPC UA
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
/// the backend's <c>maxEvents &lt;= 0</c> "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 <see cref="int.MaxValue"/> (mirroring <see cref="ClampToInt"/>).</summary>
/// <param name="numValuesPerNode">The request's <c>NumValuesPerNode</c> cap (0 ⇒ no limit).</param>
/// <returns><see cref="int.MaxValue"/> when 0 (unbounded); otherwise the clamped non-negative int.</returns>
internal static int EventMaxEvents(uint numValuesPerNode) =>
numValuesPerNode == 0 ? int.MaxValue : ClampToInt(numValuesPerNode);
/// <summary>
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
/// <paramref name="selectClauses"/>' fields in select-clause order (see
/// <see cref="ProjectEventField"/>).
/// </summary>
/// <param name="events">The historian's event rows.</param>
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
private static HistoryEvent ProjectEvents(
IReadOnlyList<HistoricalEvent> 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 };
}
/// <summary>
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
/// into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
/// <see cref="HistoricalEvent"/> shape:
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
/// </summary>
/// <param name="evt">The source event row.</param>
/// <param name="operand">The select-clause operand naming the field to project.</param>
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
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,
};
}
/// <summary>Extract the leaf (last) <see cref="QualifiedName.Name"/> from a select operand's BrowsePath,
/// or <c>null</c> when the BrowsePath is null/empty.</summary>
/// <param name="operand">The select-clause operand.</param>
/// <returns>The leaf field name, or null for an empty BrowsePath.</returns>
private static string? LeafFieldName(SimpleAttributeOperand operand)
{
var path = operand.BrowsePath;
if (path is null || path.Count == 0) return null;
return path[^1].Name;
}
/// <summary>
/// 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 <see cref="TryGetHistorizedTagname"/> lookup; the resolved tagname is passed directly
/// to <paramref name="read"/>, removing any risk of a second concurrent lookup on the same key.
/// A node we don't recognise as historized maps to <c>BadHistoryOperationUnsupported</c>
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
/// bit, but we guard explicitly). The <paramref name="read"/> 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.
/// </summary>
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
/// once the tagname is confirmed present.</param>
/// <summary>
/// Run a batch of per-node HistoryRead work items with BOUNDED parallelism (arch-review 03/S3),
/// block-bridged ONCE at the arm boundary. <b>Contract:</b> 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 <c>GetAwaiter().GetResult()</c> safe: <see cref="Task.WhenAll(Task[])"/>
/// 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.
/// </summary>
/// <param name="work">The per-node work items (index-disjoint writes into the shared results/errors).</param>
/// <param name="degree">Max concurrent work items (<see cref="HistoryReadBatchParallelism"/>).</param>
private static void RunBounded(IReadOnlyList<Func<Task>> 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();
}
/// <summary>
/// Run one HistoryRead arm under the process-wide concurrent-batch limiter (arch-review 03/S3). Each
/// arm waits up to <c>min(HistoryReadDeadline, 5 s)</c> for a slot; on expiry every handle in the
/// batch gets <c>BadTooManyOperations</c> (fast-fail under flood, brief waits under mild contention)
/// and <paramref name="serve"/> is skipped. On acquisition the arm body runs and the slot is
/// released in a <c>finally</c>. Combined with <see cref="HistoryReadBatchParallelism"/> this caps
/// total in-flight gateway reads at
/// <c>MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism</c>.
/// </summary>
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="serve">The arm body to run once a slot is acquired.</param>
private void WithHistoryReadBatchLimiter(
List<NodeHandle> nodesToProcess,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
Action serve) =>
WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle =>
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations });
/// <summary>
/// HistoryUpdate counterpart of <see cref="WithHistoryReadBatchLimiter(List{NodeHandle}, IList{SdkHistoryReadResult}, IList{ServiceResult}, Action)"/>:
/// the SAME process-wide limiter and admission wait, so update batches and read batches share one
/// concurrency budget against the historian backend.
/// </summary>
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
/// <param name="results">The service-level HistoryUpdate results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="serve">The arm body to run once a slot is acquired.</param>
private void WithHistoryReadBatchLimiter(
List<NodeHandle> nodesToProcess,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
Action serve) =>
WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle =>
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTooManyOperations });
/// <summary>Shared body of the HistoryRead / HistoryUpdate batch limiter.</summary>
/// <param name="nodesToProcess">The batch's handles.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="serve">The arm body.</param>
/// <param name="rejectResult">Fills the per-node result slot when the batch is rejected.</param>
private void WithHistoryBatchLimiter(
List<NodeHandle> nodesToProcess,
IList<ServiceResult> errors,
Action serve,
Action<NodeHandle> rejectResult)
{
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;
rejectResult(handle);
}
return;
}
try
{
serve();
}
finally
{
gate.Release();
}
}
/// <summary>
/// Lazily create (double-checked) the process-wide HistoryRead-batch limiter, sized from
/// <see cref="MaxConcurrentHistoryReadBatches"/> at first use so the Host's post-boot property set is
/// respected (the same benign wiring race as <see cref="MaxTieClusterOverfetch"/>).
/// </summary>
/// <returns>The shared batch limiter semaphore.</returns>
private SemaphoreSlim GetHistoryReadBatchLimiter()
{
var gate = _historyReadBatchLimiter;
if (gate is not null) return gate;
lock (_historyReadBatchLimiterLock)
{
return _historyReadBatchLimiter ??= new SemaphoreSlim(Math.Max(1, MaxConcurrentHistoryReadBatches));
}
}
/// <summary>
/// Create the per-request deadline source for a HistoryRead arm (arch-review 03/S3): a
/// <see cref="CancellationTokenSource"/> that fires after <see cref="HistoryReadDeadline"/>, so a
/// single request can never hold an SDK request thread longer than the deadline regardless of node
/// count. Returns <c>null</c> when the deadline is non-positive (unbounded — the arm then passes
/// <see cref="CancellationToken.None"/>), matching the <c>ServerHistorianOptions</c> warning.
/// </summary>
/// <returns>The deadline source, or <c>null</c> for an unbounded (non-positive) deadline.</returns>
private CancellationTokenSource? CreateDeadline() =>
HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null;
/// <summary>Awaits one work item under a semaphore permit, releasing it when the item completes.</summary>
/// <param name="gate">The concurrency-limiting semaphore.</param>
/// <param name="work">The per-node work item.</param>
private static async Task RunGatedAsync(SemaphoreSlim gate, Func<Task> work)
{
await gate.WaitAsync().ConfigureAwait(false);
try
{
await work().ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
private async Task ServeNodeAsync(
NodeHandle handle,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
Func<IHistorianDataSource, string, CancellationToken, Task<HistorianRead>> read,
CancellationToken ct)
{
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname))
{
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
// status, but set it explicitly so the contract is local + obvious.)
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
try
{
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so awaiting
// the async source here is safe and won't freeze the address space.
var sourceResult = await read(HistorianDataSource, tagname!, ct).ConfigureAwait(false);
var historyData = ToHistoryData(sourceResult);
results[handle.Index] = new SdkHistoryReadResult
{
// No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
// Single-shot arms (Processed / AtTime) never page — the backend returns the complete
// result in one read (no client count cap to detect a "full page" against), so no
// continuation point. Raw pages via ServeRawPaged, not this helper.
ContinuationPoint = null,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while this node was in flight — surface BadTimeout for
// THIS node only (a spec-conformant history-read status), ahead of the generic catch.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryAggregateNotSupportedException ex)
{
// The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
// not a logged server fault.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
}
catch (HistoryReadLimitExceededException ex)
{
// The read would exceed a backend size limit (#671: the time-weighted raw-row cap) — refuse THIS node
// explicitly instead of a generic fault or a deadline-elapsed BadTimeout.
_logger.LogWarning("OtOpcUaNodeManager: HistoryRead limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
}
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] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item><b>Fresh read</b> (no inbound continuation point): read the window from
/// <c>details.StartTime</c> to <paramref name="endUtc"/> capped at
/// <paramref name="numValuesPerNode"/> <b>+ 1</b> — one lookahead row (#692). If the lookahead row came
/// back, return the first N, store a resume cursor and emit a continuation point; otherwise the page is
/// the last and carries none (so a total that is an exact multiple of N ends on a full page, not on an
/// empty trailing one).</item>
/// <item><b>Resume read</b> (inbound continuation point present): take the stored cursor, read
/// <c>N + boundarySkip + 1</c> rows from the boundary (the ties about to be trimmed, the page, and the
/// lookahead row), trim the already-emitted boundary ties, and continue exactly as a fresh read — so every
/// page carries N values. (Asking for only N used to net N − skip per resumed page, and at N = 1 an EMPTY
/// page between every value: the trimmed page looked like a stalled tie cluster and paid a
/// <see cref="MaxTieClusterOverfetch"/> + 1 over-fetch to step one tick — Gitea #692.)</item>
/// </list>
/// The resume cursor is tie-safe (see <see cref="HistoryPaging.ComputeResumeCursor"/> /
/// <see cref="HistoryPaging.TrimBoundaryDuplicates"/>): 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
/// <see cref="HistoryContinuationStore"/> — session-bound + capped in production. Per-node error
/// isolation matches <see cref="ServeNode"/>: a backend throw / an unknown continuation point
/// becomes a Bad status for THIS node only and never throws out of the batch.
/// </summary>
/// <param name="handle">The pre-filtered node handle; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="session">The session the read runs under (null on the session-less in-process path).</param>
/// <param name="nodesToRead">The per-node read list; <c>nodesToRead[handle.Index].ContinuationPoint</c>
/// carries the inbound continuation point (non-null ⇒ a resume read).</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="startTimeUtc">The request window's (inclusive) lower bound, used for a fresh read.</param>
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
/// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "all values, no paging".</param>
/// <param name="ct">Cancellation (the per-request deadline).</param>
/// <param name="returnBounds">
/// Part 11 bounding values (#691, <see cref="HistoryBounds"/>): the start bound leads the first page, the end bound
/// ends the last, and both count toward <paramref name="numValuesPerNode"/>. A resumed read takes it from the cursor.
/// </param>
private async Task ServeRawPagedAsync(
NodeHandle handle,
ISession? session,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
DateTime startTimeUtc,
DateTime endUtc,
uint numValuesPerNode,
CancellationToken ct,
bool returnBounds = false)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
{
DateTime startUtc;
var boundarySkip = 0;
var walkHint = default(ReverseWalkHint); // #686/#689/#691: the walk-back's width + proven-empty stretch, page to page
HistoryEndBound? endBound = null; // #691: the end bound still owed (null = bounds not requested)
var withinTieCluster = false; // #692: the previous page sliced an oversized tie cluster that still has ties
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 || state.Kind != HistoryCursorKind.Raw
|| !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname)
{
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;
walkHint = state.WalkHint;
endBound = state.PendingEndBound;
withinTieCluster = state.WithinTieCluster;
}
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;
// Part 11: with NumValuesPerNode > 0 one bound may be left unset. Only a StartTime ⇒ page FORWARD from
// it to an open upper bound; only an EndTime ⇒ page BACKWARD from it (a reverse read, start > end).
// Without this an unset EndTime (MinValue) looked like a reverse read to a lower bound of "now".
// Every backend serves the backward read (#686): natively when it declares SupportsReverseRaw, otherwise
// walked back from bounded forward reads below — unlike the events path, which keeps it forward there.
if (numValuesPerNode > 0)
{
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
}
if (returnBounds) endBound = HistoryBounds.For(startUtc, endUtc);
}
var nowUtc = Clock.GetUtcNow().UtcDateTime;
var resolution = Math.Max(1L, HistorianDataSource.TimestampResolutionTicks);
// #691: the data is drained — the end bound ends this page when it has room (Part 11: bounds count toward
// NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any.
async Task<byte[]?> AppendEndBoundAsync(List<DataValueSnapshot> page, DateTime resumedAtUtc)
{
// The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this
// page's last, else the last of an earlier page (carried in the cursor), else where the read resumed.
var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null)
?? endBound!.LastReturnedUtc ?? resumedAtUtc;
if (numValuesPerNode == 0 || page.Count < numValuesPerNode)
{
page.Add(await HistoryBounds.EndBoundAsync(
HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false));
return null;
}
return _historyContinuationStore.Save(session, new HistoryContinuationState(
tagname, previous, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true }));
}
// #691 review: whether a bounded read whose start bound was NOT found has any data or a found end bound — only
// asked when a one-value page would otherwise be the start-bound placeholder alone. #692: what it learns rides the
// cursor — the walk hint its walk-back computed, and, when there is no data, the end bound it found (so the next
// page is that bound with no backend read at all).
async Task<(bool Any, HistoryEndBound Pending)> BoundedReadHasAnythingAsync()
{
var src = HistorianDataSource;
IReadOnlyList<DataValueSnapshot> first;
if (startUtc > endUtc && !src.SupportsReverseRaw)
{
var walked = await ReverseRawReader.ReadAsync(
src, tagname, startUtc, endUtc, 1, MaxTieClusterOverfetch, nowUtc, walkHint, ct)
.ConfigureAwait(false);
walkHint = walked.Hint;
first = walked.Samples;
}
else first = (await src.ReadRawAsync(tagname, startUtc, endUtc, 1, ct).ConfigureAwait(false)).Samples;
if (first.Any(x => HistoryBounds.InDataWindow(x, endBound!, resolution))) return (true, endBound!);
var end = await HistoryBounds.EndBoundAsync(
src, tagname, endBound!, startUtc, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false);
return HistoryBounds.IsPlaceholder(end)
? (false, endBound!)
: (true, endBound! with { DataExhausted = true, Resolved = end });
}
// #691: a resumed bounded read whose data is drained (the end bound did not fit on the previous page, or the
// resume point already sits on the end time) owes only the end bound.
if (endBound is not null && inboundCp is { Length: > 0 }
&& (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution)))
{
var onlyBound = new List<DataValueSnapshot>(1);
var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false);
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(onlyBound)),
ContinuationPoint = afterCp,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
// #691: the start bound leads the first page and counts toward the cap; null when a sample sits on StartTime
// (that sample is the bound, and it is the data's first).
DataValueSnapshot? startBound = null;
var readCap = numValuesPerNode;
if (endBound is not null && inboundCp is not { Length: > 0 })
{
startBound = await HistoryBounds.StartBoundAsync(
HistorianDataSource, tagname, startUtc, endBound.Reverse, MaxTieClusterOverfetch, nowUtc, ct)
.ConfigureAwait(false);
var (anything, pending) = startBound is not null && numValuesPerNode == 1
? HistoryBounds.IsPlaceholder(startBound)
? await BoundedReadHasAnythingAsync().ConfigureAwait(false)
: (true, endBound)
: (false, endBound);
if (startBound is not null && numValuesPerNode == 1 && anything)
{
// The page is the start bound alone; the data starts at StartTime on the next page.
var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
tagname, startUtc, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(pending, [startBound])));
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([startBound])),
ContinuationPoint = boundOnlyCp,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
if (startBound is not null && numValuesPerNode == 1)
{
// Nothing at all — no data and no bound found (review of #691): the pre-bounds GoodNoData answer.
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.GoodNoData,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([])),
ContinuationPoint = null,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
if (startBound is not null && numValuesPerNode > 0) readCap = numValuesPerNode - 1;
}
// #692: the page's read asks for the boundary ties it is about to trim (a resumed page re-reads them, because the
// cursor resumes AT the boundary timestamp, inclusive) plus ONE lookahead row past the page. Without them a resumed
// page netted N − skip values, and at N = 1 nothing at all: the lone boundary sample came back, was trimmed, the
// "full" empty page was taken for a stalled tie cluster, and a MaxTieClusterOverfetch + 1 over-fetch stepped one
// tick to return an EMPTY page with a continuation point — between every value. The lookahead row is what says
// "more data follows": a page whose read found no row past it is the last, so the read no longer ends with an
// empty trailing page either. (RawPageLimit.Plan computes the request: page + boundary ties + 1.)
var resumed = inboundCp is { Length: > 0 };
var boundaryTies = resumed ? Math.Max(0, boundarySkip) : 0;
var want = readCap; // N, less the start bound on a bounded first page; 0 = all values
// HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
// A reverse window (explicit, resumed, or the end-only read above) goes to the backend only when it declares it
// can read newest-first; otherwise it is walked back from bounded forward reads (#686 — the Gateway rejects
// start > end, which failed the node and counted as a backend fault).
// A cursor left inside an oversized tie cluster (#692) skips this read: every row it could return is a tie.
// #694: a backend with a row ceiling (MaxRawValuesPerRead = L > 0, a strict gateway) sizes the page to it — the
// read asks for page + boundary ties + lookahead ≤ L, and a full page carries a continuation point as always
// (uncapped reads included). No room left after the boundary ties ⇒ the cursor sits in a tie cluster as large as
// a read: straight to the over-fetch. See RawPageLimit.
var source = HistorianDataSource;
IReadOnlyList<DataValueSnapshot> samples = [];
var backendFull = false;
int pageLimit;
if (!withinTieCluster)
{
var read = await RawPageLimit.ReadAsync(source, want, boundaryTies, async plan =>
{
if (startUtc > endUtc && !source.SupportsReverseRaw)
{
var page = await ReverseRawReader.ReadAsync(
source, tagname, startUtc, endUtc, plan.RequestCap, MaxTieClusterOverfetch,
nowUtc, walkHint, ct, retryOnLearnedLimit: false)
.ConfigureAwait(false);
walkHint = page.Hint;
return new HistorianRead(page.Samples, ContinuationPoint: null);
}
return await source.ReadRawAsync(tagname, startUtc, endUtc, plan.RequestCap, ct).ConfigureAwait(false);
}).ConfigureAwait(false);
pageLimit = read.Plan.Limit;
if (read.Plan.NoRoom)
{
if (!resumed)
throw new HistoryReadLimitExceededException(pageLimit,
$"The backend's row ceiling ({pageLimit} values per read) leaves no room for a raw page of '{tagname}'.");
withinTieCluster = true; // every row a read could return is a boundary tie already emitted
}
else
{
readCap = read.Plan.PageCap;
var sourceResult = read.Page!;
backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, read.Plan.RequestCap);
// On a resume read, drop the boundary ties already returned on the prior page.
samples = resumed
? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
: sourceResult.Samples;
// #691: a bounded read's data window excludes the end time (a sample there is the end bound). A backend whose
// forward end is inclusive (the gateway's own) returns it as data; dropping it also means the data is drained.
if (endBound is not null)
{
var inside = samples.Where(x => HistoryBounds.InDataWindow(x, endBound, resolution)).ToList();
if (inside.Count != samples.Count)
{
samples = inside;
backendFull = false;
}
}
}
}
else
{
pageLimit = source.MaxRawValuesPerRead;
}
// Oversized tie cluster: a resume read whose FULL backend read does not advance past the resume boundary
// timestamp `startUtc` — every row it returned past the trimmed ties, the lookahead row included, still sits on
// `startUtc`. That happens only when more samples share `startUtc` than the page plus the already-emitted ties
// plus one: a genuine tie cluster larger than the page. The fixed-(start,end,cap) backend can only ever return
// the first `cap` of those ties, so growing the read page by page would re-read the cluster quadratically.
// Instead 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. (#692: before the read asked for the trimmed ties
// and a lookahead row, a resumed N = 1 page tripped this on every single sample.)
var stalledOnTieCluster = resumed
&& (withinTieCluster
|| (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).
// #694: on a backend with a row ceiling the bound is also at most L − 1, so the over-fetch (bound + 1 rows)
// fits one read and an oversized cluster is refused here rather than by a gateway refusal.
var overfetchBound = pageLimit > 0
? Math.Min(Math.Max(1, MaxTieClusterOverfetch), Math.Max(1, pageLimit - 1))
: Math.Max(1, MaxTieClusterOverfetch);
var overfetchCap = (uint)overfetchBound + 1u;
IReadOnlyList<DataValueSnapshot> cluster = (await HistorianDataSource
.ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, ct)
.ConfigureAwait(false)).Samples;
// A start == end point read returns the ties in the backend's FORWARD order, but a reverse read
// emitted them (and counted boundarySkip) in descending order — the mirror of forward. Slice the
// cluster in the read's own direction, or the skip would index from the wrong end and re-emit ties.
var reverse = startUtc > endUtc;
if (reverse) cluster = cluster.Reverse().ToList();
// 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 > overfetchBound)
{
_logger.LogError(
"OtOpcUaNodeManager: HistoryReadRaw tie cluster at {StartUtc:O} for tag '{Tagname}' has {Count} samples, " +
"exceeding {Max} (MaxTieClusterOverfetch, or the backend's row ceiling less one); cannot page within it. " +
"Increase MaxTieClusterOverfetch (or the HistorianGateway MaxValuesCeiling).",
startUtc, tagname, cluster.Count, overfetchBound);
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
return;
}
// Direction comes from the window itself (Part 11: start > end is a reverse read). A resumed reverse
// read keeps start > end, because a reverse resume point is always strictly after endUtc.
// #694: an uncapped read pages only at the backend's limit, so it slices at that page size — never 0 (a zero
// slice hands back the same cursor forever; a limit that reverted to 0 under an uncapped cursor in a cluster
// falls back to the cluster's own size).
var sliceSize = numValuesPerNode > 0 ? numValuesPerNode
: readCap > 0 ? readCap
: pageLimit > 0 ? (uint)pageLimit
: (uint)Math.Max(1, cluster.Count);
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, sliceSize, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
reverse: reverse, resolutionTicks: Math.Max(1L, HistorianDataSource.TimestampResolutionTicks));
var slice = new List<DataValueSnapshot>(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, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound, slice),
WithinTieCluster: resumeAt == startUtc);
clusterCp = _historyContinuationStore.Save(session, clusterState);
}
else if (endBound is not null)
{
// #691: the data is drained — the end bound ends this page, or is the next one when this one is full.
clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false);
}
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;
}
// #692: more than the page came back (the lookahead row, after trimming) ⇒ there is more data: return the page and
// resume after it. Otherwise the data is drained and this is the last data page.
var more = readCap > 0 && samples.Count > readCap;
if (more) samples = samples.Take((int)readCap).ToList();
HistoryContinuationState? nextState = null;
if (more)
{
// A page that never left the boundary timestamp (ties continuing a resumed page) adds to the ties already
// emitted there, or the next page would re-emit the earlier ones.
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
if (resumed && nextStart == startUtc) skip += boundarySkip;
nextState = new HistoryContinuationState(
tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound, samples));
}
// #691: the start bound (first page only) leads the data.
if (startBound is not null)
{
var led = new List<DataValueSnapshot>(samples.Count + 2) { startBound };
led.AddRange(samples);
samples = led;
}
var historyData = ToHistoryDataFromSamples(samples);
byte[]? outboundCp = null;
if (nextState is not null)
{
// 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);
}
else if (endBound is not null)
{
// #691: the data is drained — the end bound ends this page, or is the next one when this one is full.
var page = new List<DataValueSnapshot>(samples.Count + 1);
page.AddRange(samples);
outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false);
samples = page;
historyData = ToHistoryDataFromSamples(samples);
}
// #691 review: a whole bounded read with no data and no bound found (one page, every value a placeholder) is the
// pre-bounds answer — GoodNoData, no values (§6.5.3.2: bounds make it a success only "if one or both exist").
if (endBound is not null && inboundCp is not { Length: > 0 } && outboundCp is null
&& samples.All(HistoryBounds.IsPlaceholder))
{
samples = [];
historyData = ToHistoryDataFromSamples(samples);
}
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 (HistoryReadLimitExceededException ex)
{
// A request limit (#686: a reverse read walked back from forward reads met a tie cluster over the row
// budget) — BadTooManyOperations for THIS node, as on the processed path; not a backend fault.
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadRaw limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
}
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] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
/// <summary>
/// Serve one historized variable for a HistoryReadRaw with <c>IsReadModified = true</c> (Part 11 modified-value
/// history, Gitea #668), with continuation-point paging. The backend orders the trail by
/// <c>(SourceTimeUtc, ModifiedId)</c> and reports truncation itself (<see cref="HistoryModifiedReadResult.HasMore"/>),
/// so the cursor is an exact, exclusive key — there are no ties to trim. Window normalisation matches
/// <see cref="ServeRawPagedAsync"/>: with a cap, an unset EndTime pages forward to an open bound and an unset
/// StartTime pages backward. A cursor is bound to the node's tagname and to the modified read kind; any other
/// point is <c>BadContinuationPointInvalid</c>. A backend that keeps no trail (Gateway, Null) throws
/// <see cref="NotSupportedException"/> ⇒ <c>BadHistoryOperationUnsupported</c> for this node.
/// </summary>
/// <param name="handle">The node handle; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="session">The session (null on the session-less in-process path ⇒ single-shot).</param>
/// <param name="nodesToRead">The per-node read list (inbound continuation points).</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="startTimeUtc">Request StartTime.</param>
/// <param name="endUtc">Request EndTime.</param>
/// <param name="numValuesPerNode">Per-page cap; <c>0</c> = all.</param>
/// <param name="ct">Request deadline.</param>
private async Task ServeModifiedPagedAsync(
NodeHandle handle,
ISession? session,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
DateTime startTimeUtc,
DateTime endUtc,
uint numValuesPerNode,
CancellationToken ct)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
{
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
return;
}
var startUtc = startTimeUtc;
HistoryModifiedCursor? resumeAfter = null;
if (inboundCp is { Length: > 0 })
{
var state = _historyContinuationStore.TryTake(session, inboundCp);
if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
return;
}
// The cursor carries the NORMALISED window, so a resumed page never re-derives its direction.
startUtc = state.WindowStartUtc;
endUtc = state.EndUtc;
numValuesPerNode = state.NumValuesPerNode;
resumeAfter = new HistoryModifiedCursor(state.NextStartUtc, state.LastModifiedId);
}
else if (numValuesPerNode > 0)
{
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
}
var page = await HistorianDataSource
.ReadModifiedAsync(tagname, startUtc, endUtc, numValuesPerNode, resumeAfter, ct)
.ConfigureAwait(false);
var data = ToHistoryModifiedData(page.Values);
byte[]? outboundCp = null;
if (page.HasMore && numValuesPerNode > 0 && page.Values.Count > 0)
{
var last = page.Values[^1];
outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
tagname, last.SourceTimeUtc, endUtc, 0, numValuesPerNode,
HistoryCursorKind.Modified, last.ModifiedId, startUtc));
}
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = page.Values.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(data),
ContinuationPoint = outboundCp,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (NotSupportedException ex)
{
// The backend keeps no modification trail (Gateway / Null) — the spec status, not a server fault.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend serves no modified-value history for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead (modified) failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
/// <summary>
/// Project a modification trail into an SDK <see cref="HistoryModifiedData"/>: one prior <see cref="DataValue"/>
/// per modification plus a parallel <see cref="ModificationInfo"/> (time, Part 11 update type, user name).
/// </summary>
/// <param name="values">The modifications, in the order to emit.</param>
/// <returns>The populated <see cref="HistoryModifiedData"/>.</returns>
internal static HistoryModifiedData ToHistoryModifiedData(IReadOnlyList<HistoryModifiedValue> values)
{
var dataValues = new DataValueCollection(values.Count);
var infos = new ModificationInfoCollection(values.Count);
foreach (var m in values)
{
dataValues.Add(ToSdkDataValue(m.Value));
infos.Add(new ModificationInfo
{
ModificationTime = m.ModificationTimeUtc,
UpdateType = (HistoryUpdateType)(int)m.UpdateType,
UserName = m.UserName,
});
}
return new HistoryModifiedData { DataValues = dataValues, ModificationInfos = infos };
}
/// <inheritdoc />
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
IList<HistoryReadValueId> nodesToRead,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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;
}
}
/// <summary>Transport failures are retryable; unsupported operations retain their distinct status (#697).</summary>
private static uint HistoryReadFailureStatus(Exception error)
{
for (Exception? ex = error; ex is not null; ex = ex.InnerException)
if (ex is System.IO.IOException or System.Net.Sockets.SocketException or System.Net.Http.HttpRequestException or TimeoutException)
return StatusCodes.BadNoCommunication;
return StatusCodes.BadHistoryOperationUnsupported;
}
/// <summary>Normalize stored and AtTime history to each scalar node's declared DataType (#698).</summary>
private void NormalizeHistoryValues(List<NodeHandle> handles, IList<SdkHistoryReadResult> results)
{
foreach (var handle in handles)
{
if (AtTimeValueType(handle.NodeId) is not { } type || results[handle.Index]?.HistoryData is not { } body) continue;
var values = ExtensionObject.ToEncodeable(body) switch
{
HistoryData data => data.DataValues,
_ => null,
};
if (values is null) continue;
foreach (var value in values)
{
if (value.Value is null) continue;
try
{
// A gateway number cannot reconstruct a DateTime or a String which was never stored.
if ((type == TypeCode.DateTime && value.Value is not DateTime)
|| (type == TypeCode.String && value.Value is not string)) throw new InvalidCastException();
var converted = Convert.ChangeType(value.Value, type, System.Globalization.CultureInfo.InvariantCulture);
if (converted is float f && float.IsInfinity(f) && value.Value is double d && double.IsFinite(d))
throw new OverflowException();
value.WrappedValue = new Variant(converted);
}
catch (Exception ex) when (ex is InvalidCastException or OverflowException or FormatException)
{
value.WrappedValue = Variant.Null;
value.StatusCode = StatusCodes.BadTypeMismatch;
}
}
}
}
/// <summary>Project a plain sample list into an SDK <see cref="HistoryData"/> (the paged Raw path
/// works on a trimmed <see cref="IReadOnlyList{T}"/> rather than a whole <see cref="HistorianRead"/>).</summary>
/// <param name="samples">The samples to project (already trimmed of boundary duplicates).</param>
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
private static HistoryData ToHistoryDataFromSamples(IReadOnlyList<DataValueSnapshot> samples)
{
var values = new DataValueCollection(samples.Count);
foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
return new HistoryData { DataValues = values };
}
/// <summary>
/// Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
/// SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
/// <c>DateTime.MinValue</c> (the SDK's "unset" sentinel for that field).
/// </summary>
/// <param name="sourceResult">The data source's read result.</param>
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
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 };
}
/// <summary>Convert one driver-agnostic <see cref="DataValueSnapshot"/> to an SDK
/// <see cref="DataValue"/>, mirroring value / status code / source + server timestamps.</summary>
/// <param name="snapshot">The source sample.</param>
/// <returns>The equivalent SDK data value.</returns>
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,
};
/// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> 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<IReference>();
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;
}
}