Merge #587/#588: one condition event per method-driven transition (native: Ack/AddComment only); model-change + audit events gated per session on i=2253 when ACL is on

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 01:23:20 -04:00
10 changed files with 1696 additions and 67 deletions
@@ -825,9 +825,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// 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 + auto-fired its own event) 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.
// 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);
@@ -881,8 +882,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// 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 ack the SDK already applied equals the node's current state → no delta →
// suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
// 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)
{
@@ -970,16 +972,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// materialise) — no branch creation here.
/// </para>
/// <para>
/// <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
/// goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
/// state to the node and auto-fires its own condition event (E2) — directly on the node,
/// BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
/// transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
/// event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
/// incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
/// pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
/// engine-driven transitions still differ from the node's current state, so they fire here as
/// before.
/// <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>
@@ -1046,11 +1049,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <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>, so the OPC UA SDK itself applies the comment to the node
/// and auto-fires the Part 9 comment event (E2) directly to subscribers — 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 (E3). Force-firing for
/// <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>
@@ -1201,6 +1204,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
UnwireAlarmNotifiers(conditionKey);
existing.Parent?.RemoveChild(existing);
PredefinedNodes?.Remove(existing.NodeId);
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
// dropped instance's back here rather than leave it tracked until the next full reconcile.
if (_allEventsMonitoredConditions.Remove(existing))
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
}
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
@@ -1276,7 +1283,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
// supplies on every transition, rather than the SDK's default false until the first projection.
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
alarm.SetSeverity(SystemContext, MapSeverity(severity));
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
// TimedShelve handler rejects any ShelvingTime above it — so every client TimedShelve failed with
// BadShelvingTimeOutOfRange. The bound becomes the longest shelve the SDK's own shelving timer can
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
maxTimeShelved.Value = int.MaxValue;
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
alarm.Message.Value = new LocalizedText(displayName);
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
@@ -1286,24 +1303,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// 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 + auto-fires its own event (E2).
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
// 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. Confirm/AddComment/
// Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
// 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) =>
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
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
@@ -1348,6 +1374,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
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
@@ -1363,7 +1393,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
AddPredefinedNode(SystemContext, alarm);
_alarmConditions[conditionKey] = alarm;
ReconcileAllEventsMonitoring();
// 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);
@@ -1487,6 +1520,148 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_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, 0)</c>
/// mapping <see cref="WriteAlarmCondition"/> projects — 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 a
/// re-ack) and the call is silent. AddComment always reports: adding a comment is itself the change.
/// </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)
{
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true);
}
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)
{
// shelvingTime 0, as WriteAlarmCondition projects it: the scripted engine owns the expiry and
// re-projects Unshelved when it passes (the SDK's own timer only routes an Unshelve back to it).
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
() => timed(context, method, objectId, shelvingTime),
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: 0));
}
}
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>
@@ -1513,8 +1688,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <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 + auto-fires its
/// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
/// <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)
{
@@ -1534,7 +1709,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// 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 + auto-fires its own condition event.
// 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;
}
@@ -1552,7 +1728,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <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 + auto-fires its event);
/// <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 (no route, no state mutation).</returns>
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
{
@@ -1571,7 +1747,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
Comment: comment?.Text,
OperatorUser: AlarmOperatorName(identity!)));
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
// 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;
}
@@ -2054,7 +2231,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
try
{
Server.ReportEvent(SystemContext, auditEvent);
ReportToServerObject(auditEvent);
}
catch (Exception ex)
{
@@ -2417,7 +2594,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
try
{
Server.ReportEvent(SystemContext, BuildNodeShapeChangedEvent(variable));
ReportToServerObject(BuildNodeShapeChangedEvent(variable));
}
catch (Exception ex)
{
@@ -2461,7 +2638,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
try
{
Server.ReportEvent(SystemContext, e);
ReportToServerObject(e);
}
catch (Exception ex)
{
@@ -2647,7 +2824,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
try
{
Server.ReportEvent(SystemContext, e);
ReportToServerObject(e);
}
catch (Exception ex)
{
@@ -3484,9 +3661,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <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 own method-driven state changes (and their 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.
/// 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
@@ -3507,6 +3688,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
: 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);
@@ -3521,6 +3704,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <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>
@@ -3533,7 +3742,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </para>
/// <para>
/// Only condition events bubble through this manager's notifiers; the audit and model-change
/// events it raises go to <c>Server.ReportEvent</c> directly and are untouched by this.
/// 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)
@@ -3608,6 +3818,130 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|| 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.