feat(alarms): thread an additive AckTime through the native-alarm mirror

MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.

Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.

Provenance is honest, never fabricated:
  - OPC UA A&C supplies a TRUE ack instant, so we now select it:
    AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
    index 18, APPENDED so the positional reads at 0-17 keep their meaning.
    Servers that omit the field fall back to the event's own Time.
  - MxAccess Gateway supplies none, so the ack transition's own timestamp is
    used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
    re-subscribe snapshot restores one from LastTransitionTimestamp rather
    than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.

Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.

Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. That table is RegisterReplicated in
SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at
registration time, so an additive JSON property changes no schema, no triggers
and no replication contract; metadata_json is exactly the extension point UA4
introduced for this. Rows written before the field deserialize it as null.

Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
This commit is contained in:
Joseph Doherty
2026-08-01 13:12:04 -04:00
parent 6dc5d94cb9
commit 01bcca992c
19 changed files with 775 additions and 113 deletions
@@ -25,4 +25,34 @@ public class AlarmStateChangedEnrichmentTests
Assert.True(c.Acknowledged);
Assert.Equal(250, c.Severity);
}
// ── MES alarm-status API §6.4: additive AckTime ──
[Fact]
public void AckTime_DefaultsToNull_OnThePositionalConstructor()
{
// Additive-only evolution: every existing positional construction stays valid and
// reports no ack time. A computed alarm is auto-acked but has no operator ack
// event, so null (not the timestamp) is the honest value.
var m = new AlarmStateChanged("inst", "HiAlarm", AlarmState.Active, 700, DateTimeOffset.UnixEpoch);
Assert.True(m.Condition.Acknowledged); // computed = auto-acked…
Assert.Null(m.AckTime); // …yet still no ack instant
}
[Fact]
public void AckTime_RoundTripsThroughTheInitProperty()
{
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
var m = new AlarmStateChanged("inst", "Tank01.Level.HiHi", AlarmState.Active, 900, DateTimeOffset.UnixEpoch)
{
Kind = AlarmKind.NativeOpcUa,
AckTime = ackedAt
};
Assert.Equal(ackedAt, m.AckTime);
// `with` (used by the mirror to synthesise a return-to-normal) preserves it.
Assert.Equal(ackedAt, (m with { State = AlarmState.Normal }).AckTime);
}
}
@@ -247,6 +247,44 @@ public class CompatibilityTests
Assert.Equal("HighTemp", deserialized.AlarmName);
}
[Fact]
public void RoundTrip_AlarmStateChanged_PreservesAckTime()
{
// MES alarm-status API §6.4: the additive AckTime must survive the wire, or the
// central live view would show every mirrored alarm as never acknowledged.
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
var msg = new AlarmStateChanged("inst-1", "Tank01.Level.HiHi", AlarmState.Active, 900, DateTimeOffset.UtcNow)
{
AckTime = ackedAt
};
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(
JsonSerializer.Serialize(msg), Options);
Assert.Equal(ackedAt, deserialized!.AckTime);
}
[Fact]
public void BackwardCompat_AlarmStateChanged_MissingAckTime_DefaultsToNull()
{
// A frame minted by a node predating the AckTime enrichment omits the property
// entirely; it must deserialize to "no ack time", not fail.
var json = """
{
"InstanceUniqueName": "inst-1",
"AlarmName": "Tank01.Level.HiHi",
"State": 1,
"Priority": 900,
"Timestamp": "2026-08-01T00:00:00+00:00"
}
""";
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(json, Options);
Assert.NotNull(deserialized);
Assert.Null(deserialized!.AckTime);
}
[Fact]
public void RoundTrip_HeartbeatMessage_Succeeds()
{
@@ -24,4 +24,22 @@ public class NativeAlarmMessagesTests
Assert.Equal("PlantOpcUa", u.ConnectionName);
Assert.Equal("Tank01", u.Transition.SourceObjectReference);
}
[Fact]
public void NativeAlarmTransition_AckTime_IsAnAdditiveTrailingParameter()
{
// MES alarm-status API §6.4. The 14-argument positional form (every pre-existing
// call site) must still compile and report no ack time; the 15th argument is the
// only way to set one.
var withoutAck = new NativeAlarmTransition("Tank01.Hi", "Tank01", "x", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
"", "", "", "", "", null, DateTimeOffset.UnixEpoch, "", "");
Assert.Null(withoutAck.AckTime);
var ackedAt = DateTimeOffset.UnixEpoch.AddMinutes(5);
var withAck = new NativeAlarmTransition("Tank01.Hi", "Tank01", "x", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 500),
"", "", "", "", "", null, DateTimeOffset.UnixEpoch, "", "", ackedAt);
Assert.Equal(ackedAt, withAck.AckTime);
}
}
@@ -159,6 +159,69 @@ public class StreamRelayActorTests : TestKit
Assert.False(roundTripped.IsConfiguredPlaceholder);
}
[Fact]
public void RelaysAlarmStateChanged_AckTime_SurvivesFullRoundTrip()
{
// MES alarm-status API §6.4: AlarmStateUpdate field 24. An acknowledged mirrored
// condition must reach central with its ack instant intact, and an unacknowledged
// one must arrive as null — an absent proto Timestamp, not the epoch.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-acktime", channel.Writer)));
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
var acked = new AlarmStateChanged(
"Site1.Motor01", "Motor1.MotorAlarms.Hi", AlarmState.Active, 900,
new DateTimeOffset(2026, 8, 1, 12, 5, 0, TimeSpan.Zero))
{
Kind = AlarmKind.NativeOpcUa,
SourceReference = "Motor1.MotorAlarms.Hi",
NativeSourceCanonicalName = "Motor1.MotorAlarms",
AckTime = ackedAt,
Condition = new AlarmConditionState(
Active: true, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
};
actor.Tell(acked);
var ackedProto = ReadProtoEvent(channel);
Assert.Equal(Timestamp.FromDateTimeOffset(ackedAt), ackedProto.AlarmChanged.AckTime);
Assert.Equal(
ackedAt,
Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(ackedProto)).AckTime);
actor.Tell(acked with
{
AckTime = null,
Condition = new AlarmConditionState(
Active: true, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
});
var unackedProto = ReadProtoEvent(channel);
Assert.Null(unackedProto.AlarmChanged.AckTime);
Assert.Null(
Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(unackedProto)).AckTime);
}
/// <summary>
/// Reads the next relayed proto event, retrying once after a short pause because the
/// relay actor writes to the channel asynchronously (mirrors the existing round-trip
/// tests' read pattern).
/// </summary>
private static SiteStreamEvent ReadProtoEvent(Channel<SiteStreamEvent> channel)
{
if (!channel.Reader.TryRead(out var protoEvent))
{
Thread.Sleep(500);
Assert.True(channel.Reader.TryRead(out protoEvent), "Expected a proto event on the channel");
}
Assert.NotNull(protoEvent);
return protoEvent!;
}
[Fact]
public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder()
{
@@ -76,15 +76,29 @@ public class RealOpcUaClientAlarmFilterTests
// ── SelectClause index alignment (M2.13 / #27) ───────────────────────────
// CRITICAL: HandleAlarmEvent reads fields[N] by position. Verify new clauses
// are APPENDED at indices 1317 so existing mappings (012) are undisturbed.
// are APPENDED at indices 1318 so existing mappings (012) are undisturbed.
[Fact]
public void BuildAlarmEventFilter_HasExactly18SelectClauses()
public void BuildAlarmEventFilter_HasExactly19SelectClauses()
{
// Baseline: 6 base fields + 7 A&C sub-state fields + 5 new appended fields = 18.
// Baseline: 6 base fields + 7 A&C sub-state fields + 5 appended fields (1317)
// + AckedState/TransitionTime at 18 (MES alarm-status API §6.4) = 19.
// If this count changes, review HandleAlarmEvent index mappings immediately.
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
Assert.Equal(18, filter.SelectClauses.Count);
Assert.Equal(19, filter.SelectClauses.Count);
}
[Fact]
public void BuildAlarmEventFilter_Index18_IsAcknowledgeableConditionType_AckedState_TransitionTime()
{
// MES alarm-status API §6.4: index 18 must be AckedState/TransitionTime → AckTime.
// APPENDED after the limit fields, so indices 017 keep their existing meaning.
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
var clause = filter.SelectClauses[18];
Assert.Equal(ObjectTypeIds.AcknowledgeableConditionType, clause.TypeDefinitionId);
Assert.Equal(2, clause.BrowsePath.Count);
Assert.Equal("AckedState", clause.BrowsePath[0].Name);
Assert.Equal("TransitionTime", clause.BrowsePath[1].Name);
}
[Fact]
@@ -1,3 +1,4 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
@@ -199,4 +200,122 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("FAULT", t.CurrentValue);
Assert.Equal("", t.LimitValue); // not set
}
// ── MES alarm-status API §6.4: AckTime stamping ──
private static readonly DateTimeOffset GatewayTime =
new(2026, 8, 1, 9, 30, 0, TimeSpan.Zero);
[Fact]
public void MapTransition_Acknowledge_StampsTheObservedTransitionTimeAsAckTime()
{
// The gateway feed carries NO dedicated ack timestamp, so the ack transition's own
// time is what gets stamped — accurate to when the system saw the ack.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Acknowledge,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal(GatewayTime, t.AckTime);
Assert.Equal(t.TransitionTime, t.AckTime);
}
[Fact]
public void MapTransition_Raise_HasNoAckTime()
{
// Null while unacknowledged.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
[Fact]
public void MapTransition_Retrigger_ClearsTheAckTime()
{
// "Cleared on re-raise": a Retrigger arrives unacknowledged, so no ack time is
// reported even though the condition was acked a moment earlier.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Retrigger,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
[Fact]
public void MapTransition_Clear_HasNoAckTime_DespiteInactiveMappingToAcked()
{
// The gateway maps INACTIVE to acked = true. Without the active check every
// return-to-normal would claim an ack the system never observed.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Clear,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.True(t.Condition.Acknowledged); // the gateway's own mapping
Assert.Null(t.AckTime); // …but no ack was observed
}
[Fact]
public void MapSnapshot_ActiveAcked_RestoresAnAckTimeFromTheLastTransition()
{
// A (re)subscribe snapshot must not silently drop the ack instant of an already
// acknowledged alarm — the last transition time is the best the feed supplies.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
CurrentState = ProtoConditionState.ActiveAcked,
Severity = 600,
LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Equal(GatewayTime, MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
[Fact]
public void MapSnapshot_ActiveUnacked_HasNoAckTime()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
CurrentState = ProtoConditionState.Active,
Severity = 600,
LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
[Fact]
public void SnapshotComplete_Sentinel_CarriesNoAckTime()
{
// The end-of-snapshot sentinel has no condition payload at all.
Assert.Null(MxGatewayAlarmMapper.SnapshotComplete().AckTime);
}
}
@@ -176,4 +176,52 @@ public class OpcUaAlarmMapperTests
Assert.Contains('.', result); // invariant culture: '.' not ','
Assert.Equal("1.5", result);
}
// ── MES alarm-status API §6.4: AckTime stamping ──
private static readonly DateTimeOffset SourceAck =
new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset Observed =
new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero);
[Fact]
public void DeriveAckTime_ActiveAcked_PrefersTheSourcesOwnAckInstant()
{
// OPC UA A&C DOES supply a true ack time (AckedState/TransitionTime). When the
// server sends it, it must win over the coarser observation time — that is the
// whole point of selecting field 18.
Assert.Equal(
SourceAck,
OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, SourceAck, Observed));
}
[Fact]
public void DeriveAckTime_ActiveAcked_WithoutSourceTime_FallsBackToObservationTime()
{
// Servers may omit AckedState/TransitionTime (base ConditionType events, or a
// server that does not expose it). The fallback is the event's own time — real,
// just coarser. Never fabricated, never null-when-acked.
Assert.Equal(
Observed,
OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, sourceAckTime: null, Observed));
}
[Fact]
public void DeriveAckTime_Unacked_IsNull_EvenWhenSourceReportsAnAckTransition()
{
// AckedState/TransitionTime also stamps the flip BACK to unacked on a re-raise,
// so it is present-but-meaningless there. The acked check is what makes "null
// while unacked" and "cleared on re-raise" hold.
Assert.Null(OpcUaAlarmMapper.DeriveAckTime(
active: true, acked: false, sourceAckTime: SourceAck, Observed));
}
[Fact]
public void DeriveAckTime_Inactive_IsNull()
{
// A cleared condition is on its way out of the mirror and reports no ack time,
// matching the MxGateway mapper (where INACTIVE maps to acked = true).
Assert.Null(OpcUaAlarmMapper.DeriveAckTime(
active: false, acked: true, sourceAckTime: SourceAck, Observed));
}
}
@@ -455,6 +455,99 @@ public class NativeAlarmActorTests : TestKit, IDisposable
TestLocalDb.DeleteFiles(path);
}
// ── MES alarm-status API §6.4: AckTime through the mirror ──────────────
[Fact]
public void Emit_CarriesTheAdaptersAckTimeVerbatim()
{
// The DCL adapter already decided whether an ack instant applies; the mirror must
// neither invent one for an unacked raise nor drop the one on an ack transition.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var raisedAt = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), raisedAt)));
Assert.Null(instance.ExpectMsg<AlarmStateChanged>().AckTime);
var ackedAt = raisedAt.AddMinutes(2);
actor.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"T01.Hi", "T01", "AnalogLimit.Hi", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800),
"Process", "hi", "hi", "op1", "ack", null, ackedAt, "92", "90", AckTime: ackedAt)));
var acked = instance.ExpectMsg<AlarmStateChanged>();
Assert.Equal(ackedAt, acked.AckTime);
Assert.True(acked.Condition.Acknowledged);
}
[Fact]
public async Task Rehydration_RestoresAckTime_SoItSurvivesFailover()
{
// A failover must not make an acknowledged alarm look freshly unacknowledged: the
// ack instant rides metadata_json (see NativeAlarmMetadata) and comes back on the
// rehydration emit of a fresh actor over the SAME site database.
var instance1 = CreateTestProbe();
var dcl1 = CreateTestProbe();
var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100));
dcl1.ExpectMsg<SubscribeAlarmsRequest>();
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"ref-ack", "T01", "HighLevelAlarm", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800),
"Process", "desc", "Tank overflow", "op1", "ack", null, DateTimeOffset.UtcNow, "92", "90",
AckTime: ackedAt)));
instance1.ExpectMsg<AlarmStateChanged>(m => m.AckTime == ackedAt);
await AwaitAssertAsync(async () =>
{
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("AckTime"));
}, TimeSpan.FromSeconds(3));
var instance2 = CreateTestProbe();
var dcl2 = CreateTestProbe();
SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100));
var emitted = instance2.FishForMessage<AlarmStateChanged>(
m => m.SourceReference == "ref-ack", TimeSpan.FromSeconds(5));
Assert.Equal(ackedAt, emitted.AckTime);
}
[Fact]
public async Task Rehydration_OfAPreAckTimeRow_LeavesAckTimeNull()
{
// Rows written before this change carry metadata_json without an AckTime property.
// System.Text.Json leaves the missing property at null — "ack time unknown" — rather
// than failing the rehydration and discarding the condition.
var legacyMetadata = JsonSerializer.Serialize(new
{
AlarmTypeName = "HighLevelAlarm",
Category = "Process",
Message = "Tank overflow",
CurrentValue = "92",
LimitValue = "90"
});
await _storage.UpsertNativeAlarmAsync(
"inst", Source().CanonicalName, "ref-legacy",
JsonSerializer.Serialize(
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800)),
DateTimeOffset.UtcNow, legacyMetadata);
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
Spawn(instance.Ref, dcl.Ref);
var emitted = instance.FishForMessage<AlarmStateChanged>(
m => m.SourceReference == "ref-legacy", TimeSpan.FromSeconds(5));
Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName); // metadata still restored…
Assert.Null(emitted.AckTime); // …with no ack time invented
}
[Fact]
public void LostSubscribeResponse_ResendsSubscribe()
{