diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs
new file mode 100644
index 0000000..0b61759
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs
@@ -0,0 +1,91 @@
+using System;
+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 the subtag-fallback synthesis state machine. The machine
+/// consumes normalized subtag value changes (active/acked/priority) and
+/// emits records mirroring the wnwrap
+/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions. No COM
+/// or AVEVA install is required.
+///
+public sealed class SubtagAlarmStateMachineTests
+{
+ private static AlarmSubtagTarget Target() => new()
+ {
+ AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
+ SourceObjectReference = "Tank01",
+ ActiveSubtag = "Tank01.Level.HiHi.active",
+ AckedSubtag = "Tank01.Level.HiHi.acked",
+ AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
+ };
+
+ [Fact]
+ public void ActiveFalseToTrue_EmitsRaise()
+ {
+ var sm = new SubtagAlarmStateMachine(new[] { Target() });
+ var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
+ var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
+ var e = Assert.Single(events);
+ Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
+ Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
+ Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
+ }
+
+ [Fact]
+ public void AckedTrueWhileActive_EmitsAck()
+ {
+ var sm = new SubtagAlarmStateMachine(new[] { Target() });
+ var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
+ sm.Apply("Tank01.Level.HiHi.active", true, ts);
+ var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
+ var e = Assert.Single(events);
+ Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
+ Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
+ }
+
+ [Fact]
+ public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
+ {
+ var sm = new SubtagAlarmStateMachine(new[] { Target() });
+ var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
+ sm.Apply("Tank01.Level.HiHi.active", true, ts);
+ var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
+ var e = Assert.Single(events);
+ Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
+ }
+
+ [Fact]
+ public void ActiveTrueToFalse_WhileAcked_EmitsAckRtn()
+ {
+ var sm = new SubtagAlarmStateMachine(new[] { Target() });
+ var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
+ sm.Apply("Tank01.Level.HiHi.active", true, ts);
+ sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
+ var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
+ var e = Assert.Single(events);
+ Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
+ }
+
+ [Fact]
+ public void Snapshot_ReflectsActiveAndAckedState()
+ {
+ var sm = new SubtagAlarmStateMachine(new[] { Target() });
+ var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
+ sm.Apply("Tank01.Level.HiHi.active", true, ts);
+ sm.Apply("Tank01.Level.HiHi.acked", true, ts);
+ var snap = Assert.Single(sm.SnapshotActive());
+ Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
+ }
+
+ [Fact]
+ public void UnknownAddress_NoEvents()
+ {
+ var sm = new SubtagAlarmStateMachine(new[] { Target() });
+ var events = sm.Apply("Some.Other.Tag.active", true, DateTime.UtcNow);
+ Assert.Empty(events);
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs
new file mode 100644
index 0000000..7ad0576
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs
@@ -0,0 +1,34 @@
+namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
+
+/// A change in one advised subtag value, normalized off the COM boundary.
+public sealed class SubtagValueChange
+{
+ /// Gets the MXAccess item address (subtag reference) whose value changed.
+ public string ItemAddress { get; init; } = string.Empty;
+
+ /// Gets the new value, as delivered by MXAccess (boxed COM variant or test value).
+ public object? Value { get; init; }
+
+ /// Gets the UTC timestamp the change was observed.
+ public DateTime TimestampUtc { get; init; }
+}
+
+///
+/// Advises a set of MXAccess subtag addresses and surfaces value changes.
+/// The production implementation owns its own LMXProxyServerClass (later task);
+/// tests substitute a fake that pushes SubtagValueChange values.
+///
+public interface ISubtagAlarmSource : System.IDisposable
+{
+ /// Raised when an advised subtag reports a new value.
+ event System.EventHandler? ValueChanged;
+
+ /// Begins advising the supplied MXAccess subtag item addresses.
+ /// The subtag references to advise.
+ void Advise(System.Collections.Generic.IReadOnlyCollection itemAddresses);
+
+ /// Writes a value to the named MXAccess subtag (e.g. an ack request).
+ /// The subtag reference to write.
+ /// The value to write.
+ void Write(string itemAddress, object? value);
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs
new file mode 100644
index 0000000..6ff12c9
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs
@@ -0,0 +1,326 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+
+namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
+
+///
+/// Synthesizes alarm transitions from raw subtag value changes when the
+/// native alarmmgr (wnwrap) consumer is unavailable. Each configured
+/// contributes an .active,
+/// .acked, optional .priority, and optional ack-comment
+/// subtag; the machine tracks per-alarm state and emits
+/// records mirroring the wnwrap
+/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions.
+/// This type performs no COM work and is fully unit-testable.
+///
+public sealed class SubtagAlarmStateMachine
+{
+ private static readonly IReadOnlyList NoEvents = Array.Empty();
+
+ private readonly Dictionary _statesByReference = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _bindingsByAddress = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Initializes the machine from the configured alarm subtag targets.
+ /// The alarm targets whose subtags will be observed.
+ public SubtagAlarmStateMachine(IEnumerable targets)
+ {
+ if (targets is null)
+ {
+ throw new ArgumentNullException(nameof(targets));
+ }
+
+ foreach (AlarmSubtagTarget target in targets)
+ {
+ var state = new AlarmState(target);
+ _statesByReference[target.AlarmFullReference] = state;
+
+ Bind(target.ActiveSubtag, state, SubtagRole.Active);
+ Bind(target.AckedSubtag, state, SubtagRole.Acked);
+ Bind(target.PrioritySubtag, state, SubtagRole.Priority);
+ }
+ }
+
+ ///
+ /// Applies a single subtag value change and returns any synthesized
+ /// transitions. Unknown addresses and no-op changes return an empty list.
+ ///
+ /// The subtag address whose value changed.
+ /// The new value (bool, numeric, or string).
+ /// The UTC timestamp of the change.
+ /// The synthesized alarm transitions, if any.
+ public IReadOnlyList Apply(string itemAddress, object? value, DateTime timestampUtc)
+ {
+ if (itemAddress is null || !_bindingsByAddress.TryGetValue(itemAddress, out SubtagBinding binding))
+ {
+ return NoEvents;
+ }
+
+ AlarmState state = binding.State;
+ switch (binding.Role)
+ {
+ case SubtagRole.Active:
+ return ApplyActive(state, CoerceBool(value), timestampUtc);
+ case SubtagRole.Acked:
+ return ApplyAcked(state, CoerceBool(value), timestampUtc);
+ case SubtagRole.Priority:
+ state.Priority = CoerceInt(value, state.Priority);
+ return NoEvents;
+ default:
+ return NoEvents;
+ }
+ }
+
+ ///
+ /// Returns one snapshot record per currently-active alarm, with state
+ /// reflecting whether the alarm has been acknowledged.
+ ///
+ /// The active alarm snapshot records.
+ public IReadOnlyList SnapshotActive()
+ {
+ var records = new List();
+ foreach (AlarmState state in _statesByReference.Values)
+ {
+ if (!state.Active)
+ {
+ continue;
+ }
+
+ MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckAlm : MxAlarmStateKind.UnackAlm;
+ records.Add(state.BuildRecord(kind, state.FirstRaiseUtc));
+ }
+
+ return records;
+ }
+
+ private IReadOnlyList ApplyActive(AlarmState state, bool newActive, DateTime timestampUtc)
+ {
+ if (newActive == state.Active)
+ {
+ return NoEvents;
+ }
+
+ if (newActive)
+ {
+ // false -> true: alarm raised, initially unacknowledged.
+ MxAlarmStateKind previous = state.DerivedState();
+ state.Active = true;
+ state.Acked = false;
+ state.FirstRaiseUtc = timestampUtc;
+ return Single(state, MxAlarmStateKind.UnackAlm, previous, timestampUtc);
+ }
+ else
+ {
+ // true -> false: alarm returned to normal. State depends on ack.
+ MxAlarmStateKind previous = state.DerivedState();
+ MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn;
+ state.Active = false;
+ state.Acked = false;
+ return Single(state, kind, previous, timestampUtc);
+ }
+ }
+
+ private IReadOnlyList ApplyAcked(AlarmState state, bool newAcked, DateTime timestampUtc)
+ {
+ if (newAcked == state.Acked)
+ {
+ return NoEvents;
+ }
+
+ state.Acked = newAcked;
+
+ // Only a true ack of a currently-active alarm produces an ACK_ALM transition.
+ if (newAcked && state.Active)
+ {
+ return Single(state, MxAlarmStateKind.AckAlm, MxAlarmStateKind.UnackAlm, timestampUtc);
+ }
+
+ return NoEvents;
+ }
+
+ private static IReadOnlyList Single(
+ AlarmState state,
+ MxAlarmStateKind kind,
+ MxAlarmStateKind previous,
+ DateTime timestampUtc)
+ {
+ return new[]
+ {
+ new MxAlarmTransitionEvent
+ {
+ Record = state.BuildRecord(kind, timestampUtc),
+ PreviousState = previous,
+ },
+ };
+ }
+
+ private void Bind(string? address, AlarmState state, SubtagRole role)
+ {
+ if (!string.IsNullOrEmpty(address))
+ {
+ _bindingsByAddress[address!] = new SubtagBinding(state, role);
+ }
+ }
+
+ private static bool CoerceBool(object? value)
+ {
+ switch (value)
+ {
+ case null:
+ return false;
+ case bool b:
+ return b;
+ case string s:
+ if (bool.TryParse(s, out bool parsedBool))
+ {
+ return parsedBool;
+ }
+
+ if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out double parsedNum))
+ {
+ return parsedNum != 0d;
+ }
+
+ return false;
+ default:
+ try
+ {
+ return Convert.ToDouble(value, CultureInfo.InvariantCulture) != 0d;
+ }
+ catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
+ {
+ return false;
+ }
+ }
+ }
+
+ private static int CoerceInt(object? value, int fallback)
+ {
+ switch (value)
+ {
+ case null:
+ return fallback;
+ case int i:
+ return i;
+ case string s when int.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out int parsed):
+ return parsed;
+ case string:
+ return fallback;
+ default:
+ try
+ {
+ return Convert.ToInt32(value, CultureInfo.InvariantCulture);
+ }
+ catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
+ {
+ return fallback;
+ }
+ }
+ }
+
+ private enum SubtagRole
+ {
+ Active,
+ Acked,
+ Priority,
+ }
+
+ private readonly struct SubtagBinding
+ {
+ public SubtagBinding(AlarmState state, SubtagRole role)
+ {
+ State = state;
+ Role = role;
+ }
+
+ public AlarmState State { get; }
+
+ public SubtagRole Role { get; }
+ }
+
+ private sealed class AlarmState
+ {
+ private readonly string _providerName;
+ private readonly string _group;
+ private readonly string _tagName;
+
+ public AlarmState(AlarmSubtagTarget target)
+ {
+ (_providerName, _group, _tagName) = DeriveNameParts(target);
+ }
+
+ public bool Active { get; set; }
+
+ public bool Acked { get; set; }
+
+ public int Priority { get; set; }
+
+ public DateTime FirstRaiseUtc { get; set; }
+
+ /// Derives the current alarm state from the tracked flags, before a transition is applied.
+ public MxAlarmStateKind DerivedState()
+ {
+ if (!Active)
+ {
+ return MxAlarmStateKind.Unspecified;
+ }
+
+ return Acked ? MxAlarmStateKind.AckAlm : MxAlarmStateKind.UnackAlm;
+ }
+
+ public MxAlarmSnapshotRecord BuildRecord(MxAlarmStateKind kind, DateTime timestampUtc)
+ {
+ return new MxAlarmSnapshotRecord
+ {
+ ProviderName = _providerName,
+ Group = _group,
+ TagName = _tagName,
+ Priority = Priority,
+ State = kind,
+ TransitionTimestampUtc = timestampUtc,
+ };
+ }
+
+ ///
+ /// Splits an alarm full reference such as Galaxy!Area.Tank01.Level.HiHi
+ /// into provider (Galaxy), group/area (Area), and the object-rooted
+ /// tag name (Tank01.Level.HiHi). The tag name is anchored on the target's
+ /// when present, so any
+ /// leading provider and area segments are stripped.
+ ///
+ private static (string ProviderName, string Group, string TagName) DeriveNameParts(AlarmSubtagTarget target)
+ {
+ string reference = target.AlarmFullReference ?? string.Empty;
+
+ string providerName = string.Empty;
+ string afterBang = reference;
+ int bang = reference.IndexOf('!');
+ if (bang >= 0)
+ {
+ providerName = reference.Substring(0, bang);
+ afterBang = reference.Substring(bang + 1);
+ }
+
+ string tagName = afterBang;
+ string source = target.SourceObjectReference ?? string.Empty;
+ string group = string.Empty;
+ if (source.Length > 0)
+ {
+ int objIndex = afterBang.IndexOf(source, StringComparison.OrdinalIgnoreCase);
+ if (objIndex > 0)
+ {
+ // Everything before the object (minus a trailing '.') is the area/group.
+ group = afterBang.Substring(0, objIndex).TrimEnd('.');
+ tagName = afterBang.Substring(objIndex);
+ }
+ else if (objIndex == 0)
+ {
+ tagName = afterBang;
+ }
+ }
+
+ return (providerName, group, tagName);
+ }
+ }
+}