fix(mtconnect): review rulings for CONDITION alarms (#626)

- Unavailable keeps an ACTIVE condition active: quality-only BadNoCommunication; on data
  return same statement => quality-only Good, Normal => Clear, different => Retrigger.
- A changed alarm reference set (alarm-bearing delta = full rebuild) clears the whole
  last-told record so every active condition is re-asserted; the same set keeps it.
- Quality-only events go through the host's AlarmQualityUpdate path, never a projected
  snapshot (which could ack a retained unacked condition or rewrite message/severity).
- Deterministic tie-break between equal-level conditions (ordinal nativeCode, then type).
- Dedup key is level+nativeCode+nativeSeverity+qualifier; a text-only change updates nothing.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 15:38:52 -04:00
parent 6a5f5c5a28
commit 9c8e3da5da
7 changed files with 382 additions and 112 deletions
@@ -273,7 +273,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
/// <summary>Gitea #626 — a QUALITY-ONLY native event (an <see cref="AlarmTransitionKind.Unspecified"/> kind
/// carrying <see cref="AlarmEventArgs.QualityStatusCode"/>, e.g. an MTConnect condition gone Unavailable while
/// already Normal) re-annotates the condition's Quality through the ordinary state update, and publishes NO
/// already Normal) re-annotates the condition's Quality through the quality-only node path
/// (<see cref="OpcUaPublishActor.AlarmQualityUpdate"/> — NOT a projected snapshot), and publishes NO
/// <c>alerts</c> row — it is not an alarm transition. The positive control is the Raise that follows: it
/// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.</summary>
[Fact]
@@ -298,10 +299,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
Kind: AlarmTransitionKind.Unspecified,
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
annotated.State.Active.ShouldBeFalse();
annotated.State.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
annotated.Realm.ShouldBe(AddressSpaceRealm.Raw);
annotated.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Positive control: a real transition after it DOES publish, and is the first thing on the topic.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
@@ -327,6 +328,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
evt.Severity.ShouldBe(800);
}
/// <summary>Gitea #626 (review MINOR 1) — after a deploy (which clears the projector's prior state), a
/// quality-only event on a raised, UNACKNOWLEDGED condition must not reach the node as a snapshot: a projected
/// "keep the state" would default to inactive+acked, silently acknowledging it and rewriting its message and
/// severity. Only an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/> (which writes Quality alone) may be
/// sent. Proven by order: the next message after it is the positive control's state update.</summary>
[Fact]
public void Quality_only_event_never_sends_a_state_snapshot_that_could_ack_or_rewrite()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
// Whatever the projector's prior state is (any apply clears it), a quality-only event must never be
// projected — the routing below is the whole guard, so no deploy needs staging here.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: 800,
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
// Positive control.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "control", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Retrigger, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
var next = publish.FishForMessage(
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
TimeSpan.FromSeconds(5));
next.ShouldBeOfType<OpcUaPublishActor.AlarmQualityUpdate>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
var control = publish.FishForMessage(
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
TimeSpan.FromSeconds(5));
control.ShouldBeOfType<OpcUaPublishActor.AlarmStateUpdate>().State.Message.ShouldBe("control",
"the quality-only event must produce no state snapshot ahead of the control");
}
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this