fix(opcua): #855 review 2 — frozen replay skips non-live conditions; per-hop ACL resolution; drain never inline

- QueueFrozenReplay's verdict refuses a condition a deploy removed or replaced
  since the cut (Server-object and folder), so the cut path cannot replay a
  ghost or judge a replaced scripted alarm on its old folder.
- DeliverToNotifierItems resolves the #826 decision's item-independent part
  (live condition, notifier check, scopes) once per hop; only the grant and
  SDK role checks stay per item.
- A deferred delivery's drain starts on the pool, never inline under Lock.
- Test: a condition removed between the cut and its replay (folder + Server
  object).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:40:06 -05:00
parent d2b1545659
commit f775625805
3 changed files with 101 additions and 5 deletions
@@ -1539,13 +1539,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
return;
}
DeferredConditionReports queue;
lock (_eventReportGate)
{
var queue = new DeferredConditionReports();
queue = new DeferredConditionReports();
_deferredReports[condition] = queue;
CountDeferred(+1, condition);
_ = DrainDeferredAsync(condition, queue, delivery.AsTask());
}
// #855 review 2: started on the pool, never inline. The suspended delivery may complete between the check above and
// here; an inline drain would then run (and end the episode, and clear cuts over every item gate) on this thread,
// under Lock. The queue is registered first, so this condition's later reports wait behind it either way.
var inFlight = delivery.AsTask();
_ = Task.Run(() => DrainDeferredAsync(condition, queue, inFlight));
}
/// <summary>Queues a later event behind a condition's deferred one, coalescing past the budget. Under the gate.</summary>
@@ -1719,13 +1725,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
private void DeliverToNotifierItems(MonitoredNode2 monitored, IFilterTarget e)
{
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
// #855 review 2: the #826 decision's item-independent part — the live condition, whether this notifier is still a
// legitimate notifier of it, and its visibility scopes — is resolved ONCE per hop, not per item (it is what
// IsNotifierEventVisibleTo resolves for an item attached here). Only the grant check and the SDK's role check
// remain per item.
var enforced = AclEnforced;
List<NodeId>? scopes = null;
if (enforced)
{
if (EventConditionOf(e) is not { } named
|| !_alarmConditions.TryGetValue(MapKey(named.NodeId), out var live)
|| !IsLiveNotifierOf(live, monitored.Node))
{
return; // fail closed for every item on this notifier
}
scopes = ConditionVisibilityScopes(live);
}
foreach (var item in monitored.EventMonitoredItems.Values)
{
if (item is null) continue;
try
{
if (IsAuditEvent(e) && !IsAuditDeliverableTo(item)) continue;
if (ServiceResult.IsBad(((INodeManager3)this).ValidateEventRolePermissions(item, e))) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
QueueConditionEventToItem(item, e, deferred, DeferredFolderDeliveryProbe);
}
catch (Exception ex)
@@ -6701,6 +6727,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
generation,
(item, e) =>
{
// #855 review 2: never replay a condition a deploy has removed or replaced since the cut — its frozen
// state would be a ghost (and, with the ACL on, a replaced scripted alarm would be judged on its old folder).
if (ConditionOf(e) is { } frozen && !IsLiveCondition(frozen)) return false;
if (enforced)
{
if (ConditionOf(e) is not { } condition) return false;
@@ -6981,7 +7010,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
DirectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
// #855 review: re-decided per entry right before queueing — a deploy may have revoked since the verdicts.
QueueFrozenReplay(items, states, perItem, generation, (item, e) => IsDirectRefreshEventAllowed(item, e, enforced));
QueueFrozenReplay(
items, states, perItem, generation,
(item, e) => (ConditionOf(e) is not AlarmConditionState frozen || IsLiveCondition(frozen))
&& IsDirectRefreshEventAllowed(item, e, enforced));
}
finally
{