fix(alarms): surface a refused native alarm ack synchronously, revert one that fails later (lmxopcua#760)

The native-ack path is fire-and-forget after the Part 9 Acknowledge returns, so
a Galaxy ack refused for lack of a credential (#749), or rejected by MXAccess,
left the client with Good and the condition showing Acknowledged while Galaxy
was never acked. Both options from the issue, layered:

1. Synchronous refusal for what the driver can decide without I/O. New optional
   IAlarmAcknowledgePreflight (Core.Abstractions); GalaxyDriver refuses an
   anonymous session when the driver has no AlarmAck fallback account.
   DriverHostActor publishes a condition NodeId -> owning-driver snapshot into
   NativeAlarmAckPreflightView (lock-free, volatile); the node manager's new
   NativeAlarmAckPreflight seam, consulted after the authz and Primary gates,
   returns BadUserAccessDenied with the reason - nothing routed, applied or
   reported. A keyed session is not pre-checked (its identity lives in the
   Secrets store, which must not be read under the SDK lock).

2. Revert for everything else. RouteAlarmAck carries the condition NodeId;
   DriverInstanceActor replies AlarmAckFailed on a refusal, a backend failure,
   a driver without an alarm source, or an ack dropped while connecting /
   reconnecting. DriverHostActor re-projects the condition unacknowledged via
   NativeAlarmProjector.TryRevertAcknowledge, but only while the local ack is
   still pending - any driver transition projected since then wins. A disabled
   or unspawned driver is not reverted (unchanged).

GatewayGalaxyAlarmAcknowledger now throws DriverOperationRefusedException on a
non-zero native hresult (wrong password, no ack permission) instead of only
warning, so that case reverts too; as a refusal it neither retries nor trips
the breaker.

Tests (each fails with its fix mutated out): projector revert/supersede,
DriverHostActor refused + failed ack -> unacked AlarmStateUpdate (successful
ack control), preflight view snapshot, node-manager preflight unit test,
over-the-wire ConditionMethodEventTests (BadUserAccessDenied, no route, no
event; positive control), Galaxy preflight + hresult refusal.
Docs: Galaxy.md, AlarmTracking.md, ScriptedAlarms.md, .claude/rules/alarms.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:12:17 -05:00
parent 0217d36cf4
commit 8e8d4cdf13
21 changed files with 737 additions and 61 deletions
+17 -8
View File
@@ -457,12 +457,20 @@ from the scripted `AlarmCommandRouter`:
authenticated `OperatorUser`. The driver forwards this to the Galaxy
gateway → AVEVA via `GatewayGalaxyAlarmAcknowledger`.
The Galaxy adapter awaits the correlated `AcknowledgeAlarmAsync` RPC:
gateway protocol failures throw, while a non-zero native return code is
warning-logged. **The actor route back to OPC UA is fire-and-forget** —
`DriverInstanceActor` logs and drops failures without replying to the
OPC UA client, whose local `AlarmConditionState` SDK update already
committed at step 1. This is a native-ack routing limitation; Galaxy
value writes have a separate correlated write-outcome path. An awaited
gateway protocol failures throw, and a non-zero native return code is
warning-logged and thrown as a refusal (#760). **A failed ack is reverted,
not surfaced on the call** — the OPC UA client's local
`AlarmConditionState` update already committed at step 1, so when the
driver refuses or fails the ack (or is still connecting / reconnecting),
`DriverInstanceActor` replies `AlarmAckFailed` and
`DriverHostActor` re-projects the condition **unacknowledged** (a fresh
condition event). Only an ack still pending in `NativeAlarmProjector` is
reverted: a driver transition projected since then is authoritative. A
refusal the driver can decide without I/O (Galaxy: an anonymous session
and no fallback ack account) is instead returned synchronously as
`BadUserAccessDenied` by the node manager's `NativeAlarmAckPreflight`
before anything is applied. Galaxy value writes have a separate correlated
write-outcome path. An awaited
ack RPC does not prove the ack reached AVEVA event history: the H6 live
verification remains [operator-gated](plans/2026-06-15-stillpending-phase-3-opcua-standards-design.md).
@@ -619,8 +627,9 @@ reports nothing.
commented re-ack from the AdminUI `/alerts` page.
- **A native re-ack is still routed to the driver** (deliberate, #597). The
route is fire-and-forget, so a node showing Acked does not prove the
upstream system acknowledged — an ack dropped during a reconnect leaves
exactly that state — and re-acking is the documented retry (§"Best-effort
upstream system acknowledged — an ack the driver reports as failed is
reverted (#760), but one lost without a report (e.g. a node stopped mid-ack)
is not — and re-acking is the documented retry (§"Best-effort
during reconnect"). The cost is a duplicate ack upstream for an alarm that
was already acknowledged there.
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
+3 -2
View File
@@ -325,8 +325,9 @@ routes through a separate `NativeAlarmAckRouter` seam (instead of the scripted
FullName)` inverse map, Primary-gated) → the owning driver's
`IAlarmSource.AcknowledgeAsync` → Galaxy gateway → AVEVA. The call carries the
authenticated operator's display name via the `AlarmAcknowledgeRequest.OperatorUser`
field. The actor route back to OPC UA is fire-and-forget — a failed upstream ack
is logged rather than surfaced back to the OPC UA client. The Galaxy adapter
field. The actor route back to OPC UA is fire-and-forget; a failed or refused upstream
ack reverts the condition to unacknowledged (#760), and one the driver can refuse
without I/O fails the call synchronously with `BadUserAccessDenied`. The Galaxy adapter
awaits its correlated RPC; this does not establish delivery to AVEVA event history.
Galaxy value writes use a separate correlated write-outcome path. See [AlarmTracking.md
§Native alarm acknowledge → AVEVA](AlarmTracking.md#native-alarm-acknowledge--aveva)
+11 -3
View File
@@ -107,9 +107,17 @@ Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowle
`file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig.
3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the
gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway
health). ⚠️ The native ack path is fire-and-forget after the Part 9 method returns, so the OPC UA
call itself still returns `Good` and the refusal surfaces as a `DriverInstance` warning naming the
fix — a synchronous status / revert is #760.
health). How the client learns of it (#760):
- **Synchronously** when the driver can tell without I/O — an *anonymous* session on a driver with
no fallback account: the Part 9 `Acknowledge` call itself fails `BadUserAccessDenied` with the
reason, and nothing is applied (`IAlarmAcknowledgePreflight`, published per condition by
`DriverHostActor` into `NativeAlarmAckPreflightView`).
- **By revert** otherwise — a keyed session whose key has no identity (that lives in the Secrets
store, which is not read under the SDK lock), or MXAccess rejecting the account (a non-zero
native `hresult`, e.g. a wrong password, now thrown as a refusal): the call returned `Good`, then
`DriverHostActor` re-projects the condition **unacknowledged** (a fresh condition event,
`Retain=true`) and logs the reason. A driver transition projected after the ack wins over a late
failure.
The comment written to `AckMsg` is suffixed with the OPC UA identity —
`"<comment> [OPC UA: <principal>, key <keyId>]"` or `"[OPC UA: anonymous]"` — so the client is not