Files
lmxopcua/.claude/rules/alarms.md
T
Joseph Doherty f54922f94c fix(alarms): revert a native ack that reaches no driver; refuse native acks while the host is Stale (lmxopcua#765)
Residuals of #760:

1. An ack routed to a disabled / not-yet-spawned driver, or to a stubbed child
   (driver does not run on this node, or its factory rejected the config), was
   dropped and left the condition Acknowledged. DriverHostActor now reverts it
   through the same NativeAlarmProjector.TryRevertAcknowledge path as a refused
   ack.

2. A native ack arriving while the host is Stale was dropped before the projector
   recorded it, so it could never be reverted. Stale now marks the whole
   NativeAlarmAckPreflightView unavailable (SetHostUnavailable), so the node
   manager refuses every native ack synchronously; BecomeSteady clears it.

3. (keyed sessions without a Galaxy identity) is not addressed.

Tests: DriverHostActorNativeAlarmTests' client-ack test and the FOCAS respawn
theory now start the host's own driver (AckingAlarmDriver; a factory building a
real FocasDriver per spawn over per-instance fake CNCs, with a real config-change
respawn) - both fail against the fix in their old shape. New: ack to a disabled
driver reverts, ack to a stubbed driver reverts, a Stale host refuses through the
pre-check view - all three fail with the fix mutated out. Runtime.Tests 943/0/3.
Docs: AlarmTracking.md, .claude/rules/alarms.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:03:59 -05:00

6.2 KiB

paths
paths
src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/**
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/**
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**
docs/ScriptedAlarms.md
docs/AlarmTracking.md

Scripted alarm ack/shelve and Part 9 condition method behaviour

Moved verbatim from the root CLAUDE.md (2026-10-13) so it loads only when working in this area. Root CLAUDE.md § "Subsystem guides" indexes it. Edit it here, not in the root file.

Scripted Alarm Ack/Shelve

Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the alarm-commands DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in OtOpcUaNodeManager, gated on the AlarmAck role — conferred by the session's API-key principal's NodeAcl grants (or the anonymous principal's), carried by RoleCarryingUserIdentity; LDAP is AdminUI-only; (2) AdminUI /alerts per-row buttons routed through the AdminOperationsActor singleton (gated by DriverOperator policy). ScriptedAlarmHostActor dispatches from the topic to the engine. Client.CLI supports ack, confirm, shelve commands. See docs/ScriptedAlarms.md §"Inbound operator ack/shelve" and docs/AlarmTracking.md.

⚠️ One condition event per accepted method call is OURS, not the SDK's (Gitea #587). The SDK's own report is gated on AutoReportStateChanges (left false), so before #587 a client Acknowledge emitted no condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. WireMethodTransitionReporting wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and, on Good, reports once via ReportConditionEvent — under Lock, so the engine's/driver's re-projection (routed by a fire-and-forget Tell before the SDK applies state) waits and then hits the delta-gate with no delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips); MaxTimeShelved is set to int.MaxValue ms at materialise because Create() left it 0 and every TimedShelve failed. ⚠️ Native alarms support Acknowledge and AddComment only — Confirm, the shelve methods and Enable/Disable return BadNotSupported (no upstream path, no pair replication, and NativeAlarmProjector's Confirmed/Unshelved constants own those fields); a native ack re-asserts ConfirmedState = true because the SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by ConditionMethodEventTests (exactly-once after a sentinel, folder + i=2253).

A native ack the driver refuses or fails is not left Acknowledged (#760). Two layers: (1) a refusal the driver can decide without I/O (IAlarmAcknowledgePreflight — Galaxy: anonymous session and no fallback ack account) fails the Part 9 call BadUserAccessDenied via NativeAlarmAckPreflight, read lock-free from the NativeAlarmAckPreflightView snapshot DriverHostActor publishes; never do I/O (e.g. a Secrets lookup) in a pre-check — it runs under the SDK Lock. (2) anything else (keyed session without an identity, MXAccess non-zero hresult, driver still connecting/reconnecting) comes back as DriverInstanceActor.AlarmAckFailed — or, for a disabled/unspawned/stubbed driver, is decided in the host itself (#765) — and DriverHostActor re-projects the condition unacknowledged — only while the ack is still pending in NativeAlarmProjector (any driver transition since then wins). A host that came up Stale cannot route any native ack, so it marks the whole NativeAlarmAckPreflightView unavailable and every native ack is refused synchronously until BecomeSteady (#765). A test that routes an ack and expects it to STAND must start the owning driver (AckingAlarmDriver in Runtime.Tests).

Behaviours that were wrong or missing and are now fixed — keep them: the SDK's OnTimedUnshelve is a no-op (the engine's AutoUnshelve owns timed-shelve expiry, and UnshelveTime reports the real remaining time from AlarmConditionSnapshot.UnshelveAtUtc); a commented re-ack is stored as an AddComment (durable, replicated) and reports one event, an uncommented re-ack stays silent; a native ack is refused BadInvalidState off-Primary via NativeAlarmAckPrimaryGate (fed by IDriverPrimaryRoleSource; DriverHostActor publishes its Primary verdict at host start and on member changes, so the boot window before the first redundancy snapshot is closed — the same fix covers the MQTT Sparkplug primary-host claim). A native re-ack is still re-sent to the driver (the retry path, by design).

Condition Severity is carried verbatim, clamped to 1..1000 — ToPart9Severity; the node manager used to re-bucket onto the five EventSeverity points (so an MTConnect Fault read 900, scripted Critical 1000 read 900, native Low 200 read 300). ScadaBridge only clamps, so exact values pass through. ⚠️ Never select a two-state id with AddSelectClause(type, "X/Id") — the string becomes ONE QualifiedName named X/Id, which no event carries; the Client.CLI/Client.UI filter did this since a2883b82 and every alarm read inactive+unacked. Use new SimpleAttributeOperand(type, [X, Id]) (ScadaBridge already does).

Condition children are instance-scoped (#629). Every child of a materialised condition carries {ConditionId}/{browse path} in the condition's namespace (e.g. …/temp_hi/ShelvingState/UnshelveTime), assigned by the OtOpcUaNodeManager.New override — the SDK default kept each child on the TYPE's ns=0 id, so a direct Read hit the type (or BadNodeIdUnknown), translate failed, and children sat outside the ACL (the slash join now puts them under their condition's scope). ⚠️ The shelve methods resolve by the AlarmConditionType_ShelvingState_* ids only because New adopts those (a fixed set) as their MethodDeclarationId; calls by the ShelvedStateMachineType_* id are no longer supported (pinned by a test). Condition teardown must forget the whole PredefinedNodes subtree (ForgetPredefinedSubtree) or a removed condition's children keep answering.