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
@@ -87,8 +87,13 @@ public sealed class GalaxyAckCredentialTests
logger.AllText.ShouldNotContain(KeyPassword);
}
/// <summary>
/// A non-zero native code (MXAccess rejected the ack — e.g. a wrong password) warns AND throws a
/// <see cref="DriverOperationRefusedException"/> (lmxopcua#760) so the host reverts the OPC UA condition
/// instead of leaving it acknowledged; neither the log nor the exception carries the password.
/// </summary>
[Fact]
public void EvaluateReply_native_failure_warns_without_password()
public void EvaluateReply_native_failure_warns_and_refuses_without_password()
{
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
var reply = new AcknowledgeAlarmReply
@@ -98,13 +103,34 @@ public sealed class GalaxyAckCredentialTests
};
var logger = new CapturingLogger();
GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
var ex = Should.Throw<DriverOperationRefusedException>(
() => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
ex.Message.ShouldContain("0x80070005");
ex.Message.ShouldContain("CORP\\alice");
ex.ToString().ShouldNotContain(KeyPassword);
logger.Entries.ShouldHaveSingleItem().Level.ShouldBe(LogLevel.Warning);
logger.AllText.ShouldContain("CORP\\alice");
logger.AllText.ShouldNotContain(KeyPassword);
}
/// <summary>A zero native code is success: nothing logged, nothing thrown.</summary>
[Fact]
public void EvaluateReply_success_is_silent()
{
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
var reply = new AcknowledgeAlarmReply
{
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
};
var logger = new CapturingLogger();
GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
logger.Entries.ShouldBeEmpty();
}
[Fact]
public async Task Acknowledger_transport_failure_exception_text_has_no_password()
{
@@ -359,6 +385,28 @@ public sealed class GalaxyAckCredentialTests
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Text.Contains("refused"));
}
/// <summary>
/// lmxopcua#760 — the synchronous pre-check refuses exactly the case decidable without I/O: an anonymous
/// session on a driver with no fallback account. A keyed session is let through (its identity lives in the
/// Secrets store), and a fallback account makes every session acceptable.
/// </summary>
[Fact]
public void Preflight_refuses_only_an_anonymous_session_without_a_fallback()
{
using var noFallback = NewDriver(Opts(), new CredentialRecordingAcknowledger(), new DictSecretResolver(), new CapturingLogger());
var refusal = noFallback.GetAcknowledgeRefusal(apiKeyId: null);
refusal.ShouldNotBeNull();
refusal.ShouldContain("FallbackUser");
refusal.ShouldContain("drv");
noFallback.GetAcknowledgeRefusal(apiKeyId: " ").ShouldNotBeNull("a blank key id is no key");
noFallback.GetAcknowledgeRefusal(KeyId).ShouldBeNull("a keyed session may have an identity in the Secrets store");
using var withFallback = NewDriver(
Opts() with { AlarmAck = WithFallback() }, new CredentialRecordingAcknowledger(), new DictSecretResolver(), new CapturingLogger());
withFallback.GetAcknowledgeRefusal(apiKeyId: null).ShouldBeNull();
withFallback.GetAcknowledgeRefusal(KeyId).ShouldBeNull();
}
[Fact]
public void Annotate_comment_forms()
{