fix(alarms,acl): one condition event per Part 9 method call; gate Server-object model-change/audit events (#587, #588)

#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's
ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node
already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each
method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed
re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise
AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies
it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report
every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0
and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed.

#588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent
(ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the
Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus
the SDK audit/encryption guard. Flag off: unchanged.

#586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per
condition ⇒ O(N²) deploys under Lock).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 00:54:08 -04:00
parent 390c8269a0
commit 167a48bffa
10 changed files with 1454 additions and 66 deletions
@@ -825,11 +825,15 @@ 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.
// Gitea #587: a NATIVE condition's Confirmed and Shelving belong to the Part 9 methods, not
// to this projection — see WireMethodTransitionReporting.
var methodOwned = _nativeAlarmNodeIds.Contains(key);
var current = ReadConditionDelta(condition);
var incoming = ToConditionDelta(state, condition);
var incoming = ToConditionDelta(state, condition, methodOwned);
bool fire = ShouldFireConditionEvent(current, incoming);
// EnabledState / AckedState / ActiveState are mandatory children — always present after
@@ -841,11 +845,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.SetEnableState(SystemContext, state.Enabled);
condition.SetActiveState(SystemContext, state.Active);
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (condition.ConfirmedState is not null)
if (condition.ConfirmedState is not null && !methodOwned)
{
condition.SetConfirmedState(SystemContext, state.Confirmed);
}
if (condition.ShelvingState is not null)
if (condition.ShelvingState is not null && !methodOwned)
{
// 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, not the
@@ -881,8 +885,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 +975,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 +1052,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>
@@ -1111,16 +1117,24 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// the bucket the node holds (the projection calls <c>SetSeverity(MapSeverity(...))</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(
/// <param name="methodOwned">True for a native condition, whose Confirmed and Shelving the projection does
/// not apply (see <see cref="WireMethodTransitionReporting"/>); they are folded to the node's current value so the
/// snapshot's constants can never register as a delta.</param>
private static AlarmConditionDelta ToConditionDelta(
AlarmConditionSnapshot state, AlarmConditionState condition, bool methodOwned = false) => 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,
Confirmed: condition.ConfirmedState is null ? true
: methodOwned ? condition.ConfirmedState.Id?.Value ?? true
: state.Confirmed,
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,
Shelving: condition.ShelvingState is null ? AlarmShelvingKind.Unshelved
: methodOwned ? ReadShelvingKind(condition)
: state.Shelving,
MappedSeverity: (ushort)MapSeverity(state.Severity),
Message: state.Message ?? string.Empty,
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
@@ -1201,6 +1215,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 +1294,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
// A native condition's Confirmed is method-owned (Gitea #587 — see WireMethodTransitionReporting), so
// no projection will ever set it: start it confirmed, the value the driver projection used to supply.
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,10 +1313,11 @@ 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/
@@ -1348,6 +1376,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 +1395,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 +1522,112 @@ 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: Confirmed and Shelving are the methods'.</b> A native condition's state is
/// re-projected from each driver transition by <c>NativeAlarmProjector</c>, which models neither
/// confirmation nor shelving — it always says <c>Confirmed = true</c>, <c>Unshelved</c>. The SDK's
/// Acknowledge sets <c>Confirmed = false</c> (the condition supports Confirm), so the driver's own
/// echo of that ack would otherwise flip it back and report a second event for one ack, and a
/// local shelve would be undone (and reported) by the next driver transition. So for a native
/// condition <see cref="WriteAlarmCondition"/> neither projects nor compares those two fields:
/// they change only through these methods, each reported once here. A scripted condition's engine
/// models both, so its projection still owns them.
/// </para>
/// <para>
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. acknowledging an acknowledged condition, a
/// denied gate, or Enable/Disable 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)
WrapComment(alarm.Acknowledge);
WrapComment(alarm.Confirm);
WrapComment(alarm.AddComment);
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)
{
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)
{
if (method?.OnCall is not { } sdk) return;
method.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null);
}
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)
{
lock (Lock)
{
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))
{
apply?.Invoke();
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 +1654,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 +1675,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 +1694,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 +1713,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 +2197,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
try
{
Server.ReportEvent(SystemContext, auditEvent);
ReportToServerObject(auditEvent);
}
catch (Exception ex)
{
@@ -2417,7 +2560,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
try
{
Server.ReportEvent(SystemContext, BuildNodeShapeChangedEvent(variable));
ReportToServerObject(BuildNodeShapeChangedEvent(variable));
}
catch (Exception ex)
{
@@ -2461,7 +2604,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 +2790,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
try
{
Server.ReportEvent(SystemContext, e);
ReportToServerObject(e);
}
catch (Exception ex)
{
@@ -3484,9 +3627,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 +3654,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 +3670,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 +3708,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 +3784,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.