diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
new file mode 100644
index 0000000..f961e9c
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Generic;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Worker.MxAccess;
+using Xunit;
+
+namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
+
+///
+/// Unit tests for : prove that the subtag
+/// fallback advises the observable subtags, synthesizes degraded
+/// transitions with stable synthetic GUIDs, routes acknowledgments to the
+/// ack-comment subtag, and stamps snapshots. A
+/// stands in for the live MXAccess subtag source so this needs no AVEVA
+/// install.
+///
+public sealed class SubtagAlarmConsumerTests
+{
+ private const string Reference = "Galaxy!TestArea.Tank01.Level.HiHi";
+ private const string ActiveSubtag = "Tank01.Level.HiHi.InAlarm";
+ private const string AckedSubtag = "Tank01.Level.HiHi.Acked";
+ private const string AckCommentSubtag = "Tank01.Level.HiHi.AckComment";
+ private const string PrioritySubtag = "Tank01.Level.HiHi.Priority";
+
+ private static AlarmSubtagTarget BuildTarget()
+ {
+ return new AlarmSubtagTarget
+ {
+ AlarmFullReference = Reference,
+ SourceObjectReference = "Tank01",
+ ActiveSubtag = ActiveSubtag,
+ AckedSubtag = AckedSubtag,
+ AckCommentSubtag = AckCommentSubtag,
+ PrioritySubtag = PrioritySubtag,
+ };
+ }
+
+ private static SubtagAlarmConsumer BuildConsumer(FakeSource source)
+ {
+ return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
+ }
+
+ /// Verifies Subscribe advises the active, acked, and priority subtags but not the ack-comment subtag.
+ [Fact]
+ public void Subscribe_AdvisesActiveAndAckedSubtags()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ Assert.Contains(ActiveSubtag, source.Advised);
+ Assert.Contains(AckedSubtag, source.Advised);
+ Assert.Contains(PrioritySubtag, source.Advised);
+ Assert.DoesNotContain(AckCommentSubtag, source.Advised);
+ }
+
+ /// Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.
+ [Fact]
+ public void ValueChange_RaisesDegradedSynthesizedTransition()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ MxAlarmTransitionEvent? emitted = null;
+ consumer.AlarmTransitionEmitted += (_, e) => emitted = e;
+
+ source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
+
+ Assert.NotNull(emitted);
+ Assert.Equal(MxAlarmStateKind.UnackAlm, emitted!.Record.State);
+ Assert.True(emitted.Record.Degraded);
+ Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
+ }
+
+ /// Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.
+ [Fact]
+ public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ int rc = consumer.AcknowledgeByName(
+ alarmName: "Tank01.Level.HiHi",
+ providerName: "Galaxy",
+ groupName: "TestArea",
+ ackComment: "operator ack",
+ ackOperatorName: "alice",
+ ackOperatorNode: "WS01",
+ ackOperatorDomain: "CORP",
+ ackOperatorFullName: "Alice Smith");
+
+ Assert.Equal(0, rc);
+ Assert.NotNull(source.LastWrite);
+ Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
+ Assert.Equal("operator ack", source.LastWrite!.Value.Value);
+ }
+
+ /// Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.
+ [Fact]
+ public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ int rc = consumer.AcknowledgeByName(
+ alarmName: "DoesNotExist.NoSuchAlarm",
+ providerName: "Galaxy",
+ groupName: "TestArea",
+ ackComment: "operator ack",
+ ackOperatorName: "alice",
+ ackOperatorNode: "WS01",
+ ackOperatorDomain: "CORP",
+ ackOperatorFullName: "Alice Smith");
+
+ Assert.NotEqual(0, rc);
+ Assert.Null(source.LastWrite);
+ }
+
+ /// Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.
+ [Fact]
+ public void SnapshotActiveAlarms_StampsDegradedAndGuid()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
+
+ IReadOnlyList snapshot = consumer.SnapshotActiveAlarms();
+
+ Assert.Single(snapshot);
+ Assert.True(snapshot[0].Degraded);
+ Assert.NotEqual(Guid.Empty, snapshot[0].AlarmGuid);
+ }
+
+ /// Verifies the synthetic GUID on the emitted transition equals the GUID in the snapshot for the same alarm.
+ [Fact]
+ public void SameReference_SyntheticGuidStableAcrossTransitionAndSnapshot()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ MxAlarmTransitionEvent? emitted = null;
+ consumer.AlarmTransitionEmitted += (_, e) => emitted = e;
+
+ source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
+
+ IReadOnlyList snapshot = consumer.SnapshotActiveAlarms();
+
+ Assert.NotNull(emitted);
+ Assert.Single(snapshot);
+ Assert.Equal(emitted!.Record.AlarmGuid, snapshot[0].AlarmGuid);
+ Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
+ }
+
+ private sealed class FakeSource : ISubtagAlarmSource
+ {
+ /// Raised when an advised subtag reports a new value.
+ public event EventHandler? ValueChanged;
+
+ /// Gets the subtag addresses passed to .
+ public List Advised { get; } = new List();
+
+ /// Gets the most recent (address, value) pair passed to .
+ public (string Address, object? Value)? LastWrite { get; private set; }
+
+ /// Records the advised subtag addresses.
+ /// The subtag references to advise.
+ public void Advise(IReadOnlyCollection itemAddresses)
+ {
+ Advised.AddRange(itemAddresses);
+ }
+
+ /// Records the most recent write.
+ /// The subtag reference to write.
+ /// The value to write.
+ public void Write(string itemAddress, object? value)
+ {
+ LastWrite = (itemAddress, value);
+ }
+
+ /// Raises a for the given subtag.
+ /// The subtag address whose value changed.
+ /// The new value.
+ /// The UTC timestamp of the change.
+ public void Raise(string address, object? value, DateTime timestampUtc)
+ {
+ ValueChanged?.Invoke(this, new SubtagValueChange
+ {
+ ItemAddress = address,
+ Value = value,
+ TimestampUtc = timestampUtc,
+ });
+ }
+
+ ///
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs
index 7a9bce9..8a71c9b 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs
@@ -191,7 +191,8 @@ public sealed class AlarmDispatcher : IDisposable
operatorUser: record.OperatorName,
operatorComment: record.AlarmComment,
category: record.Group,
- description: string.Empty);
+ description: string.Empty,
+ degraded: record.Degraded);
}
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record)
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
new file mode 100644
index 0000000..5b22518
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
@@ -0,0 +1,323 @@
+using System;
+using System.Collections.Generic;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+
+namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
+
+///
+/// Subtag-fallback implementation of .
+/// Where polls the native alarmmgr
+/// (wnwrap) COM stream, this consumer advises a configured set of MXAccess
+/// alarm subtags via an and synthesizes
+/// alarm transitions through a . Every
+/// emitted record is flagged
+/// and assigned a deterministic derived
+/// from the alarm's full reference, since the subtag path has no
+/// alarmmgr-supplied GUID.
+///
+///
+///
+/// Threading: like , this consumer is
+/// driven on the worker's STA thread. binds the
+/// source's ValueChanged event; the source raises that event on
+/// the same STA in production, so
+/// fires on the STA and subscribers must marshal off it themselves if
+/// they need another thread.
+///
+///
+/// The subtag path is event-driven, so is a
+/// no-op — value changes arrive through the source's advise stream
+/// rather than an explicit poll. Acknowledgment in subtag mode writes
+/// the comment to the target's writable ack-comment subtag rather than
+/// calling a native AlarmAckByGUID / AlarmAckByName.
+///
+///
+public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
+{
+ private readonly ISubtagAlarmSource source;
+ private readonly SubtagAlarmStateMachine stateMachine;
+ private readonly Dictionary targetsByReference;
+ private readonly Dictionary referencesBySyntheticGuid;
+ private readonly EventHandler valueChangedHandler;
+ private bool subscribed;
+ private bool disposed;
+
+ /// Fires once per synthesized alarm-state transition.
+ public event EventHandler? AlarmTransitionEmitted;
+
+ ///
+ /// Initializes the consumer over a subtag source and a watch list of
+ /// alarm targets.
+ ///
+ /// The subtag value source to advise and write through.
+ /// The alarm subtag targets to observe.
+ ///
+ /// Thrown when or
+ /// is .
+ ///
+ public SubtagAlarmConsumer(ISubtagAlarmSource source, IReadOnlyList watchList)
+ {
+ this.source = source ?? throw new ArgumentNullException(nameof(source));
+ if (watchList is null)
+ {
+ throw new ArgumentNullException(nameof(watchList));
+ }
+
+ this.stateMachine = new SubtagAlarmStateMachine(watchList);
+ this.targetsByReference = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ this.referencesBySyntheticGuid = new Dictionary();
+
+ foreach (AlarmSubtagTarget target in watchList)
+ {
+ string reference = target.AlarmFullReference ?? string.Empty;
+ this.targetsByReference[reference] = target;
+ this.referencesBySyntheticGuid[SyntheticAlarmGuid.ForReference(reference)] = reference;
+ }
+
+ this.valueChangedHandler = OnValueChanged;
+ }
+
+ ///
+ /// Advises every observable alarm subtag (active / acked / priority —
+ /// the ack-comment subtag is a write-only target and is not advised)
+ /// and begins listening for value changes. The
+ /// expression is ignored: the subtag set is fixed by the watch list.
+ ///
+ /// The subscription expression (unused in subtag mode).
+ public void Subscribe(string subscription)
+ {
+ if (disposed)
+ {
+ throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
+ }
+
+ List addresses = new List();
+ foreach (AlarmSubtagTarget target in targetsByReference.Values)
+ {
+ AddIfNotEmpty(addresses, target.ActiveSubtag);
+ AddIfNotEmpty(addresses, target.AckedSubtag);
+ AddIfNotEmpty(addresses, target.PrioritySubtag);
+ }
+
+ source.Advise(addresses);
+
+ if (!subscribed)
+ {
+ source.ValueChanged += valueChangedHandler;
+ subscribed = true;
+ }
+ }
+
+ ///
+ /// Acknowledges an alarm by its synthetic GUID. Resolves the GUID back
+ /// to its alarm full reference and delegates to the by-name write path.
+ ///
+ /// The synthetic alarm GUID.
+ /// The acknowledgment comment.
+ /// The operator name (unused in subtag mode).
+ /// The operator node (unused in subtag mode).
+ /// The operator domain (unused in subtag mode).
+ /// The operator full name (unused in subtag mode).
+ /// 0 on success; non-zero when the GUID or ack-comment subtag is unknown.
+ public int AcknowledgeByGuid(
+ Guid alarmGuid,
+ string ackComment,
+ string ackOperatorName,
+ string ackOperatorNode,
+ string ackOperatorDomain,
+ string ackOperatorFullName)
+ {
+ if (disposed)
+ {
+ throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
+ }
+
+ if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
+ !targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
+ {
+ return -1;
+ }
+
+ return WriteAckComment(target, ackComment);
+ }
+
+ ///
+ /// Acknowledges an alarm by its (name, provider, group) tuple. In subtag
+ /// mode the comment is written to the target's writable ack-comment
+ /// subtag; the operator-identity arguments are not surfaced through the
+ /// subtag write.
+ ///
+ /// The alarm name (object-rooted tag name or any reference fragment).
+ /// The provider name (unused for matching).
+ /// The group name (unused for matching).
+ /// The acknowledgment comment.
+ /// The operator name (unused in subtag mode).
+ /// The operator node (unused in subtag mode).
+ /// The operator domain (unused in subtag mode).
+ /// The operator full name (unused in subtag mode).
+ /// 0 on success; non-zero when no target matches or it lacks an ack-comment subtag.
+ public int AcknowledgeByName(
+ string alarmName,
+ string providerName,
+ string groupName,
+ string ackComment,
+ string ackOperatorName,
+ string ackOperatorNode,
+ string ackOperatorDomain,
+ string ackOperatorFullName)
+ {
+ if (disposed)
+ {
+ throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
+ }
+
+ AlarmSubtagTarget? target = ResolveTargetByName(alarmName);
+ if (target is null)
+ {
+ return -1;
+ }
+
+ return WriteAckComment(target, ackComment);
+ }
+
+ ///
+ /// Returns the state machine's currently-active alarm snapshot, with
+ /// each record stamped and
+ /// assigned its synthetic GUID.
+ ///
+ /// The active alarm snapshot records.
+ public IReadOnlyList SnapshotActiveAlarms()
+ {
+ IReadOnlyList records = stateMachine.SnapshotActive();
+ foreach (MxAlarmSnapshotRecord record in records)
+ {
+ StampSynthetic(record);
+ }
+
+ return records;
+ }
+
+ ///
+ /// No-op: the subtag path is event-driven and owns no poll cadence.
+ ///
+ public void PollOnce()
+ {
+ // Subtag mode is event-driven; value changes arrive via the source's
+ // advise stream, so there is nothing to poll.
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+
+ if (subscribed)
+ {
+ try
+ {
+ source.ValueChanged -= valueChangedHandler;
+ }
+ catch
+ {
+ // swallow — best-effort detach during dispose
+ }
+
+ subscribed = false;
+ }
+
+ try
+ {
+ source.Dispose();
+ }
+ catch
+ {
+ // swallow — best-effort source dispose
+ }
+ }
+
+ private void OnValueChanged(object? sender, SubtagValueChange change)
+ {
+ if (disposed || change is null)
+ {
+ return;
+ }
+
+ IReadOnlyList transitions =
+ stateMachine.Apply(change.ItemAddress, change.Value, change.TimestampUtc);
+ if (transitions.Count == 0)
+ {
+ return;
+ }
+
+ EventHandler? handler = AlarmTransitionEmitted;
+ foreach (MxAlarmTransitionEvent transition in transitions)
+ {
+ StampSynthetic(transition.Record);
+ handler?.Invoke(this, transition);
+ }
+ }
+
+ ///
+ /// Stamps the degraded flag and synthetic GUID onto a synthesized
+ /// record. The state machine sets only the provider / group / tag-name
+ /// parts, so the alarm reference is recomposed exactly as
+ /// composes it — keying the synthetic GUID
+ /// off that recomposed reference keeps it stable across transitions and
+ /// snapshots for the same alarm.
+ ///
+ private static void StampSynthetic(MxAlarmSnapshotRecord record)
+ {
+ record.Degraded = true;
+ string reference = AlarmRecordTransitionMapper.ComposeFullReference(
+ record.ProviderName, record.Group, record.TagName);
+ record.AlarmGuid = SyntheticAlarmGuid.ForReference(reference);
+ }
+
+ private AlarmSubtagTarget? ResolveTargetByName(string? alarmName)
+ {
+ if (string.IsNullOrEmpty(alarmName))
+ {
+ return null;
+ }
+
+ // Match a target whose full reference contains the supplied name. The
+ // dispatcher derives (name, provider, group) from the composed
+ // reference, so the object-rooted tag name is always a substring of the
+ // target's AlarmFullReference.
+ foreach (AlarmSubtagTarget target in targetsByReference.Values)
+ {
+ string reference = target.AlarmFullReference ?? string.Empty;
+ if (reference.IndexOf(alarmName!, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return target;
+ }
+ }
+
+ return null;
+ }
+
+ private int WriteAckComment(AlarmSubtagTarget target, string ackComment)
+ {
+ string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty;
+ if (string.IsNullOrEmpty(ackCommentSubtag))
+ {
+ return -1;
+ }
+
+ source.Write(ackCommentSubtag, ackComment);
+ return 0;
+ }
+
+ private static void AddIfNotEmpty(List addresses, string? address)
+ {
+ if (!string.IsNullOrEmpty(address))
+ {
+ addresses.Add(address!);
+ }
+ }
+}