diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
new file mode 100644
index 00000000..9cea1f3c
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
@@ -0,0 +1,52 @@
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+
+///
+/// Derives a full Part 9 from each native
+/// delta, tracking per-condition-NodeId prior state. Owned by the
+/// single-threaded DriverHostActor (no locking). Native alarms carry only a transition
+/// , not a full state machine, so this is the translation the
+/// scripted-alarm engine does internally.
+///
+public sealed class NativeAlarmProjector
+{
+ private readonly Dictionary _prior =
+ new(StringComparer.Ordinal);
+
+ /// Project an alarm transition onto the full condition snapshot for .
+ /// The materialised condition node's id (the projection's state key).
+ /// The native alarm transition.
+ /// The full Part 9 condition snapshot to write to the node.
+ public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
+ {
+ var prev = _prior.TryGetValue(nodeId, out var p)
+ ? p
+ : (Active: false, Acked: true, Severity: (ushort)0, Message: string.Empty);
+ var sev = MapSeverity(e.Severity);
+ var (active, acked) = e.Kind switch
+ {
+ AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
+ AlarmTransitionKind.Acknowledge => (prev.Active, true),
+ AlarmTransitionKind.Clear => (false, prev.Acked),
+ _ => (prev.Active, prev.Acked),
+ };
+ _prior[nodeId] = (active, acked, sev, e.Message);
+ return new AlarmConditionSnapshot(
+ Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
+ Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
+ }
+
+ /// Clears all tracked per-node state (call on address-space rebuild).
+ public void Clear() => _prior.Clear();
+
+ private static ushort MapSeverity(AlarmSeverity s) => s switch
+ {
+ AlarmSeverity.Low => 200,
+ AlarmSeverity.Medium => 500,
+ AlarmSeverity.High => 700,
+ AlarmSeverity.Critical => 900,
+ _ => 500,
+ };
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
new file mode 100644
index 00000000..5492a3f6
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
@@ -0,0 +1,166 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+public class NativeAlarmProjectorTests
+{
+ [Fact]
+ public void Raise_projects_active_unacked_with_constant_fields_and_mapped_severity()
+ {
+ var sut = new NativeAlarmProjector();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "boom"));
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeFalse();
+ snap.Confirmed.ShouldBeTrue();
+ snap.Enabled.ShouldBeTrue();
+ snap.Shelving.ShouldBe(AlarmShelvingKind.Unshelved);
+ snap.Severity.ShouldBe((ushort)700);
+ snap.Message.ShouldBe("boom");
+ }
+
+ [Fact]
+ public void Retrigger_projects_active_unacked()
+ {
+ var sut = new NativeAlarmProjector();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Acknowledge_after_raise_acks_and_keeps_active()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Clear_after_raise_deactivates_and_keeps_prior_ack()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
+
+ snap.Active.ShouldBeFalse();
+ snap.Acknowledged.ShouldBeFalse(); // preserved prior (unacked) ack state
+ }
+
+ [Fact]
+ public void Clear_after_acknowledge_deactivates_and_keeps_acked()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+ sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); // active, acked
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
+
+ snap.Active.ShouldBeFalse();
+ snap.Acknowledged.ShouldBeTrue(); // preserved prior (acked) ack state
+ }
+
+ [Fact]
+ public void Unspecified_carries_prior_active_and_ack_state()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified));
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Cold_state_defaults_to_inactive_acked()
+ {
+ var sut = new NativeAlarmProjector();
+
+ // Acknowledge on a never-seen node: prior Active defaults false, ack set true.
+ var snap = sut.Project("nNew", Evt(AlarmTransitionKind.Acknowledge));
+
+ snap.Active.ShouldBeFalse();
+ snap.Acknowledged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Per_node_state_is_isolated()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active
+
+ // n2 is cold; a Clear on it must stay inactive and not borrow n1's active state.
+ var n2 = sut.Project("n2", Evt(AlarmTransitionKind.Clear));
+
+ n2.Active.ShouldBeFalse();
+
+ // n1 is unaffected by the n2 transition.
+ var n1 = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
+ n1.Active.ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData(AlarmSeverity.Low, (ushort)200)]
+ [InlineData(AlarmSeverity.Medium, (ushort)500)]
+ [InlineData(AlarmSeverity.High, (ushort)700)]
+ [InlineData(AlarmSeverity.Critical, (ushort)900)]
+ public void Severity_buckets_map_to_opcua_scale(AlarmSeverity sev, ushort expected)
+ {
+ var sut = new NativeAlarmProjector();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, sev));
+
+ snap.Severity.ShouldBe(expected);
+ }
+
+ [Fact]
+ public void Severity_and_message_always_come_from_the_event()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "first"));
+
+ // A later Clear carries its own severity + message; the snapshot reflects the event, not prior.
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear, AlarmSeverity.Low, "second"));
+
+ snap.Severity.ShouldBe((ushort)200);
+ snap.Message.ShouldBe("second");
+ }
+
+ [Fact]
+ public void Clear_resets_tracked_state()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active, unacked
+
+ sut.Clear();
+
+ // After Clear() the node is cold again: an Acknowledge sees prior Active=false.
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
+ snap.Active.ShouldBeFalse();
+ snap.Acknowledged.ShouldBeTrue();
+ }
+
+ private static AlarmEventArgs Evt(
+ AlarmTransitionKind kind,
+ AlarmSeverity sev = AlarmSeverity.High,
+ string msg = "m")
+ => new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
+
+ private sealed class H : IAlarmSubscriptionHandle
+ {
+ public string DiagnosticId => "t";
+ }
+}