diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs
index e163a02c..ca5691be 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs
@@ -107,4 +107,13 @@ public record AlarmStateChanged(
/// show the binding node as "no active conditions". Additive; default false.
///
public bool IsConfiguredPlaceholder { get; init; }
+
+ ///
+ /// True when the alarm's deployed configuration flags it as MES-reportable
+ /// (ResolvedAlarm.IsMesReportable). Stamped by the Instance Actor on its alarm
+ /// snapshot; always false for native conditions, which have no per-alarm config row.
+ /// NOT carried on the gRPC stream or the debug-snapshot DTO in this slice (no proto
+ /// field on either) — central sees the default. Additive; default false.
+ ///
+ public bool IsMesReportable { get; init; }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index ebc746c0..933bedac 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -51,6 +51,16 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary _alarmStates = new();
private readonly Dictionary _alarmTimestamps = new();
private readonly Dictionary _alarmPriorities = new();
+
+ ///
+ /// Computed-alarm canonical name → the deployed ResolvedAlarm.IsMesReportable
+ /// flag. Populated alongside when the Alarm Actor
+ /// children are created and read back in , which
+ /// is the single place the flag is stamped — so a fired alarm (served from the retained
+ /// event) and an unfired one (served from the bare fallback projection) cannot drift.
+ /// Native conditions have no entry here and correctly resolve to false.
+ ///
+ private readonly Dictionary _alarmMesReportable = new();
private readonly Dictionary _scriptActors = new();
private readonly Dictionary _alarmActors = new();
private readonly Dictionary _nativeAlarmActors = new();
@@ -1568,7 +1578,16 @@ public class InstanceActor : ReceiveActor
///
private List BuildAlarmStatesSnapshot()
{
- var states = _latestAlarmEvents.Values.ToList();
+ // The MES-reportable flag is stamped HERE and nowhere else (not at the Alarm
+ // Actor's emit sites), so the retained-event rows below and the fallback rows
+ // that follow always agree on it. A native condition has no _alarmMesReportable
+ // entry and is returned as-is (no record copy) — the default false is the intended
+ // result, and native rows dominate this list on a busy binding.
+ var states = _latestAlarmEvents.Values
+ .Select(e => _alarmMesReportable.TryGetValue(e.AlarmName, out var mes) && mes
+ ? e with { IsMesReportable = true }
+ : e)
+ .ToList();
foreach (var name in _alarmActors.Keys)
{
@@ -1582,7 +1601,10 @@ public class InstanceActor : ReceiveActor
name,
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
_alarmPriorities.GetValueOrDefault(name, 0),
- _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
+ _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))
+ {
+ IsMesReportable = _alarmMesReportable.GetValueOrDefault(name)
+ });
}
// A native binding is "already represented" if any retained event carries its
@@ -1980,6 +2002,7 @@ public class InstanceActor : ReceiveActor
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
_alarmActors[alarm.CanonicalName] = actorRef;
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
+ _alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable;
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs
new file mode 100644
index 00000000..45e7293e
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs
@@ -0,0 +1,163 @@
+using System.Text.Json;
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using ZB.MOM.WW.ScadaBridge.TestSupport;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
+
+///
+/// The deployed alarm's IsMesReportable flag must reach the site-local alarm
+/// snapshot that Alarms.CurrentAsync() and the Debug View query both build (the
+/// flag does not cross DebugAlarmStateDto to central in this slice). It is stamped in ONE
+/// place — BuildAlarmStatesSnapshot — so a computed alarm that has fired (served
+/// from the retained AlarmStateChanged) and one that never has (served from the
+/// bare Normal fallback) can never disagree about the flag.
+///
+public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
+{
+ private readonly SiteStorageService _storage;
+ private readonly ScriptCompilationService _compilationService;
+ private readonly SharedScriptLibrary _sharedScriptLibrary;
+ private readonly SiteRuntimeOptions _options = new();
+ private readonly TestLocalDb _localDb;
+
+ public InstanceActorMesReportableSnapshotTests()
+ {
+ _localDb = TestLocalDb.CreateTemp("instance-mes-reportable");
+ _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance);
+ _storage.InitializeAsync().GetAwaiter().GetResult();
+ _compilationService = new ScriptCompilationService(NullLogger.Instance);
+ _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance);
+ }
+
+ private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) =>
+ ActorOf(Props.Create(() => new InstanceActor(
+ instanceName,
+ JsonSerializer.Serialize(config),
+ _storage,
+ _compilationService,
+ _sharedScriptLibrary,
+ null,
+ _options,
+ NullLogger.Instance,
+ null)));
+
+ ///
+ /// One static attribute driving two computed alarms: HighTemp is flagged
+ /// MES-reportable and fires at 80; TempSpike is not flagged and only matches
+ /// the exact value 100, so it never fires in this test.
+ ///
+ private static FlattenedConfiguration TwoComputedAlarmsConfig(string instanceName) => new()
+ {
+ InstanceUniqueName = instanceName,
+ Attributes =
+ [
+ new ResolvedAttribute
+ {
+ CanonicalName = "Temperature",
+ Value = "20",
+ DataType = "Double"
+ }
+ ],
+ Alarms =
+ [
+ new ResolvedAlarm
+ {
+ CanonicalName = "HighTemp",
+ TriggerType = "HiLo",
+ TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"hi\":80}",
+ PriorityLevel = 700,
+ IsMesReportable = true
+ },
+ new ResolvedAlarm
+ {
+ CanonicalName = "TempSpike",
+ TriggerType = "ValueMatch",
+ TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}",
+ PriorityLevel = 300,
+ IsMesReportable = false
+ }
+ ]
+ };
+
+ private GetAlarmSnapshotResponse AskSnapshot(IActorRef actor, string instanceName)
+ {
+ var correlationId = Guid.NewGuid().ToString();
+ actor.Tell(new GetAlarmSnapshotRequest(correlationId, instanceName, DateTimeOffset.UtcNow));
+ // Match on the correlation id so a late reply from a timed-out probe can never be
+ // consumed as the "current" snapshot by a later call; keep the inner wait well
+ // inside the AwaitAssert budget so one slow probe doesn't eat the whole retry window.
+ return ExpectMsg(
+ m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms()
+ {
+ const string instance = "Oven1";
+ var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance));
+
+ // Drive Temperature above the Hi setpoint: HighTemp fires, TempSpike does not.
+ actor.Tell(new SetStaticAttributeCommand(
+ "corr-temp", instance, "Temperature", "90", DateTimeOffset.UtcNow));
+ var write = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.True(write.Success);
+
+ // The alarm child evaluates asynchronously — poll the snapshot until the
+ // retained event for HighTemp has landed.
+ AwaitAssert(() =>
+ {
+ var pending = AskSnapshot(actor, instance);
+ var firing = Assert.Single(pending.Alarms, a => a.AlarmName == "HighTemp");
+ Assert.Equal(AlarmState.Active, firing.State);
+ }, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
+
+ var snapshot = AskSnapshot(actor, instance);
+
+ // Both rows are still returned — stamping must not drop or duplicate anything.
+ Assert.Equal(2, snapshot.Alarms.Count);
+
+ // Fired alarm: served from the retained AlarmStateChanged.
+ var fired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp");
+ Assert.Equal(AlarmState.Active, fired.State);
+ Assert.True(fired.IsMesReportable);
+
+ // Never-fired alarm: served from the bare Normal fallback projection.
+ var unfired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike");
+ Assert.Equal(AlarmState.Normal, unfired.State);
+ Assert.False(unfired.IsMesReportable);
+ }
+
+ [Fact]
+ public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires()
+ {
+ // The fallback branch must carry the flag on its own: an alarm that has never
+ // emitted an event is still MES-reportable per its deployed configuration.
+ const string instance = "Oven2";
+ var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance));
+
+ var snapshot = AskSnapshot(actor, instance);
+
+ Assert.Equal(2, snapshot.Alarms.Count);
+ Assert.True(Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp").IsMesReportable);
+ Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable);
+ }
+
+ void IDisposable.Dispose()
+ {
+ // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
+ // then dispose the database before deleting — the master connection anchors the WAL.
+ Shutdown();
+ var path = _localDb.Path;
+ _localDb.Dispose();
+ TestLocalDb.DeleteFiles(path);
+ }
+}