Files
lmxopcua/.claude/rules/alarms.md
T
Joseph Doherty 0702b31029
v2-ci / unit-tests (push) Waiting to run
fix(alarms): refuse a keyed native ack with no Galaxy identity synchronously, from a cached key set (lmxopcua#765)
Item 3 of #765. A keyed (API-key) session whose key had no Galaxy ack identity
got Good and then a revert, because whether the key has an identity lives in
the Secrets store, which must not be read under the SDK lock.

GalaxyAckIdentityDirectory (Driver.Galaxy) keeps a node-wide set of the key ids
that have a galaxy-ack-user secret. It lists this node's Secrets store
(metadata only, nothing decrypted) and is read lock-free.
GalaxyDriver.GetAcknowledgeRefusal (IAlarmAcknowledgePreflight, published
through NativeAlarmAckPreflightView as in #760) now refuses a keyed session
whose key the directory reports Absent, when the driver has no fallback ack
account. A refusal also asks for a refresh.

Fails open: before the first refresh, once the snapshot is older than
StaleAfter, and from a signalled identity-secret change until the next
refresh, the lookup answers Unknown and the ack takes the old Good-then-revert
path.

Refresh: GalaxyAckIdentityRefreshService runs on driver nodes, every
Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds (default 30;
StaleAfterSeconds 0 = three intervals; validated at start). It also refreshes
early on secret changes. The Secrets library has no change event, so
SecretsRegistration decorates ISecretCacheInvalidator, which is called by
AdminUI /api-keys, /admin/secrets and the replication appliers. API-key
changes need no event: a revoked key is already denied before the pre-check.

Tests: Galaxy.Tests GalaxyAckIdentityDirectoryTests (16) and Host.Tests
GalaxyAckIdentityCacheWiringTests (9). Seven separate mutations each turn at
least one test red: pre-check ignores the directory, factory drops it, no
invalidator decoration, no staleness check, no change-pending check, no
refresh on refusal, loop never wakes early.
Docs: Galaxy.md, AlarmTracking.md, Configuration.md, .claude/rules/alarms.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 16:38:57 -05:00

6.8 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, no fallback ack account: an anonymous session, or a keyed session whose key GalaxyAckIdentityDirectory reports Absent, #765) 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; that is why the keyed case reads a cached, periodically refreshed key set (Drivers:Galaxy:AckIdentityCache), which must answer Unknown (= let it through) whenever it is not certain — no refresh yet, stale, or an identity-secret change signalled through the decorated ISecretCacheInvalidator and not yet re-listed. Only an identity written straight into the store by the secret CLI (no in-process signal) can be refused until the next refresh, which the refusal itself requests. (2) anything else (a keyed session the cache cannot vouch for, 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.