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
6.8 KiB
paths
| paths | |||||
|---|---|---|---|---|---|
|
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. RootCLAUDE.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.